102 KiB
Supervision-RS Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Créer SuperVisionRust/ — un exécutable Windows autonome en Rust avec parité complète avec l'app Python (dashboard web, alertes email, service Windows).
Architecture: Axum 0.7 (HTTP) + Tera (templates) + sysinfo (métriques) + lettre (email) + windows-service (service Windows). AppState partagé via Arc<RwLock<>> entre le thread de monitoring (tokio task) et les routes HTTP. Flash messages stockés en session. Templates Jinja2 portés en Tera (syntaxe quasi-identique).
Tech Stack: Rust 1.78+, axum 0.7, tokio 1, tera 1, sysinfo 0.32, lettre 0.11, tower-sessions 0.12, tower_governor 0.4, bcrypt 0.15, serde_json 1, windows-service 0.7 (Windows only)
Structure des fichiers
SuperVisionRust/ ← dossier frère de supervision/
├── Cargo.toml
├── src/
│ ├── main.rs # point d'entrée + service Windows
│ ├── config.rs # structs Config + ConfigManager (JSON)
│ ├── monitor.rs # SystemMonitor (sysinfo + seuils + alertes)
│ ├── alerter.rs # EmailAlerter (lettre SMTP)
│ ├── user_monitor.rs # UserMonitor (parsing logs Amadea)
│ └── routes/
│ ├── mod.rs # AppState + flash helpers + AuthUser extractor
│ ├── auth.rs # GET/POST /login, GET /logout
│ ├── dashboard.rs # GET /, GET /api/metrics, POST /api/monitoring/toggle
│ ├── settings.rs # GET /settings + tous les POST /settings/*
│ ├── alerts.rs # GET /alerts, POST /alerts/clear
│ └── users.rs # GET /users, GET /api/users, GET /api/users/activity/weekly
├── templates/
│ ├── base.html
│ ├── login.html
│ ├── dashboard.html
│ ├── settings.html
│ ├── alerts.html
│ └── users.html
└── static/
└── style.css
Notes de portage Jinja2 → Tera
| Jinja2 | Tera |
|---|---|
url_for('dashboard') |
/ |
url_for('settings') |
/settings |
url_for('alerts') |
/alerts |
url_for('users') |
/users |
url_for('login') |
/login |
url_for('logout') |
/logout |
url_for('toggle_monitoring') |
/api/monitoring/toggle |
url_for('clear_alerts') |
/alerts/clear |
url_for('update_thresholds') |
/settings/thresholds |
url_for('update_monitoring') |
/settings/monitoring |
url_for('update_smtp') |
/settings/smtp |
url_for('test_smtp') |
/settings/smtp/test |
url_for('update_processes') |
/settings/processes |
url_for('update_password') |
/settings/password |
url_for('update_port') |
/settings/port |
url_for('update_amadea_log_path') |
/settings/amadea-log-path |
url_for('update_user_thresholds') |
/settings/user-thresholds |
url_for('static', filename='style.css') |
/static/style.css |
get_flashed_messages(with_categories=true) |
variable flash_messages dans le contexte |
current_user.is_authenticated |
variable is_authenticated dans le contexte |
request.endpoint == 'dashboard' |
variable active_page == "dashboard" |
| join(', ') |
| join(sep=", ") |
| replace('T', ' ') |
| replace(from="T", to=" ") |
{% with %}...{% endwith %} |
{% set %} ou direct |
loop.index0 |
loop.index0 (identique) |
Task 1: Scaffold — Cargo.toml + structure vide
Files:
-
Create:
../SuperVisionRust/Cargo.toml -
Create:
../SuperVisionRust/src/main.rs -
Create:
../SuperVisionRust/src/config.rs -
Create:
../SuperVisionRust/src/monitor.rs -
Create:
../SuperVisionRust/src/alerter.rs -
Create:
../SuperVisionRust/src/user_monitor.rs -
Create:
../SuperVisionRust/src/routes/mod.rs -
Create:
../SuperVisionRust/src/routes/auth.rs -
Create:
../SuperVisionRust/src/routes/dashboard.rs -
Create:
../SuperVisionRust/src/routes/settings.rs -
Create:
../SuperVisionRust/src/routes/alerts.rs -
Create:
../SuperVisionRust/src/routes/users.rs -
Step 1: Créer le dossier SuperVisionRust
cd "/Users/oussi/Documents/Documents - MacBook Pro de oussi/EttaSante/monitoring"
mkdir SuperVisionRust
cd SuperVisionRust
mkdir -p src/routes templates static
- Step 2: Créer Cargo.toml
[package]
name = "supervision"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "supervision"
path = "src/main.rs"
[dependencies]
axum = { version = "0.7", features = ["macros", "form"] }
tokio = { version = "1", features = ["full"] }
tera = "1"
sysinfo = "0.32"
lettre = { version = "0.11", features = ["tokio1-native-tls", "builder"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-sessions = { version = "0.12", features = ["memory-store"] }
tower = "0.4"
tower_governor = "0.4"
tower-http = { version = "0.5", features = ["fs"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
bcrypt = "0.15"
chrono = { version = "0.4", features = ["serde"] }
rand = "0.8"
async-trait = "0.1"
http = "1"
regex = "1"
glob = "0.3"
[target.'cfg(windows)'.dependencies]
windows-service = "0.7"
- Step 3: Créer src/main.rs minimal
mod config;
mod monitor;
mod alerter;
mod user_monitor;
mod routes;
fn main() {
println!("Supervision");
}
- Step 4: Créer les fichiers modules vides
src/config.rs :
// Config module — à implémenter dans Task 2
src/monitor.rs :
// Monitor module — à implémenter dans Task 4
src/alerter.rs :
// Alerter module — à implémenter dans Task 3
src/user_monitor.rs :
// UserMonitor module — à implémenter dans Task 5
src/routes/mod.rs :
pub mod auth;
pub mod dashboard;
pub mod settings;
pub mod alerts;
pub mod users;
src/routes/auth.rs, src/routes/dashboard.rs, src/routes/settings.rs, src/routes/alerts.rs, src/routes/users.rs : fichiers vides avec // TODO.
- Step 5: Vérifier que ça compile
cd SuperVisionRust
cargo check
Expected: Finished sans erreurs (quelques warnings unused sont OK).
- Step 6: Commit
git init
git add .
git commit -m "feat: scaffold projet supervision-rs"
Task 2: Config module
Files:
-
Modify:
src/config.rs -
Test: dans
src/config.rssection#[cfg(test)] -
Step 1: Écrire le test
Ajouter à la fin de src/config.rs :
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_has_expected_values() {
let cfg = Config::default();
assert_eq!(cfg.port, 5000);
assert_eq!(cfg.thresholds.cpu_percent, 90.0);
assert_eq!(cfg.thresholds.ram_percent, 85.0);
assert_eq!(cfg.thresholds.disk_percent, 90.0);
assert_eq!(cfg.check_interval_minutes, 1);
assert_eq!(cfg.alert_cooldown_minutes, 30);
assert_eq!(cfg.processes.len(), 3);
assert_eq!(cfg.admin.username, "admin");
}
#[test]
fn config_serializes_and_deserializes() {
let cfg = Config::default();
let json = serde_json::to_string(&cfg).unwrap();
let cfg2: Config = serde_json::from_str(&json).unwrap();
assert_eq!(cfg.port, cfg2.port);
assert_eq!(cfg.admin.username, cfg2.admin.username);
}
#[test]
fn save_alert_truncates_at_500() {
let dir = tempfile::tempdir().unwrap();
let mut cm = ConfigManager::new_with_dir(dir.path().to_path_buf());
for i in 0..510u32 {
cm.save_alert(Alert {
timestamp: format!("2026-01-01T00:00:{:02}", i % 60),
alert_type: "threshold".into(),
key: format!("cpu_{}", i),
message: format!("msg {}", i),
value: i as f64,
threshold: 90.0,
hostname: "test".into(),
});
}
let alerts = cm.load_alerts();
assert_eq!(alerts.len(), 500);
}
}
- Step 2: Vérifier que le test échoue (pas encore de code)
cargo test config
Expected: erreur de compilation — Config, ConfigManager, Alert pas définis.
- Step 3: Implémenter config.rs
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
const MAX_ALERTS: usize = 500;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thresholds {
pub cpu_percent: f64,
pub ram_percent: f64,
pub disk_percent: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessConfig {
pub name: String,
pub pattern: String,
pub memory_threshold_mb: f64,
pub enabled: bool,
pub alert_on_down: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SmtpConfig {
pub server: String,
pub port: u16,
pub use_tls: bool,
pub username: String,
pub password: String,
pub from_email: String,
pub to_emails: Vec<String>,
}
impl SmtpConfig {
pub fn default_port() -> u16 { 587 }
pub fn default_tls() -> bool { true }
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserStatusThresholds {
pub active_minutes: u64,
pub inactive_minutes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminConfig {
pub username: String,
pub password_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub secret_key: String,
pub port: u16,
pub check_interval_minutes: u64,
pub alert_cooldown_minutes: u64,
pub thresholds: Thresholds,
pub processes: Vec<ProcessConfig>,
pub smtp: SmtpConfig,
pub amadea_log_path: String,
pub user_status_thresholds: UserStatusThresholds,
pub admin: AdminConfig,
}
impl Default for Config {
fn default() -> Self {
Config {
secret_key: generate_secret_key(),
port: 5000,
check_interval_minutes: 1,
alert_cooldown_minutes: 30,
thresholds: Thresholds {
cpu_percent: 90.0,
ram_percent: 85.0,
disk_percent: 90.0,
},
processes: vec![
ProcessConfig {
name: "JVM".into(),
pattern: "java".into(),
memory_threshold_mb: 0.0,
enabled: true,
alert_on_down: true,
},
ProcessConfig {
name: "Nginx".into(),
pattern: "nginx".into(),
memory_threshold_mb: 0.0,
enabled: false,
alert_on_down: false,
},
ProcessConfig {
name: "Amadea Web 8 x64".into(),
pattern: "amadea".into(),
memory_threshold_mb: 0.0,
enabled: true,
alert_on_down: true,
},
],
smtp: SmtpConfig {
port: 587,
use_tls: true,
..Default::default()
},
amadea_log_path: r"C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs".into(),
user_status_thresholds: UserStatusThresholds {
active_minutes: 5,
inactive_minutes: 30,
},
admin: AdminConfig {
username: "admin".into(),
password_hash: bcrypt::hash("admin", bcrypt::DEFAULT_COST)
.unwrap_or_default(),
},
}
}
}
fn generate_secret_key() -> String {
use rand::Rng;
let mut rng = rand::thread_rng();
(0..32).map(|_| format!("{:02x}", rng.gen::<u8>())).collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Alert {
pub timestamp: String,
#[serde(rename = "type")]
pub alert_type: String,
pub key: String,
pub message: String,
pub value: f64,
pub threshold: f64,
pub hostname: String,
}
pub struct ConfigManager {
config_file: PathBuf,
alerts_file: PathBuf,
pub config: Config,
}
impl ConfigManager {
pub fn new() -> Self {
let exe_dir = std::env::current_exe()
.unwrap_or_default()
.parent()
.unwrap_or(Path::new("."))
.to_path_buf();
Self::new_with_dir(exe_dir.join("data"))
}
pub fn new_with_dir(data_dir: PathBuf) -> Self {
fs::create_dir_all(&data_dir).ok();
let config_file = data_dir.join("config.json");
let alerts_file = data_dir.join("alerts.json");
let config = if config_file.exists() {
fs::read_to_string(&config_file)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
} else {
let cfg = Config::default();
let json = serde_json::to_string_pretty(&cfg).unwrap_or_default();
fs::write(&config_file, &json).ok();
cfg
};
ConfigManager { config_file, alerts_file, config }
}
pub fn save(&self) {
let json = serde_json::to_string_pretty(&self.config).unwrap_or_default();
fs::write(&self.config_file, json).ok();
}
pub fn update(&mut self, config: Config) {
self.config = config;
self.save();
}
pub fn load_alerts(&self) -> Vec<Alert> {
if !self.alerts_file.exists() {
return vec![];
}
fs::read_to_string(&self.alerts_file)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save_alert(&self, alert: Alert) {
let mut alerts = self.load_alerts();
alerts.insert(0, alert);
alerts.truncate(MAX_ALERTS);
let json = serde_json::to_string_pretty(&alerts).unwrap_or_default();
fs::write(&self.alerts_file, json).ok();
}
pub fn clear_alerts(&self) {
fs::write(&self.alerts_file, "[]").ok();
}
}
Ajouter tempfile = "3" dans [dev-dependencies] de Cargo.toml :
[dev-dependencies]
tempfile = "3"
- Step 4: Lancer les tests
cargo test config
Expected: 3 tests passent.
- Step 5: Commit
git add src/config.rs Cargo.toml
git commit -m "feat: config module avec structs et persistence JSON"
Task 3: Alerter module
Files:
-
Modify:
src/alerter.rs -
Test: dans
src/alerter.rssection#[cfg(test)] -
Step 1: Écrire le test
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SmtpConfig;
#[test]
fn not_configured_when_server_empty() {
let smtp = SmtpConfig::default();
assert!(!is_smtp_configured(&smtp));
}
#[test]
fn configured_when_all_fields_present() {
let smtp = SmtpConfig {
server: "smtp.example.com".into(),
port: 587,
use_tls: true,
username: "user".into(),
password: "pass".into(),
from_email: "from@example.com".into(),
to_emails: vec!["to@example.com".into()],
};
assert!(is_smtp_configured(&smtp));
}
#[test]
fn not_configured_when_no_recipients() {
let smtp = SmtpConfig {
server: "smtp.example.com".into(),
port: 587,
use_tls: true,
username: "user".into(),
password: "pass".into(),
from_email: "from@example.com".into(),
to_emails: vec![],
};
assert!(!is_smtp_configured(&smtp));
}
}
- Step 2: Vérifier que les tests échouent
cargo test alerter
Expected: erreur de compilation — is_smtp_configured pas défini.
- Step 3: Implémenter alerter.rs
use crate::config::SmtpConfig;
use lettre::{
message::header::ContentType,
transport::smtp::{
authentication::Credentials,
client::{Tls, TlsParameters},
},
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
};
pub fn is_smtp_configured(smtp: &SmtpConfig) -> bool {
!smtp.server.is_empty() && !smtp.from_email.is_empty() && !smtp.to_emails.is_empty()
}
pub struct Alerter;
impl Alerter {
pub async fn send(&self, smtp: &SmtpConfig, subject: &str, body: &str) -> (bool, String) {
if !is_smtp_configured(smtp) {
return (false, "SMTP non configure".into());
}
match self.send_email(smtp, subject, body).await {
Ok(msg) => (true, msg),
Err(e) => (false, e),
}
}
async fn send_email(
&self,
smtp: &SmtpConfig,
subject: &str,
body: &str,
) -> Result<String, String> {
let from = smtp
.from_email
.parse()
.map_err(|_| "Email expediteur invalide".to_string())?;
let mut builder = Message::builder().from(from).subject(subject);
for recipient in &smtp.to_emails {
let mb = recipient
.parse()
.map_err(|_| format!("Destinataire invalide: {}", recipient))?;
builder = builder.to(mb);
}
let email = builder
.header(ContentType::TEXT_PLAIN)
.body(body.to_string())
.map_err(|e| e.to_string())?;
let transport = self.build_transport(smtp)?;
transport
.send(email)
.await
.map(|_| "Email envoye avec succes".to_string())
.map_err(|e| format!("Erreur SMTP: {}", e))
}
fn build_transport(
&self,
smtp: &SmtpConfig,
) -> Result<AsyncSmtpTransport<Tokio1Executor>, String> {
let mut builder = if smtp.use_tls {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&smtp.server)
.map_err(|e| e.to_string())?
} else {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&smtp.server)
};
builder = builder.port(smtp.port);
if !smtp.username.is_empty() {
builder = builder.credentials(Credentials::new(
smtp.username.clone(),
smtp.password.clone(),
));
}
Ok(builder.build())
}
pub async fn send_test(&self, smtp: &SmtpConfig) -> (bool, String) {
let subject = "[TEST] Supervision - Test de configuration email";
let body = "Ceci est un email de test.\n\nSi vous recevez ce message, la configuration SMTP est correcte.\n\n-- Supervision";
self.send(smtp, subject, body).await
}
}
- Step 4: Lancer les tests
cargo test alerter
Expected: 3 tests passent.
- Step 5: Commit
git add src/alerter.rs
git commit -m "feat: alerter module SMTP avec lettre"
Task 4: Monitor module
Files:
-
Modify:
src/monitor.rs -
Test: dans
src/monitor.rssection#[cfg(test)] -
Step 1: Écrire les tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn eval_status_ok_below_80_percent() {
assert_eq!(eval_status(70.0, 90.0), "ok");
}
#[test]
fn eval_status_warning_at_80_percent_of_threshold() {
assert_eq!(eval_status(72.0, 90.0), "warning"); // 72/90 = 0.8
}
#[test]
fn eval_status_critical_at_threshold() {
assert_eq!(eval_status(90.0, 90.0), "critical");
}
#[test]
fn eval_status_critical_above_threshold() {
assert_eq!(eval_status(95.0, 90.0), "critical");
}
#[test]
fn eval_status_ok_with_zero_threshold() {
assert_eq!(eval_status(50.0, 0.0), "ok");
}
}
- Step 2: Vérifier que les tests échouent
cargo test monitor
Expected: erreur de compilation.
- Step 3: Implémenter monitor.rs
use crate::alerter::Alerter;
use crate::config::{Alert, ConfigManager, ProcessConfig};
use chrono::{DateTime, Duration, Local};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration as StdDuration, Instant};
use sysinfo::{CpuRefreshKind, Disks, MemoryRefreshKind, ProcessRefreshKind, RefreshKind, System};
use tokio::sync::Mutex as AsyncMutex;
pub fn eval_status(value: f64, threshold: f64) -> &'static str {
if threshold <= 0.0 {
return "ok";
}
let ratio = value / threshold;
if ratio >= 1.0 {
"critical"
} else if ratio >= 0.80 {
"warning"
} else {
"ok"
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CpuMetrics {
pub percent: f64,
pub cores: usize,
pub threshold: f64,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RamMetrics {
pub percent: f64,
pub total_gb: f64,
pub used_gb: f64,
pub available_gb: f64,
pub threshold: f64,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiskMetrics {
pub drive: String,
pub mountpoint: String,
pub percent: f64,
pub total_gb: f64,
pub used_gb: f64,
pub free_gb: f64,
pub threshold: f64,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessMetrics {
pub name: String,
pub pattern: String,
pub running: bool,
pub enabled: bool,
pub alert_on_down: bool,
pub instance_count: usize,
pub total_memory_mb: f64,
pub total_cpu_percent: f64,
pub memory_threshold_mb: f64,
pub memory_status: String,
pub pids: Vec<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Metrics {
pub timestamp: String,
pub hostname: String,
pub os: String,
pub cpu: CpuMetrics,
pub ram: RamMetrics,
pub disks: Vec<DiskMetrics>,
pub processes: Vec<ProcessMetrics>,
pub uptime: String,
pub boot_time: String,
pub monitoring_active: bool,
pub last_check: String,
pub next_check: String,
}
pub struct SystemMonitor {
config_manager: Arc<AsyncMutex<ConfigManager>>,
alerter: Arc<Alerter>,
pub metrics: Arc<RwLock<Option<Metrics>>>,
pub running: Arc<std::sync::atomic::AtomicBool>,
last_alerts: Arc<Mutex<HashMap<String, DateTime<Local>>>>,
}
impl SystemMonitor {
pub fn new(
config_manager: Arc<AsyncMutex<ConfigManager>>,
alerter: Arc<Alerter>,
) -> Self {
SystemMonitor {
config_manager,
alerter,
metrics: Arc::new(RwLock::new(None)),
running: Arc::new(std::sync::atomic::AtomicBool::new(false)),
last_alerts: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn collect(&self) -> Metrics {
let config = {
let cm = self.config_manager.lock().await;
cm.config.clone()
};
let mut sys = System::new_with_specifics(
RefreshKind::new()
.with_cpu(CpuRefreshKind::everything())
.with_memory(MemoryRefreshKind::everything()),
);
std::thread::sleep(StdDuration::from_millis(500));
sys.refresh_all();
let cpu_percent = sys.global_cpu_usage() as f64;
let cpu_status = eval_status(cpu_percent, config.thresholds.cpu_percent).to_string();
let ram_total = sys.total_memory() as f64;
let ram_used = sys.used_memory() as f64;
let ram_available = sys.available_memory() as f64;
let ram_percent = if ram_total > 0.0 { ram_used / ram_total * 100.0 } else { 0.0 };
let ram_status = eval_status(ram_percent, config.thresholds.ram_percent).to_string();
let mut disks = Vec::new();
let disk_list = Disks::new_with_refreshed_list();
let ignored_fs = ["squashfs", "tmpfs", "devtmpfs", "overlay", "iso9660"];
for disk in &disk_list {
let fs = disk.file_system().to_string_lossy().to_lowercase();
if ignored_fs.iter().any(|&f| fs.contains(f)) { continue; }
let total = disk.total_space() as f64;
if total < 1_073_741_824.0 { continue; } // < 1 GB
let available = disk.available_space() as f64;
let used = total - available;
let percent = (used / total * 100.0).round() / 10.0 * 10.0;
let percent = (used / total * 100.0 * 10.0).round() / 10.0;
let status = eval_status(percent, config.thresholds.disk_percent).to_string();
disks.push(DiskMetrics {
drive: disk.name().to_string_lossy().trim_end_matches('\\').to_string(),
mountpoint: disk.mount_point().to_string_lossy().to_string(),
percent,
total_gb: (total / 1_073_741_824.0 * 10.0).round() / 10.0,
used_gb: (used / 1_073_741_824.0 * 10.0).round() / 10.0,
free_gb: (available / 1_073_741_824.0 * 10.0).round() / 10.0,
threshold: config.thresholds.disk_percent,
status,
});
}
let processes = self.check_processes(&sys, &config.processes);
let boot_time = System::boot_time();
let now_unix = chrono::Local::now().timestamp() as u64;
let uptime_secs = now_unix.saturating_sub(boot_time);
let uptime = format!(
"{}:{:02}:{:02}",
uptime_secs / 3600,
(uptime_secs % 3600) / 60,
uptime_secs % 60
);
let now = chrono::Local::now();
let interval = config.check_interval_minutes;
Metrics {
timestamp: now.to_rfc3339(),
hostname: System::host_name().unwrap_or_else(|| "inconnu".into()),
os: format!("{} {}", System::name().unwrap_or_default(), System::os_version().unwrap_or_default()),
cpu: CpuMetrics {
percent: (cpu_percent * 10.0).round() / 10.0,
cores: sys.cpus().len(),
threshold: config.thresholds.cpu_percent,
status: cpu_status,
},
ram: RamMetrics {
percent: (ram_percent * 10.0).round() / 10.0,
total_gb: (ram_total / 1_073_741_824.0 * 10.0).round() / 10.0,
used_gb: (ram_used / 1_073_741_824.0 * 10.0).round() / 10.0,
available_gb: (ram_available / 1_073_741_824.0 * 10.0).round() / 10.0,
threshold: config.thresholds.ram_percent,
status: ram_status,
},
disks,
processes,
uptime,
boot_time: chrono::DateTime::from_timestamp(boot_time as i64, 0)
.map(|dt: chrono::DateTime<chrono::Utc>| dt.to_rfc3339())
.unwrap_or_default(),
monitoring_active: self.running.load(std::sync::atomic::Ordering::Relaxed),
last_check: now.to_rfc3339(),
next_check: (now + Duration::minutes(interval as i64)).to_rfc3339(),
}
}
fn check_processes(
&self,
sys: &System,
process_configs: &[ProcessConfig],
) -> Vec<ProcessMetrics> {
let mut results = Vec::new();
for pc in process_configs {
let pattern = pc.pattern.to_lowercase();
let mut found_pids = Vec::new();
let mut total_mem: f64 = 0.0;
let mut total_cpu: f64 = 0.0;
if pc.enabled {
for (pid, proc) in sys.processes() {
let name = proc.name().to_string_lossy().to_lowercase();
let cmd = proc
.cmd()
.iter()
.map(|s| s.to_string_lossy().to_lowercase())
.collect::<Vec<_>>()
.join(" ");
if name.contains(&pattern) || cmd.contains(&pattern) {
found_pids.push(pid.as_u32());
total_mem += proc.memory() as f64 / 1_048_576.0;
total_cpu += proc.cpu_usage() as f64;
}
}
}
let mem_status = if pc.memory_threshold_mb > 0.0 && total_mem > 0.0 {
eval_status(total_mem, pc.memory_threshold_mb).to_string()
} else {
"ok".to_string()
};
results.push(ProcessMetrics {
name: pc.name.clone(),
pattern: pc.pattern.clone(),
running: !found_pids.is_empty(),
enabled: pc.enabled,
alert_on_down: pc.alert_on_down,
instance_count: found_pids.len(),
total_memory_mb: (total_mem * 10.0).round() / 10.0,
total_cpu_percent: (total_cpu * 10.0).round() / 10.0,
memory_threshold_mb: pc.memory_threshold_mb,
memory_status: mem_status,
pids: found_pids,
});
}
results
}
pub async fn check_and_alert(&self, metrics: &Metrics) {
let (cooldown, hostname) = {
let cm = self.config_manager.lock().await;
(cm.config.alert_cooldown_minutes, metrics.hostname.clone())
};
let mut to_alert: Vec<(String, String, f64, f64, String)> = Vec::new();
{
let mut last = self.last_alerts.lock().unwrap();
let now = chrono::Local::now();
macro_rules! maybe_alert {
($key:expr, $msg:expr, $val:expr, $thr:expr, $type:expr) => {
let key = $key.to_string();
let should = match last.get(&key) {
Some(t) => (now - *t) >= Duration::minutes(cooldown as i64),
None => true,
};
if should {
last.insert(key.clone(), now);
to_alert.push((key, $msg.to_string(), $val, $thr, $type.to_string()));
}
};
}
if metrics.cpu.status == "critical" {
maybe_alert!(
"cpu",
format!("CPU a {}% (seuil: {}%)", metrics.cpu.percent, metrics.cpu.threshold),
metrics.cpu.percent, metrics.cpu.threshold, "threshold"
);
}
if metrics.ram.status == "critical" {
maybe_alert!(
"ram",
format!("RAM a {}% (seuil: {}%)", metrics.ram.percent, metrics.ram.threshold),
metrics.ram.percent, metrics.ram.threshold, "threshold"
);
}
for disk in &metrics.disks {
if disk.status == "critical" {
maybe_alert!(
format!("disk_{}", disk.drive),
format!("Disque {} a {}% (seuil: {}%)", disk.drive, disk.percent, disk.threshold),
disk.percent, disk.threshold, "threshold"
);
}
}
for proc in &metrics.processes {
if !proc.enabled { continue; }
if proc.alert_on_down && !proc.running {
maybe_alert!(
format!("process_down_{}", proc.name),
format!("Processus '{}' non detecte (pattern: {})", proc.name, proc.pattern),
0.0, 0.0, "process_down"
);
}
if proc.memory_threshold_mb > 0.0 && proc.memory_status == "critical" {
maybe_alert!(
format!("process_mem_{}", proc.name),
format!("Processus '{}' utilise {} Mo (seuil: {} Mo)", proc.name, proc.total_memory_mb, proc.memory_threshold_mb),
proc.total_memory_mb, proc.memory_threshold_mb, "threshold"
);
}
}
}
for (key, message, value, threshold, alert_type) in to_alert {
let alert = Alert {
timestamp: chrono::Local::now().to_rfc3339(),
alert_type: alert_type.clone(),
key,
message: message.clone(),
value,
threshold,
hostname: hostname.clone(),
};
{
let cm = self.config_manager.lock().await;
cm.save_alert(alert);
let subject = format!("[ALERTE] {} - {}", hostname, message);
let body = format!(
"Alerte de supervision\n{}\n\nServeur : {}\nDate : {}\nType : {}\n\nMessage : {}\n\n{}\nSupervision - Monitoring automatique",
"=".repeat(40), hostname, chrono::Local::now().to_rfc3339(), alert_type, message, "=".repeat(40)
);
self.alerter.send(&cm.config.smtp, &subject, &body).await;
}
}
}
pub async fn start(self: Arc<Self>) {
self.running.store(true, std::sync::atomic::Ordering::Relaxed);
let monitor = self.clone();
tokio::spawn(async move {
loop {
if !monitor.running.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
let metrics = monitor.collect().await;
{
let mut m = monitor.metrics.write().unwrap();
*m = Some(metrics.clone());
}
monitor.check_and_alert(&metrics).await;
let interval = {
let cm = monitor.config_manager.lock().await;
cm.config.check_interval_minutes
};
tokio::time::sleep(StdDuration::from_secs(interval * 60)).await;
}
});
}
pub fn stop(&self) {
self.running.store(false, std::sync::atomic::Ordering::Relaxed);
}
}
- Step 4: Lancer les tests
cargo test monitor
Expected: 5 tests passent.
- Step 5: Compile check complet
cargo check
Expected: pas d'erreurs.
- Step 6: Commit
git add src/monitor.rs
git commit -m "feat: monitor module sysinfo + évaluation seuils"
Task 5: User monitor module
Files:
-
Modify:
src/user_monitor.rs -
Test: dans
src/user_monitor.rs -
Step 1: Écrire les tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_awevents_line_extracts_user_and_action() {
let line = r#"2026-04-07 14:23:45.123;server;;;;"login=jdupont,action=consulter,Label=Consulter dossier""#;
let mut users = std::collections::HashMap::new();
let cutoff = chrono::Local::now() - chrono::Duration::hours(25);
let mut hourly = (0..24).map(|h| (h, std::collections::HashSet::new())).collect();
parse_awevents_line(line, &mut users, cutoff.naive_local(), &mut hourly);
assert!(users.contains_key("jdupont"));
}
#[test]
fn parse_awevents_line_ignores_malformed() {
let line = "not a valid log line";
let mut users = std::collections::HashMap::new();
let cutoff = chrono::Local::now().naive_local();
let mut hourly = (0..24).map(|h| (h, std::collections::HashSet::new())).collect();
parse_awevents_line(line, &mut users, cutoff, &mut hourly);
assert!(users.is_empty());
}
#[test]
fn compute_statuses_marks_recent_as_active() {
let now = chrono::Local::now().naive_local();
let mut users = std::collections::HashMap::new();
users.insert("alice".into(), UserEntry {
login: "alice".into(),
last_action_time: now,
last_action_label: "test".into(),
action_count_24h: 1,
status: "deconnecte".into(),
explicit_logout: false,
logout_time: None,
connected_since: Some(now),
});
compute_statuses(&mut users, 5, 30, now);
assert_eq!(users["alice"].status, "actif");
}
}
- Step 2: Vérifier que les tests échouent
cargo test user_monitor
Expected: erreur de compilation.
- Step 3: Implémenter user_monitor.rs
use chrono::{Duration, Local, NaiveDateTime};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as AsyncMutex;
use crate::config::ConfigManager;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserEntry {
pub login: String,
pub last_action_time: NaiveDateTime,
pub last_action_label: String,
pub action_count_24h: u32,
pub status: String,
pub explicit_logout: bool,
pub logout_time: Option<NaiveDateTime>,
pub connected_since: Option<NaiveDateTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HourlyCount {
pub hour: u32,
pub count: usize,
}
#[derive(Debug, Clone, Default)]
pub struct UserData {
pub users: Vec<UserEntry>,
pub hourly: Vec<HourlyCount>,
pub error: Option<String>,
pub no_files: bool,
}
fn log_files_for_date(log_path: &Path, prefix: &str, date_str: &str) -> Vec<std::path::PathBuf> {
let pattern = format!("{}/{}_{}_*", log_path.to_string_lossy(), prefix, date_str);
let re = Regex::new(r"_(\d+)\.[^.]+$").unwrap();
let mut files: Vec<_> = glob::glob(&pattern)
.unwrap_or_else(|_| glob::glob("").unwrap())
.filter_map(|f| f.ok())
.filter(|f| !f.to_string_lossy().ends_with(".zip"))
.collect();
files.sort_by_key(|f| {
re.captures(&f.to_string_lossy())
.and_then(|c| c.get(1))
.and_then(|m| m.as_str().parse::<u32>().ok())
.unwrap_or(0)
});
files
}
pub fn parse_awevents_line(
line: &str,
users: &mut HashMap<String, UserEntry>,
cutoff_24h: NaiveDateTime,
hourly: &mut HashMap<u32, HashSet<String>>,
) {
let re = Regex::new(
r#"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\.\d+;[^;]*;;;;"login=([^,]+),action=([^,]+),Label=(.+?)"?\s*$"#,
).unwrap();
let m = match re.captures(line) {
Some(m) => m,
None => return,
};
let ts_str = &m[1];
let login = m[2].trim().to_string();
let label = m[4].to_string();
if login.is_empty() { return; }
let ts = match NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%d %H:%M:%S") {
Ok(t) => t,
Err(_) => return,
};
let is_logout = label.to_lowercase().contains("se deconnecter");
let entry = users.entry(login.clone()).or_insert_with(|| UserEntry {
login: login.clone(),
last_action_time: ts,
last_action_label: label.chars().take(60).collect(),
action_count_24h: 0,
status: "deconnecte".into(),
explicit_logout: is_logout,
logout_time: if is_logout { Some(ts) } else { None },
connected_since: Some(ts),
});
if ts > entry.last_action_time {
entry.last_action_time = ts;
entry.last_action_label = label.chars().take(60).collect();
}
if is_logout {
entry.explicit_logout = true;
entry.logout_time = Some(ts);
} else if entry.explicit_logout {
if let Some(lt) = entry.logout_time {
if ts > lt {
entry.explicit_logout = false;
entry.logout_time = None;
}
}
}
if ts >= cutoff_24h {
entry.action_count_24h += 1;
}
hourly
.entry(ts.hour() as u32)
.or_default()
.insert(login);
}
pub fn compute_statuses(
users: &mut HashMap<String, UserEntry>,
active_min: u64,
inactive_min: u64,
now: NaiveDateTime,
) {
for user in users.values_mut() {
let delta = (now - user.last_action_time)
.num_minutes()
.max(0) as u64;
user.status = if user.explicit_logout {
"deconnecte".into()
} else if delta > inactive_min {
"deconnecte".into()
} else if delta > active_min {
"inactif".into()
} else {
"actif".into()
};
}
}
pub struct UserMonitor {
config_manager: Arc<AsyncMutex<ConfigManager>>,
pub data: Arc<Mutex<UserData>>,
running: Arc<std::sync::atomic::AtomicBool>,
}
impl UserMonitor {
pub fn new(config_manager: Arc<AsyncMutex<ConfigManager>>) -> Self {
UserMonitor {
config_manager,
data: Arc::new(Mutex::new(UserData::default())),
running: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}
}
pub async fn parse_logs(&self) {
let (log_path, active_min, inactive_min) = {
let cm = self.config_manager.lock().await;
(
cm.config.amadea_log_path.clone(),
cm.config.user_status_thresholds.active_minutes,
cm.config.user_status_thresholds.inactive_minutes,
)
};
let log_dir = Path::new(&log_path);
if !log_dir.is_dir() {
let mut d = self.data.lock().unwrap();
*d = UserData {
error: Some(format!("Dossier de logs introuvable : {}", log_path)),
..Default::default()
};
return;
}
let now = Local::now().naive_local();
let date_str = Local::now().format("%y-%m-%d").to_string();
let cutoff_24h = now - Duration::hours(24);
let awevents_files = log_files_for_date(log_dir, "awevents", &date_str);
if awevents_files.is_empty() {
let mut d = self.data.lock().unwrap();
*d = UserData { no_files: true, ..Default::default() };
return;
}
let mut users: HashMap<String, UserEntry> = HashMap::new();
let mut hourly: HashMap<u32, HashSet<String>> =
(0..24).map(|h| (h, HashSet::new())).collect();
for file in &awevents_files {
if let Ok(content) = fs::read_to_string(file) {
for line in content.lines() {
parse_awevents_line(line, &mut users, cutoff_24h, &mut hourly);
}
}
}
let re_isoft = Regex::new(
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*method=OpenUserSession.*login=([A-Za-z0-9_]+)"
).unwrap();
for file in log_files_for_date(log_dir, "isoft", &date_str) {
if let Ok(content) = fs::read_to_string(file) {
for line in content.lines() {
if let Some(m) = re_isoft.captures(line) {
let login = m[2].to_string();
if let Ok(ts) = NaiveDateTime::parse_from_str(&m[1], "%Y-%m-%d %H:%M:%S") {
if let Some(u) = users.get_mut(&login) {
if u.connected_since.is_none() {
u.connected_since = Some(ts);
}
}
}
}
}
}
}
compute_statuses(&mut users, active_min, inactive_min, now);
let status_order = |s: &str| match s {
"actif" => 0,
"inactif" => 1,
_ => 2,
};
let mut sorted: Vec<UserEntry> = users.into_values().collect();
sorted.sort_by_key(|u| status_order(&u.status));
let hourly_data: Vec<HourlyCount> = {
let mut v: Vec<_> = hourly.iter().collect();
v.sort_by_key(|(h, _)| *h);
v.iter().map(|(h, s)| HourlyCount { hour: **h, count: s.len() }).collect()
};
let mut d = self.data.lock().unwrap();
*d = UserData {
users: sorted,
hourly: hourly_data,
error: None,
no_files: false,
};
}
pub async fn get_weekly_activity(&self) -> Vec<serde_json::Value> {
let log_path = {
let cm = self.config_manager.lock().await;
cm.config.amadea_log_path.clone()
};
let log_dir = Path::new(&log_path);
if !log_dir.is_dir() { return vec![]; }
let today = Local::now().date_naive();
let mut result = Vec::new();
let re = Regex::new(r"^(\d{4}-\d{2}-\d{2} (\d{2}):\d{2}:\d{2}).*login=([^,]+),").unwrap();
for delta in (0..=6).rev() {
let day = today - Duration::days(delta);
let date_str = day.format("%y-%m-%d").to_string();
let files = log_files_for_date(log_dir, "awevents", &date_str);
if files.is_empty() {
result.push(serde_json::json!({ "date": day.to_string(), "count": null }));
continue;
}
let mut hourly: HashMap<u32, HashSet<String>> =
(0..24).map(|h| (h, HashSet::new())).collect();
for file in &files {
if let Ok(content) = fs::read_to_string(file) {
for line in content.lines() {
if let Some(m) = re.captures(line) {
let hour: u32 = m[2].parse().unwrap_or(0);
let login = m[3].trim().to_string();
if !login.is_empty() {
hourly.entry(hour).or_default().insert(login);
}
}
}
}
}
let max_concurrent = hourly.values().map(|s| s.len()).max().unwrap_or(0);
result.push(serde_json::json!({ "date": day.to_string(), "count": max_concurrent }));
}
result
}
pub async fn start(self: Arc<Self>) {
self.running.store(true, std::sync::atomic::Ordering::Relaxed);
let um = self.clone();
tokio::spawn(async move {
loop {
if !um.running.load(std::sync::atomic::Ordering::Relaxed) { break; }
um.parse_logs().await;
let interval = {
let cm = um.config_manager.lock().await;
cm.config.check_interval_minutes
};
tokio::time::sleep(std::time::Duration::from_secs(interval * 60)).await;
}
});
}
}
- Step 4: Lancer les tests
cargo test user_monitor
Expected: 3 tests passent.
- Step 5: Commit
git add src/user_monitor.rs
git commit -m "feat: user_monitor — parsing logs Amadea"
Task 6: AppState + flash + AuthUser extractor
Files:
-
Modify:
src/routes/mod.rs -
Step 1: Implémenter routes/mod.rs
pub mod auth;
pub mod dashboard;
pub mod settings;
pub mod alerts;
pub mod users;
use axum::{
async_trait,
extract::FromRequestParts,
http::{request::Parts, StatusCode},
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::{Metrics, SystemMonitor};
use crate::user_monitor::UserMonitor;
const SESSION_USER_KEY: &str = "username";
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
}
/// Flash message helpers
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
}
/// Extractor: renvoie le username si authentifié, sinon redirige vers /login
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)),
}
}
- Step 2: Compile check
cargo check
Expected: pas d'erreurs.
- Step 3: Commit
git add src/routes/mod.rs
git commit -m "feat: AppState, flash helpers, AuthUser extractor"
Task 7: Auth routes (login/logout)
Files:
-
Modify:
src/routes/auth.rs -
Step 1: Implémenter auth.rs
use axum::{
extract::{Form, State},
response::{Html, IntoResponse, Redirect},
};
use serde::Deserialize;
use tower_sessions::Session;
use crate::routes::{flash, get_and_clear_flash, render_html, AppState, SESSION_USER_KEY};
const SESSION_USER_KEY: &str = "username";
#[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")
}
Note: SESSION_USER_KEY est défini dans routes/mod.rs. Supprimer la redéfinition locale dans auth.rs et utiliser crate::routes::SESSION_USER_KEY (rendre pub dans mod.rs).
- Step 2: Compile check
cargo check
Expected: pas d'erreurs.
- Step 3: Commit
git add src/routes/auth.rs
git commit -m "feat: routes auth login/logout"
Task 8: Dashboard et API métriques
Files:
-
Modify:
src/routes/dashboard.rs -
Step 1: Implémenter dashboard.rs
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);
ctx.insert("admin_username", &admin_username);
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("/")
}
- Step 2: Compile check
cargo check
Expected: pas d'erreurs.
- Step 3: Commit
git add src/routes/dashboard.rs
git commit -m "feat: dashboard + api/metrics routes"
Task 9: Settings routes
Files:
-
Modify:
src/routes/settings.rs -
Step 1: Implémenter settings.rs
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)
};
// Masquer le mot de passe SMTP
let smtp_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_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")
}
- Step 2: Compile check
cargo check
Expected: pas d'erreurs.
- Step 3: Commit
git add src/routes/settings.rs
git commit -m "feat: routes settings — seuils, smtp, processus, mot de passe, port"
Task 10: Alerts routes
Files:
-
Modify:
src/routes/alerts.rs -
Step 1: Implémenter alerts.rs
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 pour l'affichage (YYYY-MM-DDTHH:MM:SS → 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")
}
- Step 2: Compile check
cargo check
- Step 3: Commit
git add src/routes/alerts.rs
git commit -m "feat: routes alertes"
Task 11: Users routes
Files:
-
Modify:
src/routes/users.rs -
Step 1: Implémenter users.rs
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 }))
}
- Step 2: Compile check
cargo check
- Step 3: Commit
git add src/routes/users.rs
git commit -m "feat: routes utilisateurs Amadea"
Task 12: Templates Tera
Files:
-
Create:
templates/base.html -
Create:
templates/login.html -
Create:
templates/dashboard.html -
Create:
templates/settings.html -
Create:
templates/alerts.html -
Create:
templates/users.html -
Create:
static/style.css -
Step 1: Créer static/style.css
Copier le contenu de ../supervision/static/style.css tel quel :
/* Supervision — Style */
body {
background-color: #f4f6f9;
font-size: 0.9rem;
}
.metric-card {
transition: border-color 0.3s;
}
.metric-card .metric-value {
font-size: 2.2rem;
font-weight: 700;
line-height: 1.1;
}
.card {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.badge {
font-size: 0.75rem;
text-transform: uppercase;
}
.table th {
font-size: 0.8rem;
text-transform: uppercase;
color: #6c757d;
border-bottom-width: 1px;
}
.navbar-brand i {
color: #4fc3f7;
}
.border-success { border-left: 4px solid #198754 !important; }
.border-warning { border-left: 4px solid #ffc107 !important; }
.border-danger { border-left: 4px solid #dc3545 !important; }
@media (max-width: 768px) {
.metric-card .metric-value {
font-size: 1.8rem;
}
}
- Step 2: Créer templates/base.html
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Supervision{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="/static/style.css" rel="stylesheet">
</head>
<body>
{% if is_authenticated %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<i class="bi bi-activity"></i> Supervision
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {% if active_page == "dashboard" %}active{% endif %}" href="/">
<i class="bi bi-speedometer2"></i> Tableau de bord
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_page == "settings" %}active{% endif %}" href="/settings">
<i class="bi bi-gear"></i> Configuration
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_page == "alerts" %}active{% endif %}" href="/alerts">
<i class="bi bi-bell"></i> Alertes
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_page == "users" %}active{% endif %}" href="/users">
<i class="bi bi-people"></i> Utilisateurs
</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/logout">
<i class="bi bi-box-arrow-right"></i> Déconnexion
</a>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<div class="container-fluid mt-3">
{% if flash_messages %}
{% for item in flash_messages %}
<div class="alert alert-{{ item.0 }} alert-dismissible fade show" role="alert">
{{ item.1 }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% if default_pw is defined and default_pw %}
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle"></i>
<strong>Sécurité :</strong> Le mot de passe par défaut est encore actif.
<a href="/settings#password" class="alert-link">Changez-le maintenant</a>.
</div>
{% endif %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
- Step 3: Créer templates/login.html
Porter ../supervision/templates/login.html en remplaçant :
url_for('login')→/login{% with messages = get_flashed_messages(with_categories=true) %}...{% endwith %}→{% if flash_messages %}{% for item in flash_messages %}...{{ item.0 }}...{{ item.1 }}...{% endfor %}{% endif %}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Supervision - Connexion</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<style>
body { background: #1a1a2e; min-height: 100vh; display: flex; align-items: center; }
.login-card { max-width: 400px; margin: auto; }
.brand { color: #e2e8f0; text-align: center; margin-bottom: 2rem; }
.brand i { font-size: 3rem; color: #4fc3f7; }
.brand h1 { font-size: 1.5rem; margin-top: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<div class="login-card">
<div class="brand">
<i class="bi bi-activity"></i>
<h1>Supervision</h1>
<small class="text-secondary">Monitoring système</small>
</div>
{% if flash_messages %}
{% for item in flash_messages %}
<div class="alert alert-{{ item.0 }} alert-dismissible fade show" role="alert">
{{ item.1 }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<div class="card shadow">
<div class="card-body p-4">
<form method="POST" action="/login">
<div class="mb-3">
<label for="username" class="form-label">Identifiant</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input type="text" class="form-control" id="username" name="username"
required autofocus autocomplete="username">
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Mot de passe</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-lock"></i></span>
<input type="password" class="form-control" id="password" name="password"
required autocomplete="current-password">
</div>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-box-arrow-in-right"></i> Connexion
</button>
</form>
</div>
</div>
</div>
</div>
</body>
</html>
- Step 4: Créer templates/dashboard.html
Porter ../supervision/templates/dashboard.html :
url_for('toggle_monitoring')→/api/monitoring/toggleurl_for('alerts')→/alerts{% extends "base.html" %}→ identique (Tera supporte extends){{ metrics.cpu.percent }}→{{ metrics.cpu.percent }}loop.index0→loop.index0
{% extends "base.html" %}
{% block title %}Supervision - Tableau de bord{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">
<i class="bi bi-speedometer2"></i> Tableau de bord
{% if metrics and metrics.hostname %}
<small class="text-muted">— {{ metrics.hostname }}</small>
{% endif %}
</h4>
<div class="d-flex align-items-center gap-2">
<span id="last-update" class="text-muted small"></span>
<form method="POST" action="/api/monitoring/toggle" class="d-inline">
{% if metrics and metrics.monitoring_active %}
<button type="submit" class="btn btn-sm btn-outline-warning">
<i class="bi bi-pause-circle"></i> Pause
</button>
{% else %}
<button type="submit" class="btn btn-sm btn-outline-success">
<i class="bi bi-play-circle"></i> Démarrer
</button>
{% endif %}
</form>
</div>
</div>
{% if not metrics %}
<div class="alert alert-info">
<i class="bi bi-hourglass-split"></i> Collecte des métriques en cours...
</div>
{% else %}
<div class="row mb-3">
<div class="col-12">
<div class="card bg-dark text-light">
<div class="card-body py-2 d-flex gap-4 flex-wrap small">
<span><i class="bi bi-pc-display"></i> <strong id="sys-hostname">{{ metrics.hostname }}</strong></span>
<span><i class="bi bi-windows"></i> <span id="sys-os">{{ metrics.os }}</span></span>
<span><i class="bi bi-clock-history"></i> Uptime: <span id="sys-uptime">{{ metrics.uptime }}</span></span>
<span><i class="bi bi-cpu"></i> <span id="sys-cores">{{ metrics.cpu.cores }}</span> cœurs</span>
<span><i class="bi bi-memory"></i> <span id="sys-ram-total">{{ metrics.ram.total_gb }}</span> Go RAM</span>
</div>
</div>
</div>
</div>
<div class="row mb-3" id="main-metrics">
<div class="col-md-4 mb-3">
<div class="card metric-card" id="card-cpu">
<div class="card-body">
<div class="d-flex justify-content-between">
<h6 class="card-title"><i class="bi bi-cpu"></i> CPU</h6>
<span class="badge" id="badge-cpu">{{ metrics.cpu.status }}</span>
</div>
<div class="metric-value" id="val-cpu">{{ metrics.cpu.percent }}%</div>
<div class="progress mt-2" style="height: 8px;">
<div class="progress-bar" id="bar-cpu" role="progressbar"
style="width: {{ metrics.cpu.percent }}%"></div>
</div>
<small class="text-muted">Seuil: <span id="thresh-cpu">{{ metrics.cpu.threshold }}</span>%</small>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card metric-card" id="card-ram">
<div class="card-body">
<div class="d-flex justify-content-between">
<h6 class="card-title"><i class="bi bi-memory"></i> RAM</h6>
<span class="badge" id="badge-ram">{{ metrics.ram.status }}</span>
</div>
<div class="metric-value" id="val-ram">{{ metrics.ram.percent }}%</div>
<div class="progress mt-2" style="height: 8px;">
<div class="progress-bar" id="bar-ram" role="progressbar"
style="width: {{ metrics.ram.percent }}%"></div>
</div>
<small class="text-muted">
<span id="ram-used">{{ metrics.ram.used_gb }}</span> /
<span id="ram-total">{{ metrics.ram.total_gb }}</span> Go
— Seuil: <span id="thresh-ram">{{ metrics.ram.threshold }}</span>%
</small>
</div>
</div>
</div>
{% for disk in metrics.disks %}
<div class="col-md-4 mb-3">
<div class="card metric-card" id="card-disk-{{ loop.index0 }}">
<div class="card-body">
<div class="d-flex justify-content-between">
<h6 class="card-title"><i class="bi bi-hdd"></i> {{ disk.drive }}</h6>
<span class="badge" id="badge-disk-{{ loop.index0 }}">{{ disk.status }}</span>
</div>
<div class="metric-value" id="val-disk-{{ loop.index0 }}">{{ disk.percent }}%</div>
<div class="progress mt-2" style="height: 8px;">
<div class="progress-bar" id="bar-disk-{{ loop.index0 }}" role="progressbar"
style="width: {{ disk.percent }}%"></div>
</div>
<small class="text-muted">
{{ disk.used_gb }} / {{ disk.total_gb }} Go ({{ disk.free_gb }} Go libres)
— Seuil: {{ disk.threshold }}%
</small>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-list-task"></i> Processus surveillés</h6>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Processus</th><th>Statut</th><th>Instances</th>
<th>Mémoire</th><th>CPU</th><th>PID(s)</th>
</tr>
</thead>
<tbody>
{% for proc in metrics.processes %}
<tr>
<td><strong>{{ proc.name }}</strong><br><small class="text-muted">pattern: {{ proc.pattern }}</small></td>
<td>
{% if not proc.enabled %}<span class="badge bg-secondary">Désactivé</span>
{% elif proc.running %}<span class="badge bg-success">Actif</span>
{% else %}<span class="badge bg-danger">Arrêté</span>{% endif %}
</td>
<td>{{ proc.instance_count }}</td>
<td>{{ proc.total_memory_mb }} Mo
{% if proc.memory_threshold_mb > 0 %}<br><small class="text-muted">seuil: {{ proc.memory_threshold_mb }} Mo</small>{% endif %}
</td>
<td>{{ proc.total_cpu_percent }}%</td>
<td><small>{{ proc.pids | join(sep=", ") }}</small></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between">
<h6 class="mb-0"><i class="bi bi-bell"></i> Alertes récentes</h6>
<a href="/alerts" class="btn btn-sm btn-outline-primary">Voir tout</a>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0" id="recent-alerts"><tbody></tbody></table>
<div id="no-alerts" class="text-center text-muted py-3">Aucune alerte récente.</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
const statusColors = { ok: 'success', warning: 'warning', critical: 'danger' };
function updateMetric(id, percent, status) {
const card = document.getElementById('card-' + id);
const badge = document.getElementById('badge-' + id);
const bar = document.getElementById('bar-' + id);
const val = document.getElementById('val-' + id);
if (!card) return;
val.textContent = percent + '%';
bar.style.width = percent + '%';
const color = statusColors[status] || 'secondary';
badge.textContent = status;
badge.className = 'badge bg-' + color;
bar.className = 'progress-bar bg-' + color;
card.className = 'card metric-card border-' + color;
}
function refreshMetrics() {
fetch('/api/metrics')
.then(r => r.json())
.then(data => {
if (!data || !data.cpu) return;
updateMetric('cpu', data.cpu.percent, data.cpu.status);
updateMetric('ram', data.ram.percent, data.ram.status);
document.getElementById('ram-used').textContent = data.ram.used_gb;
document.getElementById('sys-uptime').textContent = data.uptime;
data.disks.forEach((disk, i) => updateMetric('disk-' + i, disk.percent, disk.status));
document.getElementById('last-update').textContent =
'Mis à jour: ' + new Date().toLocaleTimeString('fr-FR');
})
.catch(() => {});
}
setInterval(refreshMetrics, 30000);
</script>
{% endblock %}
- Step 5: Créer templates/alerts.html
Porter ../supervision/templates/alerts.html :
url_for('clear_alerts')→/alerts/clearalert.timestamp[:19] | replace('T', ' ')→alert.timestamp_display(formaté côté Rust dans la route)alerts | length→alerts | length
{% extends "base.html" %}
{% block title %}Supervision - Alertes{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0"><i class="bi bi-bell"></i> Historique des alertes</h4>
{% if alerts %}
<form method="POST" action="/alerts/clear">
<button type="submit" class="btn btn-sm btn-outline-danger"
onclick="return confirm('Effacer tout l\'historique ?')">
<i class="bi bi-trash"></i> Effacer l'historique
</button>
</form>
{% endif %}
</div>
{% if not alerts %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Aucune alerte enregistrée.
</div>
{% else %}
<div class="card">
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead>
<tr><th>Date</th><th>Type</th><th>Message</th><th>Valeur</th><th>Seuil</th></tr>
</thead>
<tbody>
{% for alert in alerts %}
<tr>
<td class="text-nowrap"><small>{{ alert.timestamp_display }}</small></td>
<td>
{% if alert.type == "process_down" %}
<span class="badge bg-danger">Processus</span>
{% else %}
<span class="badge bg-warning text-dark">Seuil</span>
{% endif %}
</td>
<td>{{ alert.message }}</td>
<td>{{ alert.value }}</td>
<td>{{ alert.threshold }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="text-muted small mt-2">{{ alerts | length }} alerte(s) — les 500 dernières sont conservées.</div>
{% endif %}
{% endblock %}
- Step 6: Créer templates/settings.html
Porter ../supervision/templates/settings.html en remplaçant tous les url_for('...') selon la table de portage au début du plan. Les {% if smtp.use_tls %}checked{% endif %} fonctionnent identiquement en Tera.
Structure : copier le contenu de ../supervision/templates/settings.html et remplacer :
-
url_for('update_thresholds')→/settings/thresholds -
url_for('update_monitoring')→/settings/monitoring -
url_for('update_port')→/settings/port -
url_for('update_smtp')→/settings/smtp -
url_for('test_smtp')→/settings/smtp/test -
url_for('update_processes')→/settings/processes -
url_for('update_password')→/settings/password -
url_for('update_amadea_log_path')→/settings/amadea-log-path -
url_for('update_user_thresholds')→/settings/user-thresholds -
{{ smtp.to_emails | join(', ') }}→{{ smtp.to_emails | join(sep=", ") }} -
{{ smtp.password_masked }}→{{ smtp_password_masked }} -
{% if smtp.password_masked %}{{ smtp.password_masked }}{% else %}Non défini{% endif %}→{% if smtp_password_masked %}{{ smtp_password_masked }}{% else %}Non défini{% endif %} -
Step 7: Créer templates/users.html
Copier ../supervision/templates/users.html tel quel — aucune modification nécessaire car il n'utilise que du JS pur avec fetch vers /api/users.
- Step 8: Compile check (avec les templates embarqués)
cargo check
Expected: pas d'erreurs.
- Step 9: Commit
git add templates/ static/
git commit -m "feat: templates Tera + style.css"
Task 13: Main.rs — assemblage + rate limiting + headers sécurité
Files:
-
Modify:
src/main.rs -
Step 1: Implémenter main.rs
mod config;
mod monitor;
mod alerter;
mod user_monitor;
mod routes;
use std::sync::Arc;
use tokio::sync::Mutex as AsyncMutex;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
use tower_http::services::ServeDir;
use axum::{
Router,
routing::{get, post},
middleware,
http::{HeaderValue, Method},
response::IntoResponse,
};
use config::ConfigManager;
use monitor::SystemMonitor;
use alerter::Alerter;
use user_monitor::UserMonitor;
use routes::{
AppState,
auth::{login_get, login_post, logout},
dashboard::{dashboard, api_metrics, toggle_monitoring},
settings::{
settings_get, update_thresholds, update_monitoring, update_smtp,
test_smtp, update_processes, update_password, update_port,
update_amadea_log_path, update_user_thresholds,
},
alerts::{alerts_get, clear_alerts},
users::{users_get, api_users, api_users_weekly},
};
async fn run_server() {
// Init config
let config_manager = Arc::new(AsyncMutex::new(ConfigManager::new()));
// Init services
let alerter = Arc::new(Alerter);
let monitor = Arc::new(SystemMonitor::new(
config_manager.clone(),
alerter.clone(),
));
let user_monitor = Arc::new(UserMonitor::new(config_manager.clone()));
// Démarrer monitoring et user monitor
monitor.clone().start().await;
{
let m = monitor.clone();
let _ = m.collect().await; // collecte initiale
let metrics = m.metrics.read().unwrap().clone();
if let Some(ref met) = metrics {
m.metrics.write().unwrap().replace(met.clone());
}
}
user_monitor.clone().start().await;
// App state
let state = AppState::new(
config_manager.clone(),
monitor,
alerter,
user_monitor,
);
// Rate limiting pour /login (10 req/min)
let governor_conf = Arc::new(
GovernorConfigBuilder::default()
.per_second(6) // 10 par minute = 1 toutes les 6 secondes
.burst_size(10)
.finish()
.unwrap(),
);
// Sessions
let session_store = MemoryStore::default();
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false)
.with_name("supervision_session");
// Router
let login_routes = Router::new()
.route("/login", post(login_post))
.layer(GovernorLayer { config: governor_conf });
let app = Router::new()
.route("/login", get(login_get))
.merge(login_routes)
.route("/logout", get(logout))
.route("/", get(dashboard))
.route("/api/metrics", get(api_metrics))
.route("/api/monitoring/toggle", post(toggle_monitoring))
.route("/settings", get(settings_get))
.route("/settings/thresholds", post(update_thresholds))
.route("/settings/monitoring", post(update_monitoring))
.route("/settings/smtp", post(update_smtp))
.route("/settings/smtp/test", post(test_smtp))
.route("/settings/processes", post(update_processes))
.route("/settings/password", post(update_password))
.route("/settings/port", post(update_port))
.route("/settings/amadea-log-path", post(update_amadea_log_path))
.route("/settings/user-thresholds", post(update_user_thresholds))
.route("/alerts", get(alerts_get))
.route("/alerts/clear", post(clear_alerts))
.route("/users", get(users_get))
.route("/api/users", get(api_users))
.route("/api/users/activity/weekly", get(api_users_weekly))
.nest_service("/static", ServeDir::new("static"))
.layer(session_layer)
.with_state(state.clone())
// En-têtes sécurité
.layer(axum::middleware::map_response(|mut response: axum::response::Response| async move {
let headers = response.headers_mut();
headers.insert("X-Content-Type-Options", HeaderValue::from_static("nosniff"));
headers.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
headers.insert("X-XSS-Protection", HeaderValue::from_static("1; mode=block"));
response
}));
let port = {
let cm = state.config_manager.lock().await;
cm.config.port
};
let addr = format!("0.0.0.0:{}", port);
tracing::info!("Supervision démarré sur http://localhost:{}", port);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let args: Vec<String> = std::env::args().collect();
#[cfg(windows)]
{
if args.get(1).map(|s| s.as_str()) == Some("install") {
service::install_service();
return;
}
if args.get(1).map(|s| s.as_str()) == Some("uninstall") {
service::uninstall_service();
return;
}
// Détecter si lancé par le Service Control Manager
if service::is_running_as_service() {
service::run_service();
return;
}
}
// Mode console (développement ou lancement manuel)
run_server().await;
}
#[cfg(windows)]
mod service {
pub fn install_service() {
// Implémenté dans Task 14
println!("Installation du service...");
}
pub fn uninstall_service() {
println!("Désinstallation du service...");
}
pub fn is_running_as_service() -> bool {
false // Stub pour Task 13, complété dans Task 14
}
pub fn run_service() {}
}
- Step 2: Build de test
cargo build
Expected: compilation réussie, quelques warnings OK.
- Step 3: Test manuel (mode console)
cargo run
Expected: Supervision démarré sur http://localhost:5000.
Ouvrir http://localhost:5000/login dans le navigateur → page de login visible.
- Step 4: Commit
git add src/main.rs
git commit -m "feat: main.rs — assemblage complet du serveur Axum"
Task 14: Windows Service (compilation sur Windows)
Files:
- Modify:
src/main.rs(moduleservice)
Note: Cette tâche s'exécute sur Windows uniquement. Le code #[cfg(windows)] ne compile pas sur macOS/Linux.
- Step 1: Remplacer le module
servicedans main.rs
Remplacer le module #[cfg(windows)] mod service { ... } par :
#[cfg(windows)]
mod service {
use std::ffi::OsString;
use std::time::Duration;
use windows_service::{
define_windows_service,
service::{
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
ServiceType,
},
service_control_handler::{self, ServiceControlHandlerResult},
service_dispatcher,
service_manager::{ServiceManager, ServiceManagerAccess},
service::{ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType},
};
const SERVICE_NAME: &str = "Supervision";
const SERVICE_DISPLAY: &str = "Supervision - Monitoring Système";
const SERVICE_DESCRIPTION: &str = "Surveille CPU, RAM, disques et processus. Interface web sur http://localhost:5000";
pub fn install_service() {
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CREATE_SERVICE,
)
.expect("Impossible d'ouvrir le Service Manager (lancer en administrateur)");
let exe_path = std::env::current_exe().unwrap();
let service_info = ServiceInfo {
name: OsString::from(SERVICE_NAME),
display_name: OsString::from(SERVICE_DISPLAY),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: exe_path,
launch_arguments: vec![],
dependencies: vec![],
account_name: None,
account_password: None,
};
let service = manager
.create_service(&service_info, ServiceAccess::CHANGE_CONFIG)
.expect("Impossible de créer le service");
service
.set_description(SERVICE_DESCRIPTION)
.expect("Impossible de définir la description");
println!("Service '{}' installé avec succès.", SERVICE_NAME);
println!("Démarrer avec: sc start {}", SERVICE_NAME);
}
pub fn uninstall_service() {
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.expect("Impossible d'ouvrir le Service Manager");
let service = manager
.open_service(SERVICE_NAME, ServiceAccess::DELETE)
.expect("Service introuvable");
service.delete().expect("Impossible de supprimer le service");
println!("Service '{}' désinstallé.", SERVICE_NAME);
}
pub fn is_running_as_service() -> bool {
// Heuristique : pas de console attachée et variable d'env SESSIONNAME absente
std::env::var("SESSIONNAME").is_err()
}
define_windows_service!(ffi_service_main, service_main);
fn service_main(_arguments: Vec<OsString>) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let status_handle = service_control_handler::register(
SERVICE_NAME,
move |control| match control {
ServiceControl::Stop => {
let _ = shutdown_tx.send(());
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
},
)
.unwrap();
status_handle
.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Running,
controls_accepted: ServiceControlAccept::STOP,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})
.unwrap();
tokio::select! {
_ = crate::run_server() => {},
_ = shutdown_rx => {},
}
status_handle
.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Stopped,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})
.unwrap();
});
}
pub fn run_service() {
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
.expect("Impossible de démarrer le service dispatcher");
}
}
- Step 2: Build release sur Windows
cargo build --release
Expected: supervision.exe dans target\release\.
- Step 3: Test mode console sur Windows
target\release\supervision.exe
Expected: Démarrage sur port 5000. Ouvrir http://localhost:5000.
- Step 4: Test installation service
supervision.exe install
sc start Supervision
Expected: service démarré, interface accessible sur http://localhost:5000.
- Step 5: Build final et commit
git add src/main.rs
git commit -m "feat: intégration Windows Service (install/uninstall/run)"
Notes de déploiement
- Compiler une seule fois sur Windows :
cargo build --release - Récupérer
target\release\supervision.exe - Copier l'exe sur le serveur cible dans un dossier dédié (ex:
C:\Supervision\) - Ouvrir un terminal administrateur dans ce dossier :
supervision.exe install sc start Supervision - Accéder à
http://localhost:5000— login :admin/admin - Changer le mot de passe immédiatement dans Configuration → Mot de passe administrateur
Le dossier data\ est créé automatiquement à côté de l'exe au premier lancement.