2 Commits
khalid ... main

Author SHA1 Message Date
Dom
0ac8a2ecfa docs: mise a jour README pour la version Rust
Instructions de compilation (locale + cross-compilation Windows),
deploiement, stack technique, migration depuis Python.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:58:35 +02:00
Dom
545ae921e5 feat: portage complet en Rust (axum + sysinfo + tera)
Remplacement du backend Python/Flask par un binaire Rust natif.
Stack : axum (web), sysinfo (metriques), lettre (SMTP), tera (templates), argon2 (auth).
Binaire Windows 6.3 Mo sans dependance runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:55:36 +02:00
13 changed files with 4100 additions and 88 deletions

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ imput/
*.spec
build/
dist/
target/

2358
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

33
Cargo.toml Normal file
View File

@@ -0,0 +1,33 @@
[package]
name = "supervision"
version = "1.0.0"
edition = "2021"
description = "Monitoring systeme avec interface web securisee"
[[bin]]
name = "supervision"
path = "src/main.rs"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tera = "1"
sysinfo = "0.32"
lettre = "0.11"
argon2 = "0.5"
password-hash = "0.5"
tower = "0.4"
tower-http = { version = "0.5", features = ["fs"] }
rand = "0.8"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
form_urlencoded = "1"
http = "1"
[profile.release]
opt-level = 3
lto = true
strip = true
codegen-units = 1

View File

@@ -4,6 +4,8 @@ Outil de surveillance systeme avec interface web securisee.
Surveille CPU, RAM, disques et processus specifiques (JVM, Nginx, Amadea Web 8 x64).
Envoie des alertes email lorsque les seuils configures sont depasses.
**Binaire natif Rust** — aucune dependance runtime, ~6 Mo.
---
## Fonctionnalites
@@ -31,9 +33,11 @@ Envoie des alertes email lorsque les seuils configures sont depasses.
### Etapes
1. **Dezipper** `supervision_portable.zip` dans un dossier, par exemple :
1. **Copier** les fichiers suivants dans un dossier, par exemple `C:\supervision\` :
```
C:\supervision\
supervision.exe
templates\ (dossier complet)
static\ (dossier complet)
```
2. **Lancer** l'executable :
@@ -56,6 +60,8 @@ Envoie des alertes email lorsque les seuils configures sont depasses.
7. **Ajuster les seuils** si necessaire (valeurs par defaut : CPU 90%, RAM 85%, Disque 90%)
> Le dossier `data\` est cree automatiquement au premier lancement avec la configuration par defaut.
---
## Acces distant
@@ -98,34 +104,54 @@ Pour que Supervision demarre automatiquement avec Windows, utiliser [NSSM](https
---
## Installation depuis les sources (developpement)
## Compilation depuis les sources
### Pre-requis
- Python 3.10 ou superieur
- [Rust](https://rustup.rs/) 1.75 ou superieur
### Etapes
### Compiler pour la machine locale (Linux/Windows)
1. Creer l'environnement virtuel et installer les dependances :
```cmd
cd C:\supervision
python -m venv .venv
.venv\Scripts\pip.exe install -r requirements.txt
```bash
cargo build --release
```
2. Lancer :
```cmd
.venv\Scripts\python.exe app.py
L'executable est genere dans `target/release/supervision` (Linux) ou `target\release\supervision.exe` (Windows).
### Cross-compiler pour Windows depuis Linux
```bash
# Installation (une seule fois)
rustup target add x86_64-pc-windows-gnu
sudo apt install gcc-mingw-w64-x86-64
# Compilation
cargo build --release --target x86_64-pc-windows-gnu
```
### Compiler en executable
L'executable Windows est genere dans `target/x86_64-pc-windows-gnu/release/supervision.exe`.
```cmd
.venv\Scripts\pip.exe install pyinstaller
.venv\Scripts\pyinstaller.exe --name supervision --onedir --add-data "templates;templates" --add-data "static;static" --hidden-import flask --hidden-import flask_login --hidden-import flask_limiter --hidden-import psutil --noconfirm app.py
### Lancer en mode developpement
```bash
cargo run
```
L'executable est genere dans `dist\supervision\`.
L'application demarre sur http://localhost:5000 avec rechargement des templates depuis le dossier `templates/`.
---
## Stack technique
| Composant | Crate Rust | Remplace (Python) |
|-----------|-----------|-------------------|
| Serveur web | axum | Flask |
| Metriques systeme | sysinfo | psutil |
| Templates HTML | tera (Jinja2-compatible) | Jinja2 |
| Envoi email | lettre | smtplib |
| Hashing mot de passe | argon2 | werkzeug (PBKDF2) |
| Serialisation JSON | serde + serde_json | json |
| Async runtime | tokio | threading |
---
@@ -134,16 +160,25 @@ L'executable est genere dans `dist\supervision\`.
```
supervision\
├── supervision.exe # Executable principal
├── _internal\ # Dependances Python embarquees
├── Cargo.toml # Dependances Rust
├── src\
│ ├── main.rs # Serveur web, routes, sessions
│ ├── config.rs # Gestion configuration JSON
│ ├── monitor.rs # Collecte metriques systeme
│ └── alerter.rs # Envoi alertes email SMTP
├── templates\ # Pages HTML de l'interface
├── static\ # CSS
│ ├── base.html
│ ├── login.html
│ ├── dashboard.html
│ ├── settings.html
│ └── alerts.html
├── static\
│ └── style.css # Styles CSS
└── data\ # (cree au 1er lancement)
├── config.json # Configuration (seuils, SMTP, processus)
└── alerts.json # Historique des alertes
```
> **Important** : ne pas supprimer le dossier `_internal\`, il est necessaire au fonctionnement.
---
## Configuration par defaut
@@ -169,6 +204,14 @@ Tous les parametres sont modifiables depuis l'interface web.
---
## Migration depuis la version Python
Si un fichier `data/config.json` existant contient un hash de mot de passe au format Python (werkzeug/PBKDF2), la version Rust le detecte automatiquement et reinitialise le mot de passe a `admin`. Un message d'avertissement s'affiche dans la console.
Les autres parametres (seuils, SMTP, processus) sont conserves.
---
## Depannage
| Probleme | Solution |
@@ -177,4 +220,4 @@ Tous les parametres sont modifiables depuis l'interface web.
| Impossible de se connecter a distance | Verifier la regle firewall (port 5000 TCP entrant) |
| Pas d'email recu | Verifier la configuration SMTP et utiliser le bouton "Envoyer un email de test" |
| Mot de passe oublie | Supprimer `data\config.json` et relancer (reinitialise a admin/admin) |
| L'executable ne se lance pas | Verifier que le dossier `_internal\` est present a cote de `supervision.exe` |
| L'executable ne se lance pas | Verifier que les dossiers `templates\` et `static\` sont a cote de l'executable |

86
src/alerter.rs Normal file
View File

@@ -0,0 +1,86 @@
use crate::config::SmtpConfig;
use lettre::message::header::ContentType;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport};
pub fn is_configured(smtp: &SmtpConfig) -> bool {
!smtp.server.is_empty() && !smtp.from_email.is_empty() && !smtp.to_emails.is_empty()
}
pub fn send_email(smtp: &SmtpConfig, subject: &str, body: &str) -> Result<String, String> {
if !is_configured(smtp) {
return Err("SMTP non configure".into());
}
let to_str = smtp.to_emails.join(", ");
let email = Message::builder()
.from(
smtp.from_email
.parse()
.map_err(|e| format!("Email expediteur invalide: {}", e))?,
)
.to(to_str
.parse()
.map_err(|e| format!("Email destinataire invalide: {}", e))?)
.subject(subject)
.header(ContentType::TEXT_PLAIN)
.body(body.to_string())
.map_err(|e| format!("Erreur construction email: {}", e))?;
let mailer = build_transport(smtp)?;
mailer
.send(&email)
.map_err(|e| format!("Erreur envoi SMTP: {}", e))?;
Ok("Email envoye avec succes".into())
}
pub fn send_test(smtp: &SmtpConfig) -> Result<String, String> {
if !is_configured(smtp) {
return Err("Configuration SMTP incomplete".into());
}
let subject = "[TEST] Supervision - Test de configuration email";
let body = "Ceci est un email de test.\n\n\
Si vous recevez ce message, la configuration SMTP est correcte.\n\n\
-- Supervision";
send_email(smtp, subject, body)
}
fn build_transport(smtp: &SmtpConfig) -> Result<SmtpTransport, String> {
let creds = Credentials::new(smtp.username.clone(), smtp.password.clone());
let transport = if smtp.use_tls {
SmtpTransport::starttls_relay(&smtp.server)
.map_err(|e| format!("Erreur connexion SMTP TLS: {}", e))?
.credentials(creds)
.port(smtp.port)
.build()
} else {
SmtpTransport::builder_dangerous(&smtp.server)
.credentials(creds)
.port(smtp.port)
.build()
};
Ok(transport)
}
pub fn format_alert_body(alert: &crate::config::Alert) -> String {
format!(
"Alerte de supervision\n\
{sep}\n\n\
Serveur : {host}\n\
Date : {ts}\n\
Type : {tp}\n\n\
Message : {msg}\n\n\
{sep}\n\
Supervision - Monitoring automatique",
sep = "=".repeat(40),
host = alert.hostname,
ts = alert.timestamp,
tp = alert.alert_type,
msg = alert.message,
)
}

238
src/config.rs Normal file
View File

@@ -0,0 +1,238 @@
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
use password_hash::{PasswordHash, SaltString};
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
const MAX_ALERTS: usize = 500;
// --- Structures de configuration ---
#[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 admin: AdminConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thresholds {
pub cpu_percent: u32,
pub ram_percent: u32,
pub disk_percent: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessConfig {
pub name: String,
pub pattern: String,
#[serde(default)]
pub memory_threshold_mb: u64,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub alert_on_down: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmtpConfig {
#[serde(default)]
pub server: String,
#[serde(default = "default_smtp_port")]
pub port: u16,
#[serde(default = "default_true")]
pub use_tls: bool,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
#[serde(default)]
pub from_email: String,
#[serde(default)]
pub to_emails: Vec<String>,
}
fn default_smtp_port() -> u16 {
587
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminConfig {
pub username: String,
pub password_hash: String,
}
#[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,
}
// --- Hashing de mots de passe ---
pub fn hash_password(password: &str) -> String {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.expect("Erreur hashing mot de passe")
.to_string()
}
pub fn verify_password(password: &str, hash: &str) -> bool {
let parsed = match PasswordHash::new(hash) {
Ok(h) => h,
Err(_) => return false,
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
pub fn is_default_password(config: &Config) -> bool {
verify_password("admin", &config.admin.password_hash)
}
// --- Configuration par defaut ---
pub fn default_config() -> Config {
Config {
secret_key: uuid::Uuid::new_v4().to_string(),
port: 5000,
check_interval_minutes: 1,
alert_cooldown_minutes: 30,
thresholds: Thresholds {
cpu_percent: 90,
ram_percent: 85,
disk_percent: 90,
},
processes: vec![
ProcessConfig {
name: "JVM".into(),
pattern: "java".into(),
memory_threshold_mb: 0,
enabled: true,
alert_on_down: true,
},
ProcessConfig {
name: "Nginx".into(),
pattern: "nginx".into(),
memory_threshold_mb: 0,
enabled: false,
alert_on_down: false,
},
ProcessConfig {
name: "Amadea Web 8 x64".into(),
pattern: "amadea".into(),
memory_threshold_mb: 0,
enabled: true,
alert_on_down: true,
},
],
smtp: SmtpConfig {
server: String::new(),
port: 587,
use_tls: true,
username: String::new(),
password: String::new(),
from_email: String::new(),
to_emails: Vec::new(),
},
admin: AdminConfig {
username: "admin".into(),
password_hash: hash_password("admin"),
},
}
}
// --- Persistence fichier ---
pub fn config_path(data_dir: &Path) -> PathBuf {
data_dir.join("config.json")
}
pub fn alerts_path(data_dir: &Path) -> PathBuf {
data_dir.join("alerts.json")
}
pub fn load_config(data_dir: &Path) -> Config {
fs::create_dir_all(data_dir).ok();
let path = config_path(data_dir);
if path.exists() {
let content = fs::read_to_string(&path).unwrap_or_default();
match serde_json::from_str::<Config>(&content) {
Ok(mut config) => {
// Si le hash n'est pas au format argon2, reinitialiser
if !config.admin.password_hash.starts_with("$argon2") {
println!(
"[ATTENTION] Hash de mot de passe incompatible (format Python), \
reinitialise a 'admin'"
);
config.admin.password_hash = hash_password("admin");
save_config(data_dir, &config);
}
config
}
Err(e) => {
eprintln!("[Config] Erreur de lecture: {}. Creation d'une config par defaut.", e);
let config = default_config();
save_config(data_dir, &config);
config
}
}
} else {
let config = default_config();
save_config(data_dir, &config);
config
}
}
pub fn save_config(data_dir: &Path, config: &Config) {
fs::create_dir_all(data_dir).ok();
let path = config_path(data_dir);
let json = serde_json::to_string_pretty(config).expect("Serialisation config");
if let Err(e) = fs::write(&path, json) {
eprintln!("[Config] Erreur d'ecriture: {}", e);
}
}
pub fn load_alerts(data_dir: &Path) -> Vec<Alert> {
let path = alerts_path(data_dir);
if path.exists() {
let content = fs::read_to_string(&path).unwrap_or_default();
serde_json::from_str(&content).unwrap_or_default()
} else {
Vec::new()
}
}
pub fn save_alert(data_dir: &Path, alert: &Alert) {
let mut alerts = load_alerts(data_dir);
alerts.insert(0, alert.clone());
alerts.truncate(MAX_ALERTS);
let path = alerts_path(data_dir);
let json = serde_json::to_string_pretty(&alerts).unwrap_or_default();
fs::write(&path, json).ok();
}
pub fn clear_alerts(data_dir: &Path) {
let path = alerts_path(data_dir);
fs::write(&path, "[]").ok();
}

922
src/main.rs Normal file
View File

@@ -0,0 +1,922 @@
mod alerter;
mod config;
mod monitor;
use axum::body::Body;
use axum::extract::{ConnectInfo, Form, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Json, Redirect, Response};
use axum::routing::{get, post};
use axum::Router;
use config::Alert;
use serde::Deserialize;
use std::collections::{HashMap, VecDeque};
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use tera::{Context, Tera};
use tower_http::services::ServeDir;
// --- Etat applicatif ---
pub struct AppState {
pub config: RwLock<config::Config>,
pub metrics: RwLock<Option<monitor::Metrics>>,
pub sessions: RwLock<HashMap<String, Session>>,
pub rate_limiter: RwLock<RateLimiter>,
pub monitoring_active: AtomicBool,
pub tera: Tera,
pub data_dir: PathBuf,
}
pub struct Session {
pub username: String,
pub flashes: Vec<FlashMessage>,
}
#[derive(Clone, serde::Serialize)]
pub struct FlashMessage {
pub category: String,
pub message: String,
}
struct SessionInfo {
session_id: String,
#[allow(dead_code)]
username: String,
}
// --- Rate Limiter ---
pub struct RateLimiter {
attempts: HashMap<IpAddr, VecDeque<Instant>>,
}
impl RateLimiter {
fn new() -> Self {
Self {
attempts: HashMap::new(),
}
}
fn check(&mut self, ip: IpAddr, max: usize, window: Duration) -> bool {
let now = Instant::now();
let entry = self.attempts.entry(ip).or_default();
while let Some(&front) = entry.front() {
if now.duration_since(front) > window {
entry.pop_front();
} else {
break;
}
}
if entry.len() >= max {
return false;
}
entry.push_back(now);
true
}
}
// --- Helpers session / auth ---
fn get_session_id(headers: &HeaderMap) -> Option<String> {
let cookie = headers.get("cookie")?.to_str().ok()?;
for pair in cookie.split(';') {
let pair = pair.trim();
if let Some(value) = pair.strip_prefix("session_id=") {
return Some(value.to_string());
}
}
None
}
fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<SessionInfo, Response> {
if let Some(sid) = get_session_id(headers) {
let sessions = state.sessions.read().unwrap();
if let Some(session) = sessions.get(&sid) {
return Ok(SessionInfo {
session_id: sid,
username: session.username.clone(),
});
}
}
Err(Redirect::to("/login").into_response())
}
fn add_flash(state: &AppState, session_id: &str, category: &str, message: &str) {
if let Ok(mut sessions) = state.sessions.write() {
if let Some(session) = sessions.get_mut(session_id) {
session.flashes.push(FlashMessage {
category: category.into(),
message: message.into(),
});
}
}
}
fn take_flashes(state: &AppState, session_id: &str) -> Vec<FlashMessage> {
if let Ok(mut sessions) = state.sessions.write() {
if let Some(session) = sessions.get_mut(session_id) {
return std::mem::take(&mut session.flashes);
}
}
Vec::new()
}
fn redirect_with_cookie(path: &str, cookie: &str) -> Response {
Response::builder()
.status(StatusCode::SEE_OTHER)
.header("location", path)
.header("set-cookie", cookie)
.body(Body::empty())
.unwrap()
}
// --- Contexte template commun ---
fn base_context(state: &AppState, session: &SessionInfo) -> Context {
let flashes = take_flashes(state, &session.session_id);
let config = state.config.read().unwrap();
let default_pw = config::is_default_password(&config);
let mut ctx = Context::new();
ctx.insert("authenticated", &true);
ctx.insert("flash_messages", &flashes);
ctx.insert("default_pw", &default_pw);
ctx.insert("username", &session.username);
ctx
}
fn render(tera: &Tera, template: &str, ctx: &Context) -> Result<Html<String>, Response> {
tera.render(template, ctx).map(Html).map_err(|e| {
eprintln!("[Template] Erreur {}: {}", template, e);
(StatusCode::INTERNAL_SERVER_ERROR, "Erreur interne").into_response()
})
}
// --- Middleware securite ---
fn apply_security_headers(response: &mut Response) {
let h = response.headers_mut();
h.insert("x-content-type-options", HeaderValue::from_static("nosniff"));
h.insert("x-frame-options", HeaderValue::from_static("DENY"));
h.insert(
"x-xss-protection",
HeaderValue::from_static("1; mode=block"),
);
h.insert(
"referrer-policy",
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
}
// --- Routes ---
// GET /login
async fn login_page(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let mut ctx = Context::new();
ctx.insert("flash_messages", &Vec::<FlashMessage>::new());
let mut resp = render(&state.tera, "login.html", &ctx)?.into_response();
apply_security_headers(&mut resp);
Ok::<_, Response>(resp)
}
// POST /login
#[derive(Deserialize)]
struct LoginForm {
username: String,
password: String,
}
async fn login_action(
State(state): State<Arc<AppState>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Form(form): Form<LoginForm>,
) -> Response {
// Rate limiting : 10 tentatives par minute
{
let mut limiter = state.rate_limiter.write().unwrap();
if !limiter.check(addr.ip(), 10, Duration::from_secs(60)) {
let mut ctx = Context::new();
ctx.insert(
"flash_messages",
&vec![FlashMessage {
category: "danger".into(),
message: "Trop de tentatives. Reessayez dans une minute.".into(),
}],
);
let html = state.tera.render("login.html", &ctx).unwrap_or_default();
return (StatusCode::TOO_MANY_REQUESTS, Html(html)).into_response();
}
}
let config = state.config.read().unwrap().clone();
let username = form.username.trim();
if username == config.admin.username
&& config::verify_password(&form.password, &config.admin.password_hash)
{
let session_id = uuid::Uuid::new_v4().to_string();
{
let mut sessions = state.sessions.write().unwrap();
sessions.insert(
session_id.clone(),
Session {
username: username.to_string(),
flashes: Vec::new(),
},
);
}
let cookie = format!(
"session_id={}; HttpOnly; Path=/; SameSite=Strict; Max-Age=28800",
session_id
);
redirect_with_cookie("/", &cookie)
} else {
let mut ctx = Context::new();
ctx.insert(
"flash_messages",
&vec![FlashMessage {
category: "danger".into(),
message: "Identifiants incorrects.".into(),
}],
);
let html = state.tera.render("login.html", &ctx).unwrap_or_default();
Html(html).into_response()
}
}
// GET /logout
async fn logout(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
if let Ok(session) = check_auth(&state, &headers) {
let mut sessions = state.sessions.write().unwrap();
sessions.remove(&session.session_id);
}
let cookie = "session_id=; HttpOnly; Path=/; Max-Age=0";
redirect_with_cookie("/login", cookie)
}
// GET /
async fn dashboard(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Response, Response> {
let session = check_auth(&state, &headers)?;
let mut ctx = base_context(&state, &session);
ctx.insert("active_page", "dashboard");
let metrics = state.metrics.read().unwrap().clone();
ctx.insert("metrics", &metrics);
let mut resp = render(&state.tera, "dashboard.html", &ctx)?.into_response();
apply_security_headers(&mut resp);
Ok(resp)
}
// GET /api/metrics
async fn api_metrics(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Response, Response> {
check_auth(&state, &headers)?;
let metrics = state.metrics.read().unwrap().clone();
let mut resp = match metrics {
Some(m) => Json(m).into_response(),
None => Json(serde_json::json!({})).into_response(),
};
apply_security_headers(&mut resp);
Ok(resp)
}
// POST /api/monitoring/toggle
async fn toggle_monitoring(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Response, Response> {
let session = check_auth(&state, &headers)?;
let was_active = state.monitoring_active.load(Ordering::Relaxed);
state.monitoring_active.store(!was_active, Ordering::Relaxed);
if was_active {
add_flash(&state, &session.session_id, "warning", "Monitoring arrete.");
} else {
add_flash(
&state,
&session.session_id,
"success",
"Monitoring demarre.",
);
}
Ok(Redirect::to("/").into_response())
}
// GET /settings
async fn settings_page(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Response, Response> {
let session = check_auth(&state, &headers)?;
let mut ctx = base_context(&state, &session);
ctx.insert("active_page", "settings");
let config = state.config.read().unwrap().clone();
ctx.insert("config", &config);
// SMTP avec mot de passe masque
let password_masked = if config.smtp.password.is_empty() {
String::new()
} else {
"********".into()
};
let mut smtp = serde_json::to_value(&config.smtp).unwrap();
smtp["password_masked"] = serde_json::Value::String(password_masked);
ctx.insert("smtp", &smtp);
let mut resp = render(&state.tera, "settings.html", &ctx)?.into_response();
apply_security_headers(&mut resp);
Ok(resp)
}
// POST /settings/thresholds
#[derive(Deserialize)]
struct ThresholdsForm {
cpu_percent: u32,
ram_percent: u32,
disk_percent: u32,
}
async fn update_thresholds(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Form(form): Form<ThresholdsForm>,
) -> Result<Response, Response> {
let session = check_auth(&state, &headers)?;
for (name, val) in [
("cpu_percent", form.cpu_percent),
("ram_percent", form.ram_percent),
("disk_percent", form.disk_percent),
] {
if !(1..=100).contains(&val) {
add_flash(
&state,
&session.session_id,
"danger",
&format!("Le seuil {} doit etre entre 1 et 100.", name),
);
return Ok(Redirect::to("/settings").into_response());
}
}
{
let mut config = state.config.write().unwrap();
config.thresholds.cpu_percent = form.cpu_percent;
config.thresholds.ram_percent = form.ram_percent;
config.thresholds.disk_percent = form.disk_percent;
config::save_config(&state.data_dir, &config);
}
add_flash(&state, &session.session_id, "success", "Seuils mis a jour.");
Ok(Redirect::to("/settings").into_response())
}
// POST /settings/monitoring
#[derive(Deserialize)]
struct MonitoringForm {
check_interval_minutes: u64,
alert_cooldown_minutes: u64,
}
async fn update_monitoring(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Form(form): Form<MonitoringForm>,
) -> Result<Response, Response> {
let session = check_auth(&state, &headers)?;
if form.check_interval_minutes < 1 {
add_flash(
&state,
&session.session_id,
"danger",
"L'intervalle doit etre d'au moins 1 minute.",
);
return Ok(Redirect::to("/settings").into_response());
}
if form.alert_cooldown_minutes < 1 {
add_flash(
&state,
&session.session_id,
"danger",
"Le cooldown doit etre d'au moins 1 minute.",
);
return Ok(Redirect::to("/settings").into_response());
}
{
let mut config = state.config.write().unwrap();
config.check_interval_minutes = form.check_interval_minutes;
config.alert_cooldown_minutes = form.alert_cooldown_minutes;
config::save_config(&state.data_dir, &config);
}
add_flash(
&state,
&session.session_id,
"success",
"Parametres de monitoring mis a jour.",
);
Ok(Redirect::to("/settings").into_response())
}
// POST /settings/smtp
#[derive(Deserialize)]
struct SmtpForm {
smtp_server: String,
smtp_port: u16,
smtp_tls: Option<String>,
smtp_username: String,
smtp_password: Option<String>,
smtp_from: String,
smtp_to: String,
}
async fn update_smtp(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Form(form): Form<SmtpForm>,
) -> Result<Response, Response> {
let session = check_auth(&state, &headers)?;
let to_emails: Vec<String> = form
.smtp_to
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
{
let mut config = state.config.write().unwrap();
config.smtp.server = form.smtp_server.trim().to_string();
config.smtp.port = form.smtp_port;
config.smtp.use_tls = form.smtp_tls.is_some();
config.smtp.username = form.smtp_username.trim().to_string();
config.smtp.from_email = form.smtp_from.trim().to_string();
config.smtp.to_emails = to_emails;
if let Some(ref pw) = form.smtp_password {
if !pw.is_empty() {
config.smtp.password = pw.clone();
}
}
config::save_config(&state.data_dir, &config);
}
add_flash(
&state,
&session.session_id,
"success",
"Configuration SMTP mise a jour.",
);
Ok(Redirect::to("/settings").into_response())
}
// POST /settings/smtp/test
async fn test_smtp(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Response, Response> {
let session = check_auth(&state, &headers)?;
let smtp_config = state.config.read().unwrap().smtp.clone();
let result = tokio::task::spawn_blocking(move || alerter::send_test(&smtp_config)).await;
match result {
Ok(Ok(msg)) => add_flash(
&state,
&session.session_id,
"success",
&format!("Test reussi : {}", msg),
),
Ok(Err(msg)) => add_flash(
&state,
&session.session_id,
"danger",
&format!("Test echoue : {}", msg),
),
Err(e) => add_flash(
&state,
&session.session_id,
"danger",
&format!("Erreur: {}", e),
),
}
Ok(Redirect::to("/settings").into_response())
}
// POST /settings/processes
async fn update_processes(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: String,
) -> Result<Response, Response> {
let session = check_auth(&state, &headers)?;
let pairs: Vec<(String, String)> = form_urlencoded::parse(body.as_bytes())
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
let names: Vec<&str> = pairs
.iter()
.filter(|(k, _)| k == "proc_name[]")
.map(|(_, v)| v.as_str())
.collect();
let patterns: Vec<&str> = pairs
.iter()
.filter(|(k, _)| k == "proc_pattern[]")
.map(|(_, v)| v.as_str())
.collect();
let mem_thresholds: Vec<&str> = pairs
.iter()
.filter(|(k, _)| k == "proc_mem_threshold[]")
.map(|(_, v)| v.as_str())
.collect();
let enableds: Vec<&str> = pairs
.iter()
.filter(|(k, _)| k == "proc_enabled[]")
.map(|(_, v)| v.as_str())
.collect();
let alert_downs: Vec<&str> = pairs
.iter()
.filter(|(k, _)| k == "proc_alert_down[]")
.map(|(_, v)| v.as_str())
.collect();
let mut processes = Vec::new();
for (i, name) in names.iter().enumerate() {
let name = name.trim();
if name.is_empty() {
continue;
}
let pattern = patterns
.get(i)
.map(|s| s.trim().to_lowercase())
.unwrap_or_default();
let mem_threshold = mem_thresholds
.get(i)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let idx_str = i.to_string();
let enabled = enableds.contains(&idx_str.as_str());
let alert_on_down = alert_downs.contains(&idx_str.as_str());
processes.push(config::ProcessConfig {
name: name.to_string(),
pattern,
memory_threshold_mb: mem_threshold,
enabled,
alert_on_down,
});
}
{
let mut config = state.config.write().unwrap();
config.processes = processes;
config::save_config(&state.data_dir, &config);
}
add_flash(
&state,
&session.session_id,
"success",
"Processus surveilles mis a jour.",
);
Ok(Redirect::to("/settings").into_response())
}
// POST /settings/password
#[derive(Deserialize)]
struct PasswordForm {
current_password: String,
new_password: String,
confirm_password: String,
}
async fn update_password(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Form(form): Form<PasswordForm>,
) -> Result<Response, Response> {
let session = check_auth(&state, &headers)?;
{
let config = state.config.read().unwrap();
if !config::verify_password(&form.current_password, &config.admin.password_hash) {
add_flash(
&state,
&session.session_id,
"danger",
"Mot de passe actuel incorrect.",
);
return Ok(Redirect::to("/settings").into_response());
}
}
if form.new_password.len() < 8 {
add_flash(
&state,
&session.session_id,
"danger",
"Le nouveau mot de passe doit faire au moins 8 caracteres.",
);
return Ok(Redirect::to("/settings").into_response());
}
if form.new_password != form.confirm_password {
add_flash(
&state,
&session.session_id,
"danger",
"Les mots de passe ne correspondent pas.",
);
return Ok(Redirect::to("/settings").into_response());
}
{
let mut config = state.config.write().unwrap();
config.admin.password_hash = config::hash_password(&form.new_password);
config::save_config(&state.data_dir, &config);
}
add_flash(
&state,
&session.session_id,
"success",
"Mot de passe mis a jour.",
);
Ok(Redirect::to("/settings").into_response())
}
// POST /settings/port
#[derive(Deserialize)]
struct PortForm {
port: u16,
}
async fn update_port(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Form(form): Form<PortForm>,
) -> Result<Response, Response> {
let session = check_auth(&state, &headers)?;
if !(1024..=65535).contains(&form.port) {
add_flash(
&state,
&session.session_id,
"danger",
"Le port doit etre entre 1024 et 65535.",
);
return Ok(Redirect::to("/settings").into_response());
}
{
let mut config = state.config.write().unwrap();
config.port = form.port;
config::save_config(&state.data_dir, &config);
}
add_flash(
&state,
&session.session_id,
"warning",
&format!(
"Port mis a jour a {}. Redemarrez l'application pour appliquer.",
form.port
),
);
Ok(Redirect::to("/settings").into_response())
}
// GET /alerts
async fn alerts_page(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Response, Response> {
let session = check_auth(&state, &headers)?;
let mut ctx = base_context(&state, &session);
ctx.insert("active_page", "alerts");
let alerts = config::load_alerts(&state.data_dir);
ctx.insert("alerts", &alerts);
let mut resp = render(&state.tera, "alerts.html", &ctx)?.into_response();
apply_security_headers(&mut resp);
Ok(resp)
}
// POST /alerts/clear
async fn clear_alerts(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Response, Response> {
let session = check_auth(&state, &headers)?;
config::clear_alerts(&state.data_dir);
add_flash(
&state,
&session.session_id,
"success",
"Historique des alertes efface.",
);
Ok(Redirect::to("/alerts").into_response())
}
// --- Boucle de monitoring (thread separee) ---
fn start_monitoring(state: Arc<AppState>) {
std::thread::spawn(move || {
let mut sys = sysinfo::System::new();
// Premiere mesure CPU (besoin de deux lectures)
sys.refresh_cpu_usage();
std::thread::sleep(Duration::from_secs(1));
sys.refresh_cpu_usage();
sys.refresh_memory();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let disks = sysinfo::Disks::new_with_refreshed_list();
// Collecte initiale
{
let cfg = state.config.read().unwrap().clone();
let active = state.monitoring_active.load(Ordering::Relaxed);
let metrics = monitor::collect_metrics(&sys, &disks, &cfg, active);
*state.metrics.write().unwrap() = Some(metrics);
}
let mut last_alerts: HashMap<String, chrono::DateTime<chrono::Local>> = HashMap::new();
loop {
let is_active = state.monitoring_active.load(Ordering::Relaxed);
if !is_active {
if let Ok(mut m) = state.metrics.write() {
if let Some(ref mut metrics) = *m {
metrics.monitoring_active = false;
}
}
std::thread::sleep(Duration::from_secs(5));
continue;
}
// Rafraichir les metriques systeme
sys.refresh_cpu_usage();
std::thread::sleep(Duration::from_millis(500));
sys.refresh_cpu_usage();
sys.refresh_memory();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let disks = sysinfo::Disks::new_with_refreshed_list();
let cfg = state.config.read().unwrap().clone();
let metrics = monitor::collect_metrics(&sys, &disks, &cfg, true);
// Verification des seuils et envoi d'alertes
let pending = monitor::check_thresholds(&metrics, &cfg);
let cooldown_mins = cfg.alert_cooldown_minutes as i64;
for alert_info in &pending {
let now = chrono::Local::now();
let should_alert = match last_alerts.get(&alert_info.key) {
Some(last) => (now - *last).num_minutes() >= cooldown_mins,
None => true,
};
if should_alert {
let alert = Alert {
timestamp: now.format("%Y-%m-%dT%H:%M:%S").to_string(),
alert_type: alert_info.alert_type.clone(),
key: alert_info.key.clone(),
message: alert_info.message.clone(),
value: alert_info.value,
threshold: alert_info.threshold,
hostname: metrics.hostname.clone(),
};
config::save_alert(&state.data_dir, &alert);
if alerter::is_configured(&cfg.smtp) {
let subject =
format!("[ALERTE] {} - {}", metrics.hostname, alert_info.message);
let body = alerter::format_alert_body(&alert);
if let Err(e) = alerter::send_email(&cfg.smtp, &subject, &body) {
eprintln!("[Alerter] Erreur envoi: {}", e);
}
}
last_alerts.insert(alert_info.key.clone(), now);
}
}
// Mettre a jour les metriques partagees
*state.metrics.write().unwrap() = Some(metrics);
// Dormir jusqu'au prochain check (verifier toutes les 5s si on doit s'arreter)
let interval = Duration::from_secs(cfg.check_interval_minutes * 60);
let start = Instant::now();
while start.elapsed() < interval {
if !state.monitoring_active.load(Ordering::Relaxed) {
break;
}
std::thread::sleep(Duration::from_secs(5));
}
}
});
}
// --- Point d'entree ---
#[tokio::main]
async fn main() {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
.unwrap_or_else(|| PathBuf::from("."));
// Chercher les templates et static dans le repertoire courant ou celui de l'exe
let work_dir = if PathBuf::from("templates").exists() {
PathBuf::from(".")
} else {
exe_dir
};
let data_dir = work_dir.join("data");
let config = config::load_config(&data_dir);
let port = config.port;
// Charger les templates Tera
let template_path = work_dir.join("templates").join("**").join("*.html");
let tera = match Tera::new(template_path.to_str().unwrap()) {
Ok(t) => t,
Err(e) => {
eprintln!("[ERREUR] Chargement des templates: {}", e);
std::process::exit(1);
}
};
if config::is_default_password(&config) {
println!(
"[ATTENTION] Le mot de passe admin est encore 'admin'. Changez-le immediatement !"
);
}
let state = Arc::new(AppState {
config: RwLock::new(config),
metrics: RwLock::new(None),
sessions: RwLock::new(HashMap::new()),
rate_limiter: RwLock::new(RateLimiter::new()),
monitoring_active: AtomicBool::new(true),
tera,
data_dir,
});
// Demarrer la boucle de monitoring
start_monitoring(Arc::clone(&state));
println!("[Supervision] Monitoring actif");
// Toutes les routes
let static_dir = work_dir.join("static");
let app = Router::new()
.route("/login", get(login_page).post(login_action))
.route("/", get(dashboard))
.route("/logout", get(logout))
.route("/api/metrics", get(api_metrics))
.route("/api/monitoring/toggle", post(toggle_monitoring))
.route("/settings", get(settings_page))
.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("/alerts", get(alerts_page))
.route("/alerts/clear", post(clear_alerts))
.nest_service("/static", ServeDir::new(static_dir))
.with_state(state);
// Demarrage du serveur
let addr = SocketAddr::from(([0, 0, 0, 0], port));
println!("[Supervision] Demarrage sur le port {}", port);
println!("[Supervision] Interface : http://localhost:{}", port);
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
Err(_) => {
eprintln!("[ERREUR] Le port {} est deja utilise.", port);
eprintln!("Modifiez le port dans data/config.json ou liberez le port.");
std::process::exit(1);
}
};
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.unwrap();
}

344
src/monitor.rs Normal file
View File

@@ -0,0 +1,344 @@
use crate::config::{Config, ProcessConfig};
use serde::Serialize;
use sysinfo::{Disks, System};
// --- Structures de metriques ---
#[derive(Debug, Clone, Serialize)]
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,
}
#[derive(Debug, Clone, Serialize)]
pub struct CpuMetrics {
pub percent: f32,
pub cores: usize,
pub threshold: u32,
pub status: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct RamMetrics {
pub percent: f64,
pub total_gb: f64,
pub used_gb: f64,
pub available_gb: f64,
pub threshold: u32,
pub status: String,
}
#[derive(Debug, Clone, Serialize)]
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: u32,
pub status: String,
}
#[derive(Debug, Clone, Serialize)]
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: f32,
pub memory_threshold_mb: u64,
pub memory_status: String,
pub pids: Vec<u32>,
}
// Info d'alerte retournee par check_thresholds
pub struct AlertInfo {
pub key: String,
pub alert_type: String,
pub message: String,
pub value: f64,
pub threshold: f64,
}
// --- Collecte des metriques ---
pub fn collect_metrics(sys: &System, disks: &Disks, config: &Config, monitoring_active: bool) -> Metrics {
let now = chrono::Local::now();
let interval_mins = config.check_interval_minutes;
// CPU
let cpu_percent = sys.global_cpu_usage();
let cpu_status = eval_status(cpu_percent as f64, config.thresholds.cpu_percent as f64);
// RAM
let total_mem = sys.total_memory() as f64;
let used_mem = sys.used_memory() as f64;
let available_mem = sys.available_memory() as f64;
let ram_percent = if total_mem > 0.0 {
(used_mem / total_mem) * 100.0
} else {
0.0
};
let ram_status = eval_status(ram_percent, config.thresholds.ram_percent as f64);
// Disques
let gb = 1024.0 * 1024.0 * 1024.0;
let ignored_fs = ["squashfs", "tmpfs", "devtmpfs", "overlay", "iso9660"];
let disk_metrics: Vec<DiskMetrics> = disks
.list()
.iter()
.filter(|d| {
let fs = d.file_system().to_string_lossy().to_string();
let name = d.name().to_string_lossy().to_string();
!ignored_fs.contains(&fs.as_str())
&& !name.starts_with("/dev/loop")
&& d.total_space() >= (1024 * 1024 * 1024) // >= 1 Go
})
.filter_map(|d| {
let total = d.total_space() as f64;
let available = d.available_space() as f64;
let used = total - available;
let percent = if total > 0.0 {
(used / total) * 100.0
} else {
return None;
};
let status = eval_status(percent, config.thresholds.disk_percent as f64);
Some(DiskMetrics {
drive: d.name().to_string_lossy().to_string(),
mountpoint: d.mount_point().to_string_lossy().to_string(),
percent: round1(percent),
total_gb: round1(total / gb),
used_gb: round1(used / gb),
free_gb: round1(available / gb),
threshold: config.thresholds.disk_percent,
status,
})
})
.collect();
// Processus surveilles
let process_metrics = check_processes(sys, &config.processes);
// Uptime
let boot_secs = System::boot_time();
let boot_time = chrono::DateTime::from_timestamp(boot_secs as i64, 0)
.unwrap_or_default()
.with_timezone(&chrono::Local);
let uptime_secs = System::uptime();
let uptime_str = format_duration(uptime_secs);
Metrics {
timestamp: now.format("%Y-%m-%dT%H:%M:%S").to_string(),
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: round1(ram_percent),
total_gb: round1(total_mem / gb),
used_gb: round1(used_mem / gb),
available_gb: round1(available_mem / gb),
threshold: config.thresholds.ram_percent,
status: ram_status,
},
disks: disk_metrics,
processes: process_metrics,
uptime: uptime_str,
boot_time: boot_time.format("%Y-%m-%dT%H:%M:%S").to_string(),
monitoring_active,
last_check: now.format("%Y-%m-%dT%H:%M:%S").to_string(),
next_check: (now + chrono::Duration::minutes(interval_mins as i64))
.format("%Y-%m-%dT%H:%M:%S")
.to_string(),
}
}
fn check_processes(sys: &System, process_configs: &[ProcessConfig]) -> Vec<ProcessMetrics> {
process_configs
.iter()
.map(|cfg| {
let pattern = cfg.pattern.to_lowercase();
let mut found_pids = Vec::new();
let mut total_mem: f64 = 0.0;
let mut total_cpu: f32 = 0.0;
if cfg.enabled {
for (pid, proc) in sys.processes() {
let pname = proc.name().to_string_lossy().to_lowercase();
let cmdline = proc
.cmd()
.iter()
.map(|s| s.to_string_lossy().to_lowercase())
.collect::<Vec<_>>()
.join(" ");
if pname.contains(&pattern) || cmdline.contains(&pattern) {
let mem_mb = proc.memory() as f64 / (1024.0 * 1024.0);
total_mem += mem_mb;
total_cpu += proc.cpu_usage();
found_pids.push(pid.as_u32());
}
}
}
let running = !found_pids.is_empty();
let mem_status = if cfg.memory_threshold_mb > 0 && total_mem > 0.0 {
eval_status(total_mem, cfg.memory_threshold_mb as f64)
} else {
"ok".into()
};
ProcessMetrics {
name: cfg.name.clone(),
pattern: cfg.pattern.clone(),
running,
enabled: cfg.enabled,
alert_on_down: cfg.alert_on_down,
instance_count: found_pids.len(),
total_memory_mb: round1(total_mem),
total_cpu_percent: (total_cpu * 10.0).round() / 10.0,
memory_threshold_mb: cfg.memory_threshold_mb,
memory_status: mem_status,
pids: found_pids,
}
})
.collect()
}
// --- Verification des seuils ---
pub fn check_thresholds(metrics: &Metrics, _config: &Config) -> Vec<AlertInfo> {
let mut alerts = Vec::new();
// CPU
if metrics.cpu.status == "critical" {
alerts.push(AlertInfo {
key: "cpu".into(),
alert_type: "threshold".into(),
message: format!(
"CPU a {}% (seuil: {}%)",
metrics.cpu.percent, metrics.cpu.threshold
),
value: metrics.cpu.percent as f64,
threshold: metrics.cpu.threshold as f64,
});
}
// RAM
if metrics.ram.status == "critical" {
alerts.push(AlertInfo {
key: "ram".into(),
alert_type: "threshold".into(),
message: format!(
"RAM a {}% (seuil: {}%)",
metrics.ram.percent, metrics.ram.threshold
),
value: metrics.ram.percent,
threshold: metrics.ram.threshold as f64,
});
}
// Disques
for disk in &metrics.disks {
if disk.status == "critical" {
alerts.push(AlertInfo {
key: format!("disk_{}", disk.drive),
alert_type: "threshold".into(),
message: format!(
"Disque {} a {}% (seuil: {}%)",
disk.drive, disk.percent, disk.threshold
),
value: disk.percent,
threshold: disk.threshold as f64,
});
}
}
// Processus
for proc in &metrics.processes {
if !proc.enabled {
continue;
}
if proc.alert_on_down && !proc.running {
alerts.push(AlertInfo {
key: format!("process_down_{}", proc.name),
alert_type: "process_down".into(),
message: format!(
"Processus '{}' non detecte (pattern: {})",
proc.name, proc.pattern
),
value: 0.0,
threshold: 0.0,
});
}
if proc.memory_threshold_mb > 0 && proc.memory_status == "critical" {
alerts.push(AlertInfo {
key: format!("process_mem_{}", proc.name),
alert_type: "threshold".into(),
message: format!(
"Processus '{}' utilise {} Mo (seuil: {} Mo)",
proc.name, proc.total_memory_mb, proc.memory_threshold_mb
),
value: proc.total_memory_mb,
threshold: proc.memory_threshold_mb as f64,
});
}
}
alerts
}
// --- Utilitaires ---
fn eval_status(value: f64, threshold: f64) -> String {
if threshold <= 0.0 {
return "ok".into();
}
let ratio = value / threshold;
if ratio >= 1.0 {
"critical".into()
} else if ratio >= 0.80 {
"warning".into()
} else {
"ok".into()
}
}
fn round1(v: f64) -> f64 {
(v * 10.0).round() / 10.0
}
fn format_duration(secs: u64) -> String {
let days = secs / 86400;
let hours = (secs % 86400) / 3600;
let mins = (secs % 3600) / 60;
let s = secs % 60;
if days > 0 {
format!("{} jour(s), {:02}:{:02}:{:02}", days, hours, mins, s)
} else {
format!("{:02}:{:02}:{:02}", hours, mins, s)
}
}

View File

@@ -1,11 +1,11 @@
{% extends "base.html" %}
{% block title %}Supervision - Alertes{% endblock %}
{% block title %}Supervision - Alertes{% endblock title %}
{% 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="{{ url_for('clear_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
@@ -35,10 +35,10 @@
{% for alert in alerts %}
<tr>
<td class="text-nowrap">
<small>{{ alert.timestamp[:19] | replace('T', ' ') }}</small>
<small>{{ alert.timestamp | truncate(length=19, end="") | replace(from="T", to=" ") }}</small>
</td>
<td>
{% if alert.type == 'process_down' %}
{% if alert.type == "process_down" %}
<span class="badge bg-danger">Processus</span>
{% else %}
<span class="badge bg-warning text-dark">Seuil</span>
@@ -57,4 +57,4 @@
{{ alerts | length }} alerte(s) — les 500 dernieres sont conservees.
</div>
{% endif %}
{% endblock %}
{% endblock content %}

View File

@@ -3,16 +3,16 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Supervision{% endblock %}</title>
<title>{% block title %}Supervision{% endblock title %}</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="{{ url_for('static', filename='style.css') }}" rel="stylesheet">
<link href="/static/style.css" rel="stylesheet">
</head>
<body>
{% if current_user.is_authenticated %}
{% if authenticated %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard') }}">
<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">
@@ -21,27 +21,27 @@
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}"
href="{{ url_for('dashboard') }}">
<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 request.endpoint == 'settings' %}active{% endif %}"
href="{{ url_for('settings') }}">
<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 request.endpoint == 'alerts' %}active{% endif %}"
href="{{ url_for('alerts') }}">
<a class="nav-link {% if active_page == "alerts" %}active{% endif %}"
href="/alerts">
<i class="bi bi-bell"></i> Alertes
</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('logout') }}">
<a class="nav-link" href="/logout">
<i class="bi bi-box-arrow-right"></i> Deconnexion
</a>
</li>
@@ -52,29 +52,27 @@
{% endif %}
<div class="container-fluid mt-3">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
{% if flash_messages %}
{% for msg in flash_messages %}
<div class="alert alert-{{ msg.category }} alert-dismissible fade show" role="alert">
{{ msg.message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if default_pw is defined and default_pw %}
{% if default_pw %}
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle"></i>
<strong>Securite :</strong> Le mot de passe par defaut est encore actif.
<a href="{{ url_for('settings') }}#password" class="alert-link">Changez-le maintenant</a>.
<a href="/settings#password" class="alert-link">Changez-le maintenant</a>.
</div>
{% endif %}
{% block content %}{% endblock %}
{% block content %}{% endblock content %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
{% block scripts %}{% endblock scripts %}
</body>
</html>

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Supervision - Tableau de bord{% endblock %}
{% block title %}Supervision - Tableau de bord{% endblock title %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -11,7 +11,7 @@
</h4>
<div class="d-flex align-items-center gap-2">
<span id="last-update" class="text-muted small"></span>
<form method="POST" action="{{ url_for('toggle_monitoring') }}" class="d-inline">
<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
@@ -86,7 +86,7 @@
</div>
</div>
</div>
<!-- Placeholder pour les disques - sera rempli par JS aussi -->
<!-- Disques -->
{% for disk in metrics.disks %}
<div class="col-md-4 mb-3">
<div class="card metric-card" id="card-disk-{{ loop.index0 }}">
@@ -154,7 +154,7 @@
{% endif %}
</td>
<td>{{ proc.total_cpu_percent }}%</td>
<td><small>{{ proc.pids | join(', ') }}</small></td>
<td><small>{{ proc.pids | join(sep=", ") }}</small></td>
</tr>
{% endfor %}
</tbody>
@@ -170,7 +170,7 @@
<div class="card">
<div class="card-header d-flex justify-content-between">
<h6 class="mb-0"><i class="bi bi-bell"></i> Alertes recentes</h6>
<a href="{{ url_for('alerts') }}" class="btn btn-sm btn-outline-primary">Voir tout</a>
<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">
@@ -187,7 +187,7 @@
</div>
{% endif %}
{% endblock %}
{% endblock content %}
{% block scripts %}
<script>
@@ -237,16 +237,7 @@ function refreshMetrics() {
.catch(() => {});
}
function loadRecentAlerts() {
fetch('/api/metrics')
.then(r => r.json())
.then(() => {
// Charger les alertes separement via la page
})
.catch(() => {});
}
// Rafraichir toutes les 30 secondes
setInterval(refreshMetrics, 30000);
</script>
{% endblock %}
{% endblock scripts %}

View File

@@ -23,20 +23,18 @@
<small class="text-secondary">Monitoring systeme</small>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
{% if flash_messages %}
{% for msg in flash_messages %}
<div class="alert alert-{{ msg.category }} alert-dismissible fade show" role="alert">
{{ msg.message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="card shadow">
<div class="card-body p-4">
<form method="POST" action="{{ url_for('login') }}">
<form method="POST" action="/login">
<div class="mb-3">
<label for="username" class="form-label">Identifiant</label>
<div class="input-group">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Supervision - Configuration{% endblock %}
{% block title %}Supervision - Configuration{% endblock title %}
{% block content %}
<h4 class="mb-3"><i class="bi bi-gear"></i> Configuration</h4>
@@ -10,7 +10,7 @@
<div class="card">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-sliders"></i> Seuils d'alerte (%)</h6></div>
<div class="card-body">
<form method="POST" action="{{ url_for('update_thresholds') }}">
<form method="POST" action="/settings/thresholds">
<div class="mb-3">
<label for="cpu_percent" class="form-label">CPU (%)</label>
<input type="number" class="form-control" id="cpu_percent" name="cpu_percent"
@@ -39,7 +39,7 @@
<div class="card">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-clock"></i> Frequence et alertes</h6></div>
<div class="card-body">
<form method="POST" action="{{ url_for('update_monitoring') }}">
<form method="POST" action="/settings/monitoring">
<div class="mb-3">
<label for="check_interval_minutes" class="form-label">Intervalle de verification (minutes)</label>
<input type="number" class="form-control" id="check_interval_minutes"
@@ -64,7 +64,7 @@
<div class="card mt-4">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-diagram-3"></i> Port de l'application</h6></div>
<div class="card-body">
<form method="POST" action="{{ url_for('update_port') }}">
<form method="POST" action="/settings/port">
<div class="mb-3">
<label for="port" class="form-label">Port (redemarrage requis)</label>
<input type="number" class="form-control" id="port" name="port"
@@ -85,7 +85,7 @@
<div class="card">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-envelope"></i> Configuration SMTP</h6></div>
<div class="card-body">
<form method="POST" action="{{ url_for('update_smtp') }}">
<form method="POST" action="/settings/smtp">
<div class="row">
<div class="col-md-6 mb-3">
<label for="smtp_server" class="form-label">Serveur SMTP</label>
@@ -128,7 +128,7 @@
<div class="col-md-6 mb-3">
<label for="smtp_to" class="form-label">Destinataires (separes par des virgules)</label>
<input type="text" class="form-control" id="smtp_to" name="smtp_to"
value="{{ smtp.to_emails | join(', ') }}"
value="{{ smtp.to_emails | join(sep=", ") }}"
placeholder="admin@example.com, tech@example.com">
</div>
</div>
@@ -139,7 +139,7 @@
</div>
</form>
<hr>
<form method="POST" action="{{ url_for('test_smtp') }}" class="d-inline">
<form method="POST" action="/settings/smtp/test" class="d-inline">
<button type="submit" class="btn btn-outline-info">
<i class="bi bi-send"></i> Envoyer un email de test
</button>
@@ -157,7 +157,7 @@
<h6 class="mb-0"><i class="bi bi-list-task"></i> Processus surveilles</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('update_processes') }}" id="process-form">
<form method="POST" action="/settings/processes" id="process-form">
<table class="table" id="proc-table">
<thead>
<tr>
@@ -231,7 +231,7 @@
<h6 class="mb-0"><i class="bi bi-shield-lock"></i> Mot de passe administrateur</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('update_password') }}">
<form method="POST" action="/settings/password">
<div class="mb-3">
<label for="current_password" class="form-label">Mot de passe actuel</label>
<input type="password" class="form-control" id="current_password"
@@ -257,7 +257,7 @@
</div>
</div>
{% endblock %}
{% endblock content %}
{% block scripts %}
<script>
@@ -285,4 +285,4 @@ document.addEventListener('click', function(e) {
}
});
</script>
{% endblock %}
{% endblock scripts %}