18 Commits

Author SHA1 Message Date
oussi
62a478a92a V1.0.1 2026-04-20 16:54:55 +02:00
oussi
8c59a1ab31 Résolution de bugs 2026-04-13 16:12:42 +02:00
oussi
da51482cbd fix: remove docs/ from tracking and add to .gitignore 2026-04-07 15:33:48 +02:00
oussi
c3999d5215 doc: plan d'implémentation supervision-rs (14 tâches)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 11:25:26 +02:00
oussi
29df58331a doc: ajout du spec de design supervision-rs (réécriture Rust)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 11:04:57 +02:00
oussi
f2f41c47ae ajout du suivie utilisateur 2026-04-07 10:41:25 +02:00
oussi
537a3d6f55 ajout du suivi utilisateur 2026-04-02 14:41:11 +02:00
oussi
a9b505c73c ajout du suivi utilisateur 2026-04-02 14:39:25 +02:00
oussi
65edffbbc1 ajout du suivi utilisateur 2026-04-02 14:32:04 +02:00
oussi
b3e91bb0e3 ajout du suivi utilisateur 2026-04-02 12:18:53 +02:00
oussi
8887d97f90 ajout du suivi utilisateur 2026-04-02 11:45:21 +02:00
oussi
1abe0f2657 ajout du suivi utilisateur 2026-04-02 11:44:31 +02:00
oussi
7abe46a6c4 ajout du suivi utilisateur 2026-04-02 11:44:10 +02:00
oussi
5354c9983d ajout du suivi utilisateur 2026-04-02 11:43:48 +02:00
oussi
52ef8143a6 ajout du suivi utilisateur 2026-04-02 11:42:58 +02:00
oussi
891bb7ab9a ajout du suivi utilisateur 2026-04-02 11:41:17 +02:00
oussi
5f9a71da10 docs: add implementation plan for Utilisateurs tab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:35:40 +02:00
oussi
90c5c154a7 docs: add design spec for Utilisateurs tab and Amadea log monitoring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:20:45 +02:00
21 changed files with 1192 additions and 4115 deletions

7
.gitignore vendored
View File

@@ -7,7 +7,12 @@ data/alerts.json
*.log *.log
.env .env
imput/ imput/
logTest/
log/
CLAUDE.md
docs/
.claude/
*.spec *.spec
build/ build/
dist/ dist/
target/ docs/

2358
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +0,0 @@
[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

154
README.md
View File

@@ -4,19 +4,20 @@ Outil de surveillance systeme avec interface web securisee.
Surveille CPU, RAM, disques et processus specifiques (JVM, Nginx, Amadea Web 8 x64). Surveille CPU, RAM, disques et processus specifiques (JVM, Nginx, Amadea Web 8 x64).
Envoie des alertes email lorsque les seuils configures sont depasses. Envoie des alertes email lorsque les seuils configures sont depasses.
**Binaire natif Rust** — aucune dependance runtime, ~6 Mo.
--- ---
## Fonctionnalites ## Fonctionnalites
- **Dashboard temps reel** : CPU, RAM, disques, processus surveilles (rafraichissement auto) - **Dashboard temps reel** : CPU, RAM, disques, processus surveilles (rafraichissement auto)
- **Suivi des utilisateurs Amadea** : statut en temps reel (Actif / Inactif / Deconnecte), derniere action, nombre d'actions sur 24h, graphique d'activite horaire
- **Alertes email** : envoi automatique quand un seuil est depasse, avec cooldown anti-spam - **Alertes email** : envoi automatique quand un seuil est depasse, avec cooldown anti-spam
- **Configuration complete via l'interface** : - **Configuration complete via l'interface** :
- Seuils d'alerte (CPU, RAM, disque) - Seuils d'alerte (CPU, RAM, disque)
- Frequence de verification (en minutes) - Frequence de verification (en minutes)
- Serveur SMTP + test d'envoi integre - Serveur SMTP + test d'envoi integre
- Ajout/suppression de processus a surveiller - Ajout/suppression de processus a surveiller
- Chemin du dossier de logs Amadea
- Seuils de statut utilisateurs (actif / inactif)
- Port de l'application - Port de l'application
- Mot de passe administrateur - Mot de passe administrateur
- **Securite** : authentification par login/mot de passe, rate limiting anti-bruteforce, en-tetes HTTP securises - **Securite** : authentification par login/mot de passe, rate limiting anti-bruteforce, en-tetes HTTP securises
@@ -33,11 +34,9 @@ Envoie des alertes email lorsque les seuils configures sont depasses.
### Etapes ### Etapes
1. **Copier** les fichiers suivants dans un dossier, par exemple `C:\supervision\` : 1. **Dezipper** `supervision_portable.zip` dans un dossier, par exemple :
``` ```
supervision.exe C:\supervision\
templates\ (dossier complet)
static\ (dossier complet)
``` ```
2. **Lancer** l'executable : 2. **Lancer** l'executable :
@@ -60,8 +59,6 @@ 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%) 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 ## Acces distant
@@ -104,54 +101,34 @@ Pour que Supervision demarre automatiquement avec Windows, utiliser [NSSM](https
--- ---
## Compilation depuis les sources ## Installation depuis les sources (developpement)
### Pre-requis ### Pre-requis
- [Rust](https://rustup.rs/) 1.75 ou superieur - Python 3.10 ou superieur
### Compiler pour la machine locale (Linux/Windows) ### Etapes
```bash 1. Creer l'environnement virtuel et installer les dependances :
cargo build --release ```cmd
cd C:\supervision
python -m venv .venv
.venv\Scripts\pip.exe install -r requirements.txt
```
2. Lancer :
```cmd
.venv\Scripts\python.exe app.py
```
### Compiler en executable
```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
``` ```
L'executable est genere dans `target/release/supervision` (Linux) ou `target\release\supervision.exe` (Windows). L'executable est genere dans `dist\supervision\`.
### 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
```
L'executable Windows est genere dans `target/x86_64-pc-windows-gnu/release/supervision.exe`.
### Lancer en mode developpement
```bash
cargo run
```
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 |
--- ---
@@ -160,25 +137,30 @@ L'application demarre sur http://localhost:5000 avec rechargement des templates
``` ```
supervision\ supervision\
├── supervision.exe # Executable principal ├── supervision.exe # Executable principal
├── Cargo.toml # Dependances Rust ├── _internal\ # Dependances Python embarquees
├── 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 ├── templates\ # Pages HTML de l'interface
│ ├── base.html ├── static\ # CSS
│ ├── login.html
│ ├── dashboard.html
│ ├── settings.html
│ └── alerts.html
├── static\
│ └── style.css # Styles CSS
└── data\ # (cree au 1er lancement) └── data\ # (cree au 1er lancement)
├── config.json # Configuration (seuils, SMTP, processus) ├── config.json # Configuration (seuils, SMTP, processus, logs Amadea)
└── alerts.json # Historique des alertes └── alerts.json # Historique des alertes
``` ```
### Structure des sources (developpement)
```
supervision\
├── app.py # Application Flask, routes
├── monitor.py # Surveillance CPU/RAM/disques/processus
├── user_monitor.py # Suivi utilisateurs Amadea (parsing logs)
├── alerter.py # Envoi d'alertes email
├── config_manager.py # Persistance configuration JSON
├── templates\ # Pages HTML Jinja2
├── static\ # CSS
└── tests\ # Tests unitaires (pytest)
```
> **Important** : ne pas supprimer le dossier `_internal\`, il est necessaire au fonctionnement.
--- ---
## Configuration par defaut ## Configuration par defaut
@@ -191,6 +173,9 @@ supervision\
| Seuil CPU | 90% | | Seuil CPU | 90% |
| Seuil RAM | 85% | | Seuil RAM | 85% |
| Seuil Disque | 90% | | Seuil Disque | 90% |
| Chemin logs Amadea | `C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs` |
| Statut Actif si | derniere action < 5 minutes |
| Statut Inactif si | derniere action < 30 minutes |
### Processus surveilles par defaut ### Processus surveilles par defaut
@@ -204,11 +189,44 @@ Tous les parametres sont modifiables depuis l'interface web.
--- ---
## Migration depuis la version Python ## Suivi des utilisateurs Amadea
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. L'onglet **Utilisateurs** affiche en temps reel les utilisateurs connectes a Amadea Web 8 x64.
Les autres parametres (seuils, SMTP, processus) sont conserves. ### Fichiers de logs lus
| Fichier | Role |
|---------|------|
| `awevents_JJ-MM-AA_N.log(.gz)` | Source principale : actions utilisateurs, deconnexions explicites |
| `awevents.log` | Log actif du jour (sans date dans le nom, serveur HDS) |
| `isoft_JJ-MM-AA_N.log(.gz)` | Complement : evenements de session |
La detection gere les deux cas du serveur HDS : log actif sans date dans le nom (`awevents.log`) et log zip en cours de journee avec date dans le nom (`awevents_26-04-13_1.log.gz`).
### Tableau temps reel (aujourd'hui)
- Colonnes : Utilisateur, Statut, Derniere action, Actions (24h), Depuis
- Tri : statut (actif → inactif → deconnecte), puis derniere action la plus recente en premier au sein de chaque groupe
### Graphique 7 derniers jours
- Affiche le pic d'utilisateurs simultanes par jour
- **Cliquer sur une barre** charge le tableau des utilisateurs de ce jour : Utilisateur, Derniere utilisation, Actions (jour), Duree de presence (premiere → derniere action)
- Tri par nombre d'actions decroissant
### Regles de statut
| Statut | Condition |
|--------|-----------|
| **ACTIF** | Derniere action < 5 min (configurable) |
| **INACTIF** | Derniere action entre 5 et 30 min (configurable) |
| **DECONNECTE** | Derniere action > 30 min ou deconnexion explicite detectee |
### Configuration du chemin des logs
Dans **Configuration > Chemin des logs Amadea**, renseignez le chemin complet du dossier contenant les fichiers de logs.
Valeur par defaut : `C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs`
--- ---
@@ -220,4 +238,6 @@ Les autres parametres (seuils, SMTP, processus) sont conserves.
| Impossible de se connecter a distance | Verifier la regle firewall (port 5000 TCP entrant) | | 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" | | 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) | | Mot de passe oublie | Supprimer `data\config.json` et relancer (reinitialise a admin/admin) |
| L'executable ne se lance pas | Verifier que les dossiers `templates\` et `static\` sont a cote de l'executable | | L'executable ne se lance pas | Verifier que le dossier `_internal\` est present a cote de `supervision.exe` |
| Onglet Utilisateurs vide ou erreur | Verifier le chemin des logs dans Configuration et s'assurer que le service Amadea tourne |
| Tous les utilisateurs affiches DECONNECTE | Normal si aucune activite recente — verifier que les fichiers `awevents_*.txt` du jour sont bien presents dans le dossier configure |

View File

@@ -1,8 +1,11 @@
"""Envoi d'alertes par email via SMTP.""" """Envoi d'alertes par email via Brevo API ou SMTP."""
import json
import smtplib import smtplib
from email.mime.text import MIMEText import urllib.error
import urllib.request
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
class EmailAlerter: class EmailAlerter:
@@ -14,28 +17,64 @@ class EmailAlerter:
def is_configured(self): def is_configured(self):
smtp = self._get_smtp_config() smtp = self._get_smtp_config()
return bool(smtp.get("server") and smtp.get("from_email") and smtp.get("to_emails")) has_recipients = bool(smtp.get("from_email") and smtp.get("to_emails"))
if smtp.get("brevo_api_key"):
return has_recipients
return has_recipients and bool(smtp.get("server"))
def send_alert(self, subject, body): def send_alert(self, subject, body):
"""Envoie un email d'alerte. Silencieux si SMTP non configure.""" """Envoie un email d'alerte. Silencieux si non configure."""
if not self.is_configured(): if not self.is_configured():
return False, "SMTP non configure" return False, "Email non configure"
return self._send_email(subject, body) return self._send_email(subject, body)
def send_test(self): def send_test(self):
"""Envoie un email de test pour valider la configuration SMTP.""" """Envoie un email de test pour valider la configuration."""
if not self.is_configured(): if not self.is_configured():
return False, "Configuration SMTP incomplete" return False, "Configuration email incomplete"
subject = "[TEST] Supervision - Test de configuration email" subject = "[TEST] Supervision - Test de configuration email"
body = ( body = (
"Ceci est un email de test.\n\n" "Ceci est un email de test.\n\n"
"Si vous recevez ce message, la configuration SMTP est correcte.\n\n" "Si vous recevez ce message, la configuration est correcte.\n\n"
"-- Supervision" "-- Supervision"
) )
return self._send_email(subject, body) return self._send_email(subject, body)
def _send_email(self, subject, body): def _send_email(self, subject, body):
smtp_cfg = self._get_smtp_config() smtp_cfg = self._get_smtp_config()
if smtp_cfg.get("brevo_api_key"):
return self._send_via_brevo(subject, body, smtp_cfg)
return self._send_via_smtp(subject, body, smtp_cfg)
def _send_via_brevo(self, subject, body, smtp_cfg):
"""Envoi via l'API REST Brevo."""
data = {
"sender": {"email": smtp_cfg["from_email"]},
"to": [{"email": e} for e in smtp_cfg["to_emails"]],
"subject": subject,
"textContent": body,
}
req = urllib.request.Request(
"https://api.brevo.com/v3/smtp/email",
data=json.dumps(data).encode("utf-8"),
headers={
"Content-Type": "application/json",
"api-key": smtp_cfg["brevo_api_key"],
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15):
return True, "Email envoye via Brevo"
except urllib.error.HTTPError as e:
return False, f"Erreur Brevo API: {e.code} {e.reason}"
except urllib.error.URLError as e:
return False, f"Impossible de joindre Brevo: {e.reason}"
except Exception as e:
return False, f"Erreur Brevo: {str(e)}"
def _send_via_smtp(self, subject, body, smtp_cfg):
"""Envoi via SMTP classique."""
try: try:
msg = MIMEMultipart() msg = MIMEMultipart()
msg["From"] = smtp_cfg["from_email"] msg["From"] = smtp_cfg["from_email"]

102
app.py
View File

@@ -20,6 +20,7 @@ from werkzeug.security import check_password_hash, generate_password_hash
from config_manager import ConfigManager from config_manager import ConfigManager
from monitor import SystemMonitor from monitor import SystemMonitor
from alerter import EmailAlerter from alerter import EmailAlerter
from user_monitor import UserMonitor
# --- Init --- # --- Init ---
config = ConfigManager() config = ConfigManager()
@@ -53,6 +54,7 @@ login_manager.login_message = "Veuillez vous connecter."
# Services # Services
alerter = EmailAlerter(config) alerter = EmailAlerter(config)
monitor = SystemMonitor(config, alerter) monitor = SystemMonitor(config, alerter)
user_monitor = UserMonitor(config)
class AdminUser(UserMixin): class AdminUser(UserMixin):
@@ -124,10 +126,8 @@ def settings():
smtp = cfg.get("smtp", {}) smtp = cfg.get("smtp", {})
# Masquer le mot de passe SMTP dans l'affichage # Masquer le mot de passe SMTP dans l'affichage
smtp_display = dict(smtp) smtp_display = dict(smtp)
if smtp_display.get("password"): smtp_display["password_masked"] = "*" * 8 if smtp_display.get("password") else ""
smtp_display["password_masked"] = "*" * 8 smtp_display["brevo_api_key_masked"] = "*" * 8 if smtp_display.get("brevo_api_key") else ""
else:
smtp_display["password_masked"] = ""
return render_template( return render_template(
"settings.html", "settings.html",
config=cfg, config=cfg,
@@ -181,6 +181,7 @@ def update_monitoring():
@login_required @login_required
def update_smtp(): def update_smtp():
try: try:
old_smtp = config.get("smtp", {})
smtp = { smtp = {
"server": request.form["smtp_server"].strip(), "server": request.form["smtp_server"].strip(),
"port": int(request.form["smtp_port"]), "port": int(request.form["smtp_port"]),
@@ -191,14 +192,12 @@ def update_smtp():
e.strip() for e in request.form["smtp_to"].split(",") if e.strip() e.strip() for e in request.form["smtp_to"].split(",") if e.strip()
], ],
} }
# Ne mettre a jour le mot de passe que s'il est fourni # Conserver le mot de passe si non fourni
new_password = request.form.get("smtp_password", "") new_password = request.form.get("smtp_password", "")
if new_password: smtp["password"] = new_password if new_password else old_smtp.get("password", "")
smtp["password"] = new_password # Conserver la cle Brevo si non fournie
else: new_brevo_key = request.form.get("brevo_api_key", "").strip()
# Garder l'ancien mot de passe smtp["brevo_api_key"] = new_brevo_key if new_brevo_key else old_smtp.get("brevo_api_key", "")
old_smtp = config.get("smtp", {})
smtp["password"] = old_smtp.get("password", "")
config.set("smtp", smtp) config.set("smtp", smtp)
flash("Configuration SMTP mise a jour.", "success") flash("Configuration SMTP mise a jour.", "success")
@@ -312,6 +311,84 @@ def toggle_monitoring():
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
@app.route("/users")
@login_required
def users():
return render_template("users.html")
@app.route("/api/users")
@login_required
def api_users():
cache = user_monitor.data
if cache.get("error"):
return jsonify({"error": cache["error"]})
if cache.get("no_files"):
return jsonify({"no_files": True})
users_list = [
{
"login": u["login"],
"status": u["status"],
"last_action_time": u["last_action_time"].strftime("%H:%M:%S") if u.get("last_action_time") else None,
"last_action_label": u.get("last_action_label", ""),
"action_count_24h": u.get("action_count_24h", 0),
"connected_since": u["connected_since"].strftime("%H:%M") if u.get("connected_since") else None,
"explicit_logout": u.get("explicit_logout", False),
}
for u in cache.get("users", {}).values()
]
return jsonify({"users": users_list, "hourly": cache.get("hourly", [])})
@app.route("/api/users/activity/weekly")
@login_required
def api_users_weekly():
return jsonify({"weekly": user_monitor.get_weekly_activity()})
@app.route("/api/users/day/<date_str>")
@login_required
def api_users_day(date_str):
from datetime import datetime as dt
try:
date = dt.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
return jsonify({"error": "Date invalide"}), 400
users = user_monitor.get_users_for_date(date)
return jsonify({"users": users, "date": date_str})
@app.route("/settings/amadea-log-path", methods=["POST"])
@login_required
def update_amadea_log_path():
path = request.form.get("amadea_log_path", "").strip()
if not path:
flash("Le chemin ne peut pas etre vide.", "danger")
return redirect(url_for("settings"))
config.set("amadea_log_path", path)
flash("Chemin des logs Amadea mis a jour.", "success")
return redirect(url_for("settings"))
@app.route("/settings/user-thresholds", methods=["POST"])
@login_required
def update_user_thresholds():
try:
active = int(request.form["active_minutes"])
inactive = int(request.form["inactive_minutes"])
if active < 1 or inactive < 1:
flash("Les seuils doivent etre d'au moins 1 minute.", "danger")
return redirect(url_for("settings"))
if active >= inactive:
flash("Le seuil 'actif' doit etre inferieur au seuil 'inactif'.", "danger")
return redirect(url_for("settings"))
config.set("user_status_thresholds", {"active_minutes": active, "inactive_minutes": inactive})
flash("Seuils utilisateurs mis a jour.", "success")
except (ValueError, KeyError) as e:
flash(f"Erreur: {e}", "danger")
return redirect(url_for("settings"))
def check_port_available(port): def check_port_available(port):
"""Verifie si un port est disponible.""" """Verifie si un port est disponible."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
@@ -341,6 +418,9 @@ def main():
# Collecte initiale # Collecte initiale
monitor.collect_metrics() monitor.collect_metrics()
user_monitor.start()
user_monitor.parse_logs()
print("[Supervision] Monitoring actif") print("[Supervision] Monitoring actif")
app.run(host="0.0.0.0", port=port, debug=False) app.run(host="0.0.0.0", port=port, debug=False)

View File

@@ -54,6 +54,12 @@ def get_default_config():
"password": "", "password": "",
"from_email": "", "from_email": "",
"to_emails": [], "to_emails": [],
"brevo_api_key": "",
},
"amadea_log_path": r"C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs",
"user_status_thresholds": {
"active_minutes": 5,
"inactive_minutes": 30,
}, },
"admin": { "admin": {
"username": "admin", "username": "admin",

View File

@@ -4,3 +4,4 @@ flask-limiter==3.9.*
psutil==6.1.* psutil==6.1.*
werkzeug==3.1.* werkzeug==3.1.*
pyinstaller==6.12.* pyinstaller==6.12.*
pytest==8.3.*

View File

@@ -1,86 +0,0 @@
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,
)
}

View File

@@ -1,238 +0,0 @@
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();
}

View File

@@ -1,922 +0,0 @@
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();
}

View File

@@ -1,344 +0,0 @@
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" %} {% extends "base.html" %}
{% block title %}Supervision - Alertes{% endblock title %} {% block title %}Supervision - Alertes{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-3"> <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> <h4 class="mb-0"><i class="bi bi-bell"></i> Historique des alertes</h4>
{% if alerts %} {% if alerts %}
<form method="POST" action="/alerts/clear"> <form method="POST" action="{{ url_for('clear_alerts') }}">
<button type="submit" class="btn btn-sm btn-outline-danger" <button type="submit" class="btn btn-sm btn-outline-danger"
onclick="return confirm('Effacer tout l\'historique ?')"> onclick="return confirm('Effacer tout l\'historique ?')">
<i class="bi bi-trash"></i> Effacer l'historique <i class="bi bi-trash"></i> Effacer l'historique
@@ -35,10 +35,10 @@
{% for alert in alerts %} {% for alert in alerts %}
<tr> <tr>
<td class="text-nowrap"> <td class="text-nowrap">
<small>{{ alert.timestamp | truncate(length=19, end="") | replace(from="T", to=" ") }}</small> <small>{{ alert.timestamp[:19] | replace('T', ' ') }}</small>
</td> </td>
<td> <td>
{% if alert.type == "process_down" %} {% if alert.type == 'process_down' %}
<span class="badge bg-danger">Processus</span> <span class="badge bg-danger">Processus</span>
{% else %} {% else %}
<span class="badge bg-warning text-dark">Seuil</span> <span class="badge bg-warning text-dark">Seuil</span>
@@ -57,4 +57,4 @@
{{ alerts | length }} alerte(s) — les 500 dernieres sont conservees. {{ alerts | length }} alerte(s) — les 500 dernieres sont conservees.
</div> </div>
{% endif %} {% endif %}
{% endblock content %} {% endblock %}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Supervision - Configuration{% endblock title %} {% block title %}Supervision - Configuration{% endblock %}
{% block content %} {% block content %}
<h4 class="mb-3"><i class="bi bi-gear"></i> Configuration</h4> <h4 class="mb-3"><i class="bi bi-gear"></i> Configuration</h4>
@@ -10,7 +10,7 @@
<div class="card"> <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-header"><h6 class="mb-0"><i class="bi bi-sliders"></i> Seuils d'alerte (%)</h6></div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="/settings/thresholds"> <form method="POST" action="{{ url_for('update_thresholds') }}">
<div class="mb-3"> <div class="mb-3">
<label for="cpu_percent" class="form-label">CPU (%)</label> <label for="cpu_percent" class="form-label">CPU (%)</label>
<input type="number" class="form-control" id="cpu_percent" name="cpu_percent" <input type="number" class="form-control" id="cpu_percent" name="cpu_percent"
@@ -39,7 +39,7 @@
<div class="card"> <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-header"><h6 class="mb-0"><i class="bi bi-clock"></i> Frequence et alertes</h6></div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="/settings/monitoring"> <form method="POST" action="{{ url_for('update_monitoring') }}">
<div class="mb-3"> <div class="mb-3">
<label for="check_interval_minutes" class="form-label">Intervalle de verification (minutes)</label> <label for="check_interval_minutes" class="form-label">Intervalle de verification (minutes)</label>
<input type="number" class="form-control" id="check_interval_minutes" <input type="number" class="form-control" id="check_interval_minutes"
@@ -64,7 +64,7 @@
<div class="card mt-4"> <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-header"><h6 class="mb-0"><i class="bi bi-diagram-3"></i> Port de l'application</h6></div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="/settings/port"> <form method="POST" action="{{ url_for('update_port') }}">
<div class="mb-3"> <div class="mb-3">
<label for="port" class="form-label">Port (redemarrage requis)</label> <label for="port" class="form-label">Port (redemarrage requis)</label>
<input type="number" class="form-control" id="port" name="port" <input type="number" class="form-control" id="port" name="port"
@@ -85,7 +85,7 @@
<div class="card"> <div class="card">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-envelope"></i> Configuration SMTP</h6></div> <div class="card-header"><h6 class="mb-0"><i class="bi bi-envelope"></i> Configuration SMTP</h6></div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="/settings/smtp"> <form method="POST" action="{{ url_for('update_smtp') }}">
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label for="smtp_server" class="form-label">Serveur SMTP</label> <label for="smtp_server" class="form-label">Serveur SMTP</label>
@@ -128,10 +128,21 @@
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label for="smtp_to" class="form-label">Destinataires (separes par des virgules)</label> <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" <input type="text" class="form-control" id="smtp_to" name="smtp_to"
value="{{ smtp.to_emails | join(sep=", ") }}" value="{{ smtp.to_emails | join(', ') }}"
placeholder="admin@example.com, tech@example.com"> placeholder="admin@example.com, tech@example.com">
</div> </div>
</div> </div>
<div class="row">
<div class="col-12 mb-3">
<label for="brevo_api_key" class="form-label">
<i class="bi bi-key"></i> Cle API Brevo
</label>
<input type="password" class="form-control" id="brevo_api_key" name="brevo_api_key"
placeholder="{% if smtp.brevo_api_key_masked %}{{ smtp.brevo_api_key_masked }}{% else %}Non definie{% endif %}"
autocomplete="new-password">
<div class="form-text">Si renseignee, la cle API Brevo est utilisee a la place du SMTP. Laissez vide pour conserver la cle actuelle.</div>
</div>
</div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Enregistrer <i class="bi bi-check-lg"></i> Enregistrer
@@ -139,7 +150,7 @@
</div> </div>
</form> </form>
<hr> <hr>
<form method="POST" action="/settings/smtp/test" class="d-inline"> <form method="POST" action="{{ url_for('test_smtp') }}" class="d-inline">
<button type="submit" class="btn btn-outline-info"> <button type="submit" class="btn btn-outline-info">
<i class="bi bi-send"></i> Envoyer un email de test <i class="bi bi-send"></i> Envoyer un email de test
</button> </button>
@@ -157,7 +168,7 @@
<h6 class="mb-0"><i class="bi bi-list-task"></i> Processus surveilles</h6> <h6 class="mb-0"><i class="bi bi-list-task"></i> Processus surveilles</h6>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="/settings/processes" id="process-form"> <form method="POST" action="{{ url_for('update_processes') }}" id="process-form">
<table class="table" id="proc-table"> <table class="table" id="proc-table">
<thead> <thead>
<tr> <tr>
@@ -231,7 +242,7 @@
<h6 class="mb-0"><i class="bi bi-shield-lock"></i> Mot de passe administrateur</h6> <h6 class="mb-0"><i class="bi bi-shield-lock"></i> Mot de passe administrateur</h6>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="/settings/password"> <form method="POST" action="{{ url_for('update_password') }}">
<div class="mb-3"> <div class="mb-3">
<label for="current_password" class="form-label">Mot de passe actuel</label> <label for="current_password" class="form-label">Mot de passe actuel</label>
<input type="password" class="form-control" id="current_password" <input type="password" class="form-control" id="current_password"
@@ -257,7 +268,63 @@
</div> </div>
</div> </div>
{% endblock content %} <!-- Chemin des logs Amadea + Seuils statut utilisateurs -->
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-folder2-open"></i> Chemin des logs Amadea</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('update_amadea_log_path') }}">
<div class="mb-3">
<label for="amadea_log_path" class="form-label">Dossier des logs</label>
<input type="text" class="form-control" id="amadea_log_path"
name="amadea_log_path"
value="{{ config.amadea_log_path }}"
placeholder="C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs">
<div class="form-text">Dossier contenant les fichiers isoft_*.txt et awevents_*.txt.</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Enregistrer
</button>
</form>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-people"></i> Seuils statut utilisateurs</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('update_user_thresholds') }}">
<div class="mb-3">
<label for="active_minutes" class="form-label">Actif si derniere action &lt; (minutes)</label>
<input type="number" class="form-control" id="active_minutes"
name="active_minutes"
value="{{ config.user_status_thresholds.active_minutes }}"
min="1" required>
</div>
<div class="mb-3">
<label for="inactive_minutes" class="form-label">Inactif si derniere action &lt; (minutes)</label>
<input type="number" class="form-control" id="inactive_minutes"
name="inactive_minutes"
value="{{ config.user_status_thresholds.inactive_minutes }}"
min="1" required>
<div class="form-text">Au-dela du seuil inactif, le statut passe a Deconnecte.</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Enregistrer
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
@@ -285,4 +352,4 @@ document.addEventListener('click', function(e) {
} }
}); });
</script> </script>
{% endblock scripts %} {% endblock %}

398
templates/users.html Normal file
View File

@@ -0,0 +1,398 @@
{% extends "base.html" %}
{% block title %}Supervision - Utilisateurs{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0"><i class="bi bi-people"></i> Utilisateurs Amadea</h4>
<span id="last-update" class="text-muted small"></span>
</div>
<div id="alert-zone"></div>
<!-- Graphique d'activite horaire -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-bar-chart"></i> Utilisateurs actifs par heure</h6>
<select id="period-select" class="form-select form-select-sm" style="width: auto;">
<option value="today">Aujourd'hui</option>
<option value="7days">7 derniers jours</option>
</select>
</div>
<div class="card-body">
<div style="display: flex; gap: 6px;">
<div id="chart-yaxis"
style="display: flex; flex-direction: column; justify-content: space-between;
align-items: flex-end; width: 20px; height: 100px; flex-shrink: 0;">
</div>
<div style="flex: 1; min-width: 0;">
<div id="chart-container"
style="height: 100px; display: flex; align-items: flex-end; gap: 3px;">
</div>
<div id="chart-labels" style="display: flex; gap: 3px; margin-top: 4px;"></div>
</div>
</div>
<div id="chart-hint" class="d-none mt-2">
<small class="text-muted"><i class="bi bi-hand-index"></i> Cliquez sur une barre pour voir les utilisateurs de ce jour.</small>
</div>
<div id="chart-unavailable" class="d-none">
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle"></i>
Les donnees historiques des jours precedents ne sont pas disponibles (fichiers archives).
</div>
</div>
</div>
</div>
<!-- Tableau utilisateurs -->
<div class="card">
<div class="card-header">
<h6 class="mb-0" id="table-title"><i class="bi bi-person-lines-fill"></i> Utilisateurs connectes aujourd'hui</h6>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead>
<tr id="table-headers">
<th>Utilisateur</th>
<th>Statut</th>
<th>Derniere action</th>
<th>Actions (24h)</th>
<th>Depuis</th>
</tr>
</thead>
<tbody id="users-tbody"></tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var STATUS_CONFIG = {
actif: { badge: 'bg-success', label: 'ACTIF' },
inactif: { badge: 'bg-warning text-dark', label: 'INACTIF' },
deconnecte: { badge: 'bg-secondary', label: 'DECONNECTE' },
};
var currentHourly = [];
var currentPeriod = 'today';
var selectedBarEl = null;
var lastTodayUsers = [];
/* --- Graphique CSS pur --- */
function renderYAxis(max) {
var yaxis = document.getElementById('chart-yaxis');
yaxis.textContent = '';
var values = [max, Math.round(max / 2), 0];
values.forEach(function(v) {
var lbl = document.createElement('div');
lbl.style.fontSize = '0.6rem';
lbl.style.color = '#6c757d';
lbl.style.lineHeight = '1';
lbl.textContent = v;
yaxis.appendChild(lbl);
});
}
function renderChart(data, options) {
var container = document.getElementById('chart-container');
var labelsEl = document.getElementById('chart-labels');
var unavail = document.getElementById('chart-unavailable');
container.style.display = 'flex';
labelsEl.style.display = 'flex';
unavail.classList.add('d-none');
container.textContent = '';
labelsEl.textContent = '';
selectedBarEl = null;
if (!data || data.length === 0) {
renderYAxis(0);
var msg = document.createElement('span');
msg.className = 'text-muted small';
msg.textContent = 'Aucune donnee disponible.';
container.appendChild(msg);
return;
}
var max = 1;
data.forEach(function(d) { if ((d.count || 0) > max) max = d.count; });
renderYAxis(max);
data.forEach(function(item) {
var count = item.count || 0;
var heightPct = count > 0 ? Math.max((count / max) * 100, 6) : 0;
var bar = document.createElement('div');
bar.style.flex = '1';
bar.style.minWidth = '14px';
bar.style.height = heightPct + '%';
bar.style.background = '#0d6efd';
bar.style.borderRadius = '3px 3px 0 0';
bar.style.opacity = count > 0 ? '1' : '0.15';
bar.style.transition = 'height 0.3s';
bar.title = item.hour !== undefined
? item.hour + 'h : ' + count + ' utilisateur(s)'
: item.date + ' : ' + count + ' utilisateur(s)';
if (options && options.onBarClick && item.date !== undefined) {
bar.style.cursor = 'pointer';
(function(capturedItem, capturedBar) {
capturedBar.addEventListener('click', function() {
if (selectedBarEl) {
selectedBarEl.style.background = '#0d6efd';
selectedBarEl.style.outline = '';
}
capturedBar.style.background = '#0a58ca';
capturedBar.style.outline = '2px solid #0a3fa8';
selectedBarEl = capturedBar;
options.onBarClick(capturedItem);
});
})(item, bar);
}
container.appendChild(bar);
var lbl = document.createElement('div');
lbl.style.flex = '1';
lbl.style.minWidth = '14px';
lbl.style.textAlign = 'center';
lbl.style.fontSize = '0.6rem';
lbl.style.color = '#6c757d';
lbl.style.overflow = 'hidden';
if (item.hour !== undefined) {
lbl.textContent = item.hour % 3 === 0 ? item.hour + 'h' : '';
} else {
var d = new Date(item.date);
lbl.textContent = d.getUTCDate() + '/' + (d.getUTCMonth() + 1);
}
labelsEl.appendChild(lbl);
});
}
function renderWeekly(weekly) {
var unavail = document.getElementById('chart-unavailable');
var container = document.getElementById('chart-container');
var labelsEl = document.getElementById('chart-labels');
var hint = document.getElementById('chart-hint');
var allNull = weekly.every(function(d) { return d.count === null; });
if (allNull) {
container.style.display = 'none';
labelsEl.style.display = 'none';
hint.classList.add('d-none');
document.getElementById('chart-yaxis').textContent = '';
unavail.classList.remove('d-none');
return;
}
hint.classList.remove('d-none');
renderChart(
weekly.map(function(d) {
return { date: d.date, count: d.count === null ? 0 : d.count };
}),
{
onBarClick: function(item) {
loadDayUsers(item.date);
}
}
);
}
/* --- Tableau --- */
function setTableMode(mode, dateLabel) {
var title = document.getElementById('table-title');
var thead = document.getElementById('table-headers');
if (mode === 'today') {
title.innerHTML = '<i class="bi bi-person-lines-fill"></i> Utilisateurs connectes aujourd\'hui';
thead.innerHTML = '<th>Utilisateur</th><th>Statut</th><th>Derniere action</th><th>Actions (24h)</th><th>Depuis</th>';
} else {
title.innerHTML = '<i class="bi bi-person-lines-fill"></i> Utilisateurs &mdash; ' + (dateLabel || '');
thead.innerHTML = '<th>Utilisateur</th><th>Derniere utilisation</th><th>Actions (jour)</th><th>Duree de presence</th>';
}
}
function renderTable(users) {
var tbody = document.getElementById('users-tbody');
tbody.textContent = '';
if (!users || users.length === 0) {
var tr = document.createElement('tr');
var td = document.createElement('td');
td.colSpan = 5;
td.className = 'text-center text-muted py-3';
td.textContent = "Aucun utilisateur detecte aujourd'hui.";
tr.appendChild(td);
tbody.appendChild(tr);
return;
}
users.forEach(function(u) {
var sc = STATUS_CONFIG[u.status] || { badge: 'bg-secondary', label: (u.status || '').toUpperCase() };
var tr = document.createElement('tr');
var tdUser = document.createElement('td');
var strong = document.createElement('strong');
strong.textContent = u.login || '';
tdUser.appendChild(strong);
tr.appendChild(tdUser);
var tdStatus = document.createElement('td');
var badge = document.createElement('span');
badge.className = 'badge ' + sc.badge;
badge.textContent = sc.label;
tdStatus.appendChild(badge);
tr.appendChild(tdStatus);
var tdAction = document.createElement('td');
tdAction.textContent = u.last_action_time || '\u2014';
if (u.last_action_label) {
var small = document.createElement('small');
small.className = 'text-muted d-block';
small.textContent = u.last_action_label;
tdAction.appendChild(small);
}
tr.appendChild(tdAction);
var tdCount = document.createElement('td');
tdCount.textContent = String(u.action_count_24h || 0);
tr.appendChild(tdCount);
var tdSince = document.createElement('td');
tdSince.textContent = u.connected_since || '\u2014';
tr.appendChild(tdSince);
tbody.appendChild(tr);
});
}
function renderDayTable(users, dateStr) {
setTableMode('day', dateStr);
var tbody = document.getElementById('users-tbody');
tbody.textContent = '';
if (!users || users.length === 0) {
var tr = document.createElement('tr');
var td = document.createElement('td');
td.colSpan = 4;
td.className = 'text-center text-muted py-3';
td.textContent = 'Aucun utilisateur detecte ce jour.';
tr.appendChild(td);
tbody.appendChild(tr);
return;
}
users.forEach(function(u) {
var tr = document.createElement('tr');
var tdUser = document.createElement('td');
var strong = document.createElement('strong');
strong.textContent = u.login || '';
tdUser.appendChild(strong);
tr.appendChild(tdUser);
var tdAction = document.createElement('td');
tdAction.textContent = u.last_action_time || '\u2014';
if (u.last_action_label) {
var small = document.createElement('small');
small.className = 'text-muted d-block';
small.textContent = u.last_action_label;
tdAction.appendChild(small);
}
tr.appendChild(tdAction);
var tdCount = document.createElement('td');
tdCount.textContent = String(u.action_count || 0);
tr.appendChild(tdCount);
var tdDuration = document.createElement('td');
tdDuration.textContent = u.duration || '\u2014';
tr.appendChild(tdDuration);
tbody.appendChild(tr);
});
}
function loadDayUsers(date) {
fetch('/api/users/day/' + date)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) { showAlert('warning', data.error); return; }
clearAlert();
renderDayTable(data.users || [], date);
})
.catch(function() {});
}
/* --- Alertes --- */
function showAlert(type, message) {
var zone = document.getElementById('alert-zone');
zone.textContent = '';
var div = document.createElement('div');
div.className = 'alert alert-' + type;
var icon = document.createElement('i');
icon.className = 'bi bi-exclamation-triangle me-1';
div.appendChild(icon);
div.appendChild(document.createTextNode(message));
zone.appendChild(div);
}
function clearAlert() {
document.getElementById('alert-zone').textContent = '';
}
/* --- Refresh --- */
function refreshUsers() {
fetch('/api/users')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
showAlert('warning', data.error);
if (currentPeriod === 'today') { renderTable([]); renderChart([]); }
return;
}
if (data.no_files) {
showAlert('info', "Aucun log disponible pour aujourd'hui.");
if (currentPeriod === 'today') { renderTable([]); renderChart([]); }
return;
}
clearAlert();
lastTodayUsers = data.users || [];
currentHourly = data.hourly || [];
if (currentPeriod === 'today') {
setTableMode('today');
renderTable(lastTodayUsers);
renderChart(currentHourly);
}
document.getElementById('last-update').textContent =
'Mis a jour : ' + new Date().toLocaleTimeString('fr-FR');
})
.catch(function() {});
}
document.getElementById('period-select').addEventListener('change', function() {
currentPeriod = this.value;
var hint = document.getElementById('chart-hint');
if (currentPeriod === 'today') {
hint.classList.add('d-none');
document.getElementById('chart-container').style.display = 'flex';
document.getElementById('chart-labels').style.display = 'flex';
document.getElementById('chart-unavailable').classList.add('d-none');
setTableMode('today');
renderTable(lastTodayUsers);
renderChart(currentHourly);
} else {
fetch('/api/users/activity/weekly')
.then(function(r) { return r.json(); })
.then(function(data) { renderWeekly(data.weekly || []); })
.catch(function() {});
}
});
refreshUsers();
setInterval(refreshUsers, 30000);
</script>
{% endblock %}

0
tests/__init__.py Normal file
View File

110
tests/test_user_monitor.py Normal file
View File

@@ -0,0 +1,110 @@
"""Tests unitaires pour user_monitor.py"""
from datetime import datetime, timedelta
import pytest
from user_monitor import UserMonitor
class FakeConfig:
def get(self, key, default=None):
return {
"amadea_log_path": "/nonexistent",
"user_status_thresholds": {"active_minutes": 5, "inactive_minutes": 30},
"check_interval_minutes": 1,
}.get(key, default)
def make_monitor():
return UserMonitor(FakeConfig())
# --- Parsing awevents ---
def test_parse_awevents_line_basic():
monitor = make_monitor()
users, hourly = {}, {h: set() for h in range(24)}
cutoff = datetime(2026, 3, 30, 0, 0, 0)
line = '2026-03-30 10:34:24.034;INFO ;;;;"login=JENKINS,action=SelectionChange,Label=BAO_Main/MenuPrincipal"\n'
monitor._parse_awevents_line(line, users, cutoff, hourly)
assert "JENKINS" in users
assert users["JENKINS"]["last_action_time"] == datetime(2026, 3, 30, 10, 34, 24)
assert users["JENKINS"]["action_count_24h"] == 1
assert users["JENKINS"]["explicit_logout"] is False
assert hourly[10] == {"JENKINS"}
def test_parse_awevents_line_explicit_logout():
monitor = make_monitor()
users, hourly = {}, {h: set() for h in range(24)}
cutoff = datetime(2026, 3, 30, 0, 0, 0)
line = '2026-03-30 11:34:00.500;INFO ;;;;"login=MB,action=Action,Label=Main/se deconnecter"\n'
monitor._parse_awevents_line(line, users, cutoff, hourly)
assert users["MB"]["explicit_logout"] is True
assert users["MB"]["logout_time"] == datetime(2026, 3, 30, 11, 34, 0)
def test_parse_awevents_line_reconnect_after_logout():
monitor = make_monitor()
users, hourly = {}, {h: set() for h in range(24)}
cutoff = datetime(2026, 3, 30, 0, 0, 0)
logout_line = '2026-03-30 11:34:00.500;INFO ;;;;"login=MB,action=Action,Label=Main/se deconnecter"\n'
reconnect_line = '2026-03-30 11:34:19.594;INFO ;;;;"login=MB,action=Action,Label=Main/OuvrirCTRL 1/Table"\n'
monitor._parse_awevents_line(logout_line, users, cutoff, hourly)
assert users["MB"]["explicit_logout"] is True
monitor._parse_awevents_line(reconnect_line, users, cutoff, hourly)
assert users["MB"]["explicit_logout"] is False
def test_parse_awevents_line_invalid_ignored():
monitor = make_monitor()
users, hourly = {}, {h: set() for h in range(24)}
cutoff = datetime(2026, 3, 30, 0, 0, 0)
monitor._parse_awevents_line("ligne invalide sans format attendu\n", users, cutoff, hourly)
assert users == {}
def test_parse_awevents_action_count_outside_24h():
monitor = make_monitor()
users, hourly = {}, {h: set() for h in range(24)}
cutoff = datetime(2026, 3, 30, 12, 0, 0)
old_line = '2026-03-30 08:00:00.000;INFO ;;;;"login=JENKINS,action=Click,Label=Main/Page"\n'
monitor._parse_awevents_line(old_line, users, cutoff, hourly)
assert users["JENKINS"]["action_count_24h"] == 0
# --- Calcul de statut ---
def test_compute_statuses_actif():
monitor = make_monitor()
now = datetime(2026, 3, 30, 12, 0, 0)
thresholds = {"active_minutes": 5, "inactive_minutes": 30}
users = {"JENKINS": {"last_action_time": now - timedelta(minutes=2), "explicit_logout": False}}
monitor._compute_statuses(users, thresholds, now)
assert users["JENKINS"]["status"] == "actif"
def test_compute_statuses_inactif():
monitor = make_monitor()
now = datetime(2026, 3, 30, 12, 0, 0)
thresholds = {"active_minutes": 5, "inactive_minutes": 30}
users = {"MB": {"last_action_time": now - timedelta(minutes=15), "explicit_logout": False}}
monitor._compute_statuses(users, thresholds, now)
assert users["MB"]["status"] == "inactif"
def test_compute_statuses_deconnecte_timeout():
monitor = make_monitor()
now = datetime(2026, 3, 30, 12, 0, 0)
thresholds = {"active_minutes": 5, "inactive_minutes": 30}
users = {"KO": {"last_action_time": now - timedelta(minutes=45), "explicit_logout": False}}
monitor._compute_statuses(users, thresholds, now)
assert users["KO"]["status"] == "deconnecte"
def test_compute_statuses_deconnecte_explicit():
monitor = make_monitor()
now = datetime(2026, 3, 30, 12, 0, 0)
thresholds = {"active_minutes": 5, "inactive_minutes": 30}
users = {"MB": {"last_action_time": now - timedelta(minutes=2), "explicit_logout": True}}
monitor._compute_statuses(users, thresholds, now)
assert users["MB"]["status"] == "deconnecte"

313
user_monitor.py Normal file
View File

@@ -0,0 +1,313 @@
"""Suivi des utilisateurs connectes a Amadea via parsing des logs."""
import gzip
import glob
import os
import re
import threading
import time
from datetime import datetime, timedelta
# Regex compilees au niveau module (performance)
_AWEVENTS_RE = re.compile(
r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\.\d+;[^;]*;;;;"login=([^,]+),action=([^,]+),Label=(.+?)"?\s*$'
)
_ISOFT_LOGIN_RE = re.compile(
r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*method=OpenUserSession.*login=([A-Za-z0-9_]+)'
)
def _read_log_file(filepath):
"""Lit un fichier log, supporte .log et .log.gz."""
try:
if filepath.endswith('.gz'):
with gzip.open(filepath, 'rt', encoding='utf-8', errors='ignore') as f:
return f.read()
else:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
return f.read()
except (PermissionError, OSError):
return None
_DATE_IN_NAME_RE = re.compile(r'_\d{2}-\d{2}-\d{2}[_.]')
def _log_files_for_date(log_path, prefix, date_str):
"""Retourne les fichiers de logs pour un prefixe et une date donnes, tries par index.
Cherche les fichiers avec la date dans le nom (ex: awevents_26-04-13_1.log.gz).
Pour le jour courant uniquement, inclut aussi les fichiers sans date dans le nom
(ex: awevents.log, isoft.log) qui sont les logs actifs du jour.
"""
def is_valid(f):
return f.endswith('.log') or f.endswith('.log.gz')
def sort_key(f):
m = re.search(r'_(\d+)\.log(\.gz)?$', f)
return int(m.group(1)) if m else 0
# Fichiers avec la date dans le nom
pattern = os.path.join(log_path, f"{prefix}_{date_str}_*")
files = [f for f in glob.glob(pattern) if is_valid(f)]
# Pour le jour courant uniquement : inclure aussi les fichiers sans date dans le nom
today_str = datetime.now().strftime("%y-%m-%d")
if date_str == today_str:
active_pattern = os.path.join(log_path, f"{prefix}*")
for f in glob.glob(active_pattern):
fname = os.path.basename(f)
if is_valid(f) and not _DATE_IN_NAME_RE.search(fname) and f not in files:
files.append(f)
return sorted(files, key=sort_key)
class UserMonitor:
def __init__(self, config_manager):
self.config = config_manager
self._cache = {"users": {}, "hourly": [], "error": None, "no_files": False}
self._lock = threading.Lock()
self._running = False
self._thread = None
@property
def data(self):
with self._lock:
return dict(self._cache)
def start(self):
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
def stop(self):
self._running = False
def _loop(self):
last_parse = 0
while self._running:
interval = self.config.get("check_interval_minutes", 1) * 60
if time.time() - last_parse >= interval:
try:
self.parse_logs()
except Exception as e:
print(f"[UserMonitor] Erreur: {e}")
last_parse = time.time()
time.sleep(5)
def parse_logs(self):
log_path = self.config.get(
"amadea_log_path",
r"C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs"
)
thresholds = self.config.get(
"user_status_thresholds",
{"active_minutes": 5, "inactive_minutes": 30}
)
if not os.path.isdir(log_path):
with self._lock:
self._cache = {
"error": f"Dossier de logs introuvable : {log_path}",
"users": {}, "hourly": [], "no_files": False,
}
return
date_str = datetime.now().strftime("%y-%m-%d")
awevents_files = _log_files_for_date(log_path, "awevents", date_str)
if not awevents_files:
with self._lock:
self._cache = {"no_files": True, "error": None, "users": {}, "hourly": []}
return
now = datetime.now()
cutoff_24h = now - timedelta(hours=24)
users = {}
hourly = {h: set() for h in range(24)}
for filepath in awevents_files:
content = _read_log_file(filepath)
if content:
for line in content.splitlines():
self._parse_awevents_line(line, users, cutoff_24h, hourly)
isoft_files = _log_files_for_date(log_path, "isoft", date_str)
for filepath in isoft_files:
content = _read_log_file(filepath)
if content:
for line in content.splitlines():
self._parse_isoft_line(line, users)
self._compute_statuses(users, thresholds, now)
status_order = {"actif": 0, "inactif": 1, "deconnecte": 2}
sorted_users = dict(
sorted(users.items(), key=lambda x: (
status_order.get(x[1]["status"], 3),
-(x[1]["last_action_time"].timestamp() if x[1].get("last_action_time") else 0),
))
)
hourly_data = [{"hour": h, "count": len(logins)} for h, logins in sorted(hourly.items())]
with self._lock:
self._cache = {
"error": None,
"no_files": False,
"users": sorted_users,
"hourly": hourly_data,
}
def _parse_awevents_line(self, line, users, cutoff_24h, hourly):
m = _AWEVENTS_RE.match(line)
if not m:
return
ts_str, login, action, label = m.group(1), m.group(2), m.group(3), m.group(4)
try:
ts = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
except ValueError:
return
login = login.strip()
if not login:
return
is_logout = "se deconnecter" in label.lower()
if login not in users:
users[login] = {
"login": login,
"last_action_time": ts,
"last_action_label": label[:60],
"action_count_24h": 0,
"status": "deconnecte",
"explicit_logout": is_logout,
"logout_time": ts if is_logout else None,
"connected_since": ts,
}
else:
user = users[login]
if ts > user["last_action_time"]:
user["last_action_time"] = ts
user["last_action_label"] = label[:60]
if is_logout:
user["explicit_logout"] = True
user["logout_time"] = ts
elif user["explicit_logout"] and user.get("logout_time") and ts > user["logout_time"]:
# Activite apres deconnexion explicite = reconnexion
user["explicit_logout"] = False
user["logout_time"] = None
if ts >= cutoff_24h:
users[login]["action_count_24h"] += 1
hourly[ts.hour].add(login)
def _parse_isoft_line(self, line, users):
m = _ISOFT_LOGIN_RE.match(line)
if not m:
return
ts_str, login = m.group(1), m.group(2)
try:
ts = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
except ValueError:
return
if login in users and users[login]["connected_since"] is None:
users[login]["connected_since"] = ts
def _compute_statuses(self, users, thresholds, now):
active_min = thresholds.get("active_minutes", 5)
inactive_min = thresholds.get("inactive_minutes", 30)
for user in users.values():
delta = (now - user["last_action_time"]).total_seconds() / 60
if user.get("explicit_logout"):
user["status"] = "deconnecte"
elif delta > inactive_min:
user["status"] = "deconnecte"
elif delta > active_min:
user["status"] = "inactif"
else:
user["status"] = "actif"
def get_users_for_date(self, date):
"""Retourne la liste des utilisateurs ayant agi a une date donnee, tries par nb d'actions."""
log_path = self.config.get(
"amadea_log_path",
r"C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs"
)
if not os.path.isdir(log_path):
return []
date_str = date.strftime("%y-%m-%d")
cutoff = datetime(date.year, date.month, date.day)
files = _log_files_for_date(log_path, "awevents", date_str)
if not files:
return []
users = {}
hourly = {h: set() for h in range(24)}
for filepath in files:
content = _read_log_file(filepath)
if content:
for line in content.splitlines():
self._parse_awevents_line(line, users, cutoff, hourly)
result = sorted(users.values(), key=lambda u: -u.get("action_count_24h", 0))
output = []
for u in result:
duration = None
if u.get("connected_since") and u.get("last_action_time"):
mins = int((u["last_action_time"] - u["connected_since"]).total_seconds() / 60)
if mins >= 60:
duration = f"{mins // 60}h{mins % 60:02d}"
else:
duration = f"{mins}min"
output.append({
"login": u["login"],
"last_action_time": u["last_action_time"].strftime("%H:%M:%S") if u.get("last_action_time") else None,
"last_action_label": u.get("last_action_label", ""),
"action_count": u.get("action_count_24h", 0),
"first_action_time": u["connected_since"].strftime("%H:%M") if u.get("connected_since") else None,
"duration": duration,
})
return output
def get_weekly_activity(self):
"""Retourne le nombre max d'utilisateurs actifs simultanes par jour (7 derniers jours)."""
log_path = self.config.get(
"amadea_log_path",
r"C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs"
)
if not os.path.isdir(log_path):
return []
result = []
today = datetime.now().date()
for delta in range(6, -1, -1):
day = today - timedelta(days=delta)
date_str = day.strftime("%y-%m-%d")
files = _log_files_for_date(log_path, "awevents", date_str)
if not files:
result.append({"date": day.isoformat(), "count": None})
continue
hourly = {h: set() for h in range(24)}
for filepath in files:
content = _read_log_file(filepath)
if not content:
continue
for line in content.splitlines():
m = re.match(
r'^(\d{4}-\d{2}-\d{2} (\d{2}):\d{2}:\d{2}).*login=([^,]+),',
line
)
if m:
hour = int(m.group(2))
login = m.group(3).strip()
if login:
hourly[hour].add(login)
max_concurrent = max((len(v) for v in hourly.values()), default=0)
result.append({"date": day.isoformat(), "count": max_concurrent})
return result