Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ac8a2ecfa | ||
|
|
545ae921e5 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -7,12 +7,7 @@ data/alerts.json
|
||||
*.log
|
||||
.env
|
||||
imput/
|
||||
logTest/
|
||||
log/
|
||||
CLAUDE.md
|
||||
docs/
|
||||
.claude/
|
||||
*.spec
|
||||
build/
|
||||
dist/
|
||||
docs/
|
||||
target/
|
||||
|
||||
2358
Cargo.lock
generated
Normal file
2358
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
Cargo.toml
Normal file
33
Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "supervision"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
description = "Monitoring systeme avec interface web securisee"
|
||||
|
||||
[[bin]]
|
||||
name = "supervision"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.7"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tera = "1"
|
||||
sysinfo = "0.32"
|
||||
lettre = "0.11"
|
||||
argon2 = "0.5"
|
||||
password-hash = "0.5"
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["fs"] }
|
||||
rand = "0.8"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
form_urlencoded = "1"
|
||||
http = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
154
README.md
154
README.md
@@ -4,20 +4,19 @@ Outil de surveillance systeme avec interface web securisee.
|
||||
Surveille CPU, RAM, disques et processus specifiques (JVM, Nginx, Amadea Web 8 x64).
|
||||
Envoie des alertes email lorsque les seuils configures sont depasses.
|
||||
|
||||
**Binaire natif Rust** — aucune dependance runtime, ~6 Mo.
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalites
|
||||
|
||||
- **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
|
||||
- **Configuration complete via l'interface** :
|
||||
- Seuils d'alerte (CPU, RAM, disque)
|
||||
- Frequence de verification (en minutes)
|
||||
- Serveur SMTP + test d'envoi integre
|
||||
- Ajout/suppression de processus a surveiller
|
||||
- Chemin du dossier de logs Amadea
|
||||
- Seuils de statut utilisateurs (actif / inactif)
|
||||
- Port de l'application
|
||||
- Mot de passe administrateur
|
||||
- **Securite** : authentification par login/mot de passe, rate limiting anti-bruteforce, en-tetes HTTP securises
|
||||
@@ -34,9 +33,11 @@ Envoie des alertes email lorsque les seuils configures sont depasses.
|
||||
|
||||
### Etapes
|
||||
|
||||
1. **Dezipper** `supervision_portable.zip` dans un dossier, par exemple :
|
||||
1. **Copier** les fichiers suivants dans un dossier, par exemple `C:\supervision\` :
|
||||
```
|
||||
C:\supervision\
|
||||
supervision.exe
|
||||
templates\ (dossier complet)
|
||||
static\ (dossier complet)
|
||||
```
|
||||
|
||||
2. **Lancer** l'executable :
|
||||
@@ -59,6 +60,8 @@ Envoie des alertes email lorsque les seuils configures sont depasses.
|
||||
|
||||
7. **Ajuster les seuils** si necessaire (valeurs par defaut : CPU 90%, RAM 85%, Disque 90%)
|
||||
|
||||
> Le dossier `data\` est cree automatiquement au premier lancement avec la configuration par defaut.
|
||||
|
||||
---
|
||||
|
||||
## Acces distant
|
||||
@@ -101,34 +104,54 @@ Pour que Supervision demarre automatiquement avec Windows, utiliser [NSSM](https
|
||||
|
||||
---
|
||||
|
||||
## Installation depuis les sources (developpement)
|
||||
## Compilation depuis les sources
|
||||
|
||||
### Pre-requis
|
||||
|
||||
- Python 3.10 ou superieur
|
||||
- [Rust](https://rustup.rs/) 1.75 ou superieur
|
||||
|
||||
### Etapes
|
||||
### Compiler pour la machine locale (Linux/Windows)
|
||||
|
||||
1. Creer l'environnement virtuel et installer les dependances :
|
||||
```cmd
|
||||
cd C:\supervision
|
||||
python -m venv .venv
|
||||
.venv\Scripts\pip.exe install -r requirements.txt
|
||||
```
|
||||
|
||||
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
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
L'executable est genere dans `dist\supervision\`.
|
||||
L'executable est genere dans `target/release/supervision` (Linux) ou `target\release\supervision.exe` (Windows).
|
||||
|
||||
### Cross-compiler pour Windows depuis Linux
|
||||
|
||||
```bash
|
||||
# Installation (une seule fois)
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
sudo apt install gcc-mingw-w64-x86-64
|
||||
|
||||
# Compilation
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -137,30 +160,25 @@ L'executable est genere dans `dist\supervision\`.
|
||||
```
|
||||
supervision\
|
||||
├── supervision.exe # Executable principal
|
||||
├── _internal\ # Dependances Python embarquees
|
||||
├── Cargo.toml # Dependances Rust
|
||||
├── src\
|
||||
│ ├── main.rs # Serveur web, routes, sessions
|
||||
│ ├── config.rs # Gestion configuration JSON
|
||||
│ ├── monitor.rs # Collecte metriques systeme
|
||||
│ └── alerter.rs # Envoi alertes email SMTP
|
||||
├── templates\ # Pages HTML de l'interface
|
||||
├── static\ # CSS
|
||||
│ ├── base.html
|
||||
│ ├── login.html
|
||||
│ ├── dashboard.html
|
||||
│ ├── settings.html
|
||||
│ └── alerts.html
|
||||
├── static\
|
||||
│ └── style.css # Styles CSS
|
||||
└── data\ # (cree au 1er lancement)
|
||||
├── config.json # Configuration (seuils, SMTP, processus, logs Amadea)
|
||||
├── config.json # Configuration (seuils, SMTP, processus)
|
||||
└── 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
|
||||
@@ -173,9 +191,6 @@ supervision\
|
||||
| Seuil CPU | 90% |
|
||||
| Seuil RAM | 85% |
|
||||
| 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
|
||||
|
||||
@@ -189,44 +204,11 @@ Tous les parametres sont modifiables depuis l'interface web.
|
||||
|
||||
---
|
||||
|
||||
## Suivi des utilisateurs Amadea
|
||||
## Migration depuis la version Python
|
||||
|
||||
L'onglet **Utilisateurs** affiche en temps reel les utilisateurs connectes a Amadea Web 8 x64.
|
||||
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.
|
||||
|
||||
### 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`
|
||||
Les autres parametres (seuils, SMTP, processus) sont conserves.
|
||||
|
||||
---
|
||||
|
||||
@@ -238,6 +220,4 @@ Valeur par defaut : `C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs`
|
||||
| Impossible de se connecter a distance | Verifier la regle firewall (port 5000 TCP entrant) |
|
||||
| Pas d'email recu | Verifier la configuration SMTP et utiliser le bouton "Envoyer un email de test" |
|
||||
| Mot de passe oublie | Supprimer `data\config.json` et relancer (reinitialise a admin/admin) |
|
||||
| L'executable ne se lance pas | Verifier que le dossier `_internal\` est present a cote de `supervision.exe` |
|
||||
| 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 |
|
||||
| L'executable ne se lance pas | Verifier que les dossiers `templates\` et `static\` sont a cote de l'executable |
|
||||
|
||||
55
alerter.py
55
alerter.py
@@ -1,11 +1,8 @@
|
||||
"""Envoi d'alertes par email via Brevo API ou SMTP."""
|
||||
"""Envoi d'alertes par email via SMTP."""
|
||||
|
||||
import json
|
||||
import smtplib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
|
||||
class EmailAlerter:
|
||||
@@ -17,64 +14,28 @@ class EmailAlerter:
|
||||
|
||||
def is_configured(self):
|
||||
smtp = self._get_smtp_config()
|
||||
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"))
|
||||
return bool(smtp.get("server") and smtp.get("from_email") and smtp.get("to_emails"))
|
||||
|
||||
def send_alert(self, subject, body):
|
||||
"""Envoie un email d'alerte. Silencieux si non configure."""
|
||||
"""Envoie un email d'alerte. Silencieux si SMTP non configure."""
|
||||
if not self.is_configured():
|
||||
return False, "Email non configure"
|
||||
return False, "SMTP non configure"
|
||||
return self._send_email(subject, body)
|
||||
|
||||
def send_test(self):
|
||||
"""Envoie un email de test pour valider la configuration."""
|
||||
"""Envoie un email de test pour valider la configuration SMTP."""
|
||||
if not self.is_configured():
|
||||
return False, "Configuration email incomplete"
|
||||
return False, "Configuration SMTP incomplete"
|
||||
subject = "[TEST] Supervision - Test de configuration email"
|
||||
body = (
|
||||
"Ceci est un email de test.\n\n"
|
||||
"Si vous recevez ce message, la configuration est correcte.\n\n"
|
||||
"Si vous recevez ce message, la configuration SMTP est correcte.\n\n"
|
||||
"-- Supervision"
|
||||
)
|
||||
return self._send_email(subject, body)
|
||||
|
||||
def _send_email(self, subject, body):
|
||||
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:
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = smtp_cfg["from_email"]
|
||||
|
||||
102
app.py
102
app.py
@@ -20,7 +20,6 @@ from werkzeug.security import check_password_hash, generate_password_hash
|
||||
from config_manager import ConfigManager
|
||||
from monitor import SystemMonitor
|
||||
from alerter import EmailAlerter
|
||||
from user_monitor import UserMonitor
|
||||
|
||||
# --- Init ---
|
||||
config = ConfigManager()
|
||||
@@ -54,7 +53,6 @@ login_manager.login_message = "Veuillez vous connecter."
|
||||
# Services
|
||||
alerter = EmailAlerter(config)
|
||||
monitor = SystemMonitor(config, alerter)
|
||||
user_monitor = UserMonitor(config)
|
||||
|
||||
|
||||
class AdminUser(UserMixin):
|
||||
@@ -126,8 +124,10 @@ def settings():
|
||||
smtp = cfg.get("smtp", {})
|
||||
# Masquer le mot de passe SMTP dans l'affichage
|
||||
smtp_display = dict(smtp)
|
||||
smtp_display["password_masked"] = "*" * 8 if smtp_display.get("password") else ""
|
||||
smtp_display["brevo_api_key_masked"] = "*" * 8 if smtp_display.get("brevo_api_key") else ""
|
||||
if smtp_display.get("password"):
|
||||
smtp_display["password_masked"] = "*" * 8
|
||||
else:
|
||||
smtp_display["password_masked"] = ""
|
||||
return render_template(
|
||||
"settings.html",
|
||||
config=cfg,
|
||||
@@ -181,7 +181,6 @@ def update_monitoring():
|
||||
@login_required
|
||||
def update_smtp():
|
||||
try:
|
||||
old_smtp = config.get("smtp", {})
|
||||
smtp = {
|
||||
"server": request.form["smtp_server"].strip(),
|
||||
"port": int(request.form["smtp_port"]),
|
||||
@@ -192,12 +191,14 @@ def update_smtp():
|
||||
e.strip() for e in request.form["smtp_to"].split(",") if e.strip()
|
||||
],
|
||||
}
|
||||
# Conserver le mot de passe si non fourni
|
||||
# Ne mettre a jour le mot de passe que s'il est fourni
|
||||
new_password = request.form.get("smtp_password", "")
|
||||
smtp["password"] = new_password if new_password else old_smtp.get("password", "")
|
||||
# Conserver la cle Brevo si non fournie
|
||||
new_brevo_key = request.form.get("brevo_api_key", "").strip()
|
||||
smtp["brevo_api_key"] = new_brevo_key if new_brevo_key else old_smtp.get("brevo_api_key", "")
|
||||
if new_password:
|
||||
smtp["password"] = new_password
|
||||
else:
|
||||
# Garder l'ancien mot de passe
|
||||
old_smtp = config.get("smtp", {})
|
||||
smtp["password"] = old_smtp.get("password", "")
|
||||
|
||||
config.set("smtp", smtp)
|
||||
flash("Configuration SMTP mise a jour.", "success")
|
||||
@@ -311,84 +312,6 @@ def toggle_monitoring():
|
||||
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):
|
||||
"""Verifie si un port est disponible."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
@@ -418,9 +341,6 @@ def main():
|
||||
# Collecte initiale
|
||||
monitor.collect_metrics()
|
||||
|
||||
user_monitor.start()
|
||||
user_monitor.parse_logs()
|
||||
|
||||
print("[Supervision] Monitoring actif")
|
||||
|
||||
app.run(host="0.0.0.0", port=port, debug=False)
|
||||
|
||||
@@ -54,12 +54,6 @@ def get_default_config():
|
||||
"password": "",
|
||||
"from_email": "",
|
||||
"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": {
|
||||
"username": "admin",
|
||||
|
||||
@@ -4,4 +4,3 @@ flask-limiter==3.9.*
|
||||
psutil==6.1.*
|
||||
werkzeug==3.1.*
|
||||
pyinstaller==6.12.*
|
||||
pytest==8.3.*
|
||||
|
||||
86
src/alerter.rs
Normal file
86
src/alerter.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use crate::config::SmtpConfig;
|
||||
use lettre::message::header::ContentType;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{Message, SmtpTransport, Transport};
|
||||
|
||||
pub fn is_configured(smtp: &SmtpConfig) -> bool {
|
||||
!smtp.server.is_empty() && !smtp.from_email.is_empty() && !smtp.to_emails.is_empty()
|
||||
}
|
||||
|
||||
pub fn send_email(smtp: &SmtpConfig, subject: &str, body: &str) -> Result<String, String> {
|
||||
if !is_configured(smtp) {
|
||||
return Err("SMTP non configure".into());
|
||||
}
|
||||
|
||||
let to_str = smtp.to_emails.join(", ");
|
||||
|
||||
let email = Message::builder()
|
||||
.from(
|
||||
smtp.from_email
|
||||
.parse()
|
||||
.map_err(|e| format!("Email expediteur invalide: {}", e))?,
|
||||
)
|
||||
.to(to_str
|
||||
.parse()
|
||||
.map_err(|e| format!("Email destinataire invalide: {}", e))?)
|
||||
.subject(subject)
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(body.to_string())
|
||||
.map_err(|e| format!("Erreur construction email: {}", e))?;
|
||||
|
||||
let mailer = build_transport(smtp)?;
|
||||
|
||||
mailer
|
||||
.send(&email)
|
||||
.map_err(|e| format!("Erreur envoi SMTP: {}", e))?;
|
||||
|
||||
Ok("Email envoye avec succes".into())
|
||||
}
|
||||
|
||||
pub fn send_test(smtp: &SmtpConfig) -> Result<String, String> {
|
||||
if !is_configured(smtp) {
|
||||
return Err("Configuration SMTP incomplete".into());
|
||||
}
|
||||
let subject = "[TEST] Supervision - Test de configuration email";
|
||||
let body = "Ceci est un email de test.\n\n\
|
||||
Si vous recevez ce message, la configuration SMTP est correcte.\n\n\
|
||||
-- Supervision";
|
||||
send_email(smtp, subject, body)
|
||||
}
|
||||
|
||||
fn build_transport(smtp: &SmtpConfig) -> Result<SmtpTransport, String> {
|
||||
let creds = Credentials::new(smtp.username.clone(), smtp.password.clone());
|
||||
|
||||
let transport = if smtp.use_tls {
|
||||
SmtpTransport::starttls_relay(&smtp.server)
|
||||
.map_err(|e| format!("Erreur connexion SMTP TLS: {}", e))?
|
||||
.credentials(creds)
|
||||
.port(smtp.port)
|
||||
.build()
|
||||
} else {
|
||||
SmtpTransport::builder_dangerous(&smtp.server)
|
||||
.credentials(creds)
|
||||
.port(smtp.port)
|
||||
.build()
|
||||
};
|
||||
|
||||
Ok(transport)
|
||||
}
|
||||
|
||||
pub fn format_alert_body(alert: &crate::config::Alert) -> String {
|
||||
format!(
|
||||
"Alerte de supervision\n\
|
||||
{sep}\n\n\
|
||||
Serveur : {host}\n\
|
||||
Date : {ts}\n\
|
||||
Type : {tp}\n\n\
|
||||
Message : {msg}\n\n\
|
||||
{sep}\n\
|
||||
Supervision - Monitoring automatique",
|
||||
sep = "=".repeat(40),
|
||||
host = alert.hostname,
|
||||
ts = alert.timestamp,
|
||||
tp = alert.alert_type,
|
||||
msg = alert.message,
|
||||
)
|
||||
}
|
||||
238
src/config.rs
Normal file
238
src/config.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
|
||||
use password_hash::{PasswordHash, SaltString};
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MAX_ALERTS: usize = 500;
|
||||
|
||||
// --- Structures de configuration ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub secret_key: String,
|
||||
pub port: u16,
|
||||
pub check_interval_minutes: u64,
|
||||
pub alert_cooldown_minutes: u64,
|
||||
pub thresholds: Thresholds,
|
||||
pub processes: Vec<ProcessConfig>,
|
||||
pub smtp: SmtpConfig,
|
||||
pub admin: AdminConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Thresholds {
|
||||
pub cpu_percent: u32,
|
||||
pub ram_percent: u32,
|
||||
pub disk_percent: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProcessConfig {
|
||||
pub name: String,
|
||||
pub pattern: String,
|
||||
#[serde(default)]
|
||||
pub memory_threshold_mb: u64,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub alert_on_down: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SmtpConfig {
|
||||
#[serde(default)]
|
||||
pub server: String,
|
||||
#[serde(default = "default_smtp_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_true")]
|
||||
pub use_tls: bool,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub from_email: String,
|
||||
#[serde(default)]
|
||||
pub to_emails: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_smtp_port() -> u16 {
|
||||
587
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AdminConfig {
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Alert {
|
||||
pub timestamp: String,
|
||||
#[serde(rename = "type")]
|
||||
pub alert_type: String,
|
||||
pub key: String,
|
||||
pub message: String,
|
||||
pub value: f64,
|
||||
pub threshold: f64,
|
||||
pub hostname: String,
|
||||
}
|
||||
|
||||
// --- Hashing de mots de passe ---
|
||||
|
||||
pub fn hash_password(password: &str) -> String {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.expect("Erreur hashing mot de passe")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn verify_password(password: &str, hash: &str) -> bool {
|
||||
let parsed = match PasswordHash::new(hash) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return false,
|
||||
};
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub fn is_default_password(config: &Config) -> bool {
|
||||
verify_password("admin", &config.admin.password_hash)
|
||||
}
|
||||
|
||||
// --- Configuration par defaut ---
|
||||
|
||||
pub fn default_config() -> Config {
|
||||
Config {
|
||||
secret_key: uuid::Uuid::new_v4().to_string(),
|
||||
port: 5000,
|
||||
check_interval_minutes: 1,
|
||||
alert_cooldown_minutes: 30,
|
||||
thresholds: Thresholds {
|
||||
cpu_percent: 90,
|
||||
ram_percent: 85,
|
||||
disk_percent: 90,
|
||||
},
|
||||
processes: vec![
|
||||
ProcessConfig {
|
||||
name: "JVM".into(),
|
||||
pattern: "java".into(),
|
||||
memory_threshold_mb: 0,
|
||||
enabled: true,
|
||||
alert_on_down: true,
|
||||
},
|
||||
ProcessConfig {
|
||||
name: "Nginx".into(),
|
||||
pattern: "nginx".into(),
|
||||
memory_threshold_mb: 0,
|
||||
enabled: false,
|
||||
alert_on_down: false,
|
||||
},
|
||||
ProcessConfig {
|
||||
name: "Amadea Web 8 x64".into(),
|
||||
pattern: "amadea".into(),
|
||||
memory_threshold_mb: 0,
|
||||
enabled: true,
|
||||
alert_on_down: true,
|
||||
},
|
||||
],
|
||||
smtp: SmtpConfig {
|
||||
server: String::new(),
|
||||
port: 587,
|
||||
use_tls: true,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
from_email: String::new(),
|
||||
to_emails: Vec::new(),
|
||||
},
|
||||
admin: AdminConfig {
|
||||
username: "admin".into(),
|
||||
password_hash: hash_password("admin"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- Persistence fichier ---
|
||||
|
||||
pub fn config_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("config.json")
|
||||
}
|
||||
|
||||
pub fn alerts_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("alerts.json")
|
||||
}
|
||||
|
||||
pub fn load_config(data_dir: &Path) -> Config {
|
||||
fs::create_dir_all(data_dir).ok();
|
||||
let path = config_path(data_dir);
|
||||
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(&path).unwrap_or_default();
|
||||
match serde_json::from_str::<Config>(&content) {
|
||||
Ok(mut config) => {
|
||||
// Si le hash n'est pas au format argon2, reinitialiser
|
||||
if !config.admin.password_hash.starts_with("$argon2") {
|
||||
println!(
|
||||
"[ATTENTION] Hash de mot de passe incompatible (format Python), \
|
||||
reinitialise a 'admin'"
|
||||
);
|
||||
config.admin.password_hash = hash_password("admin");
|
||||
save_config(data_dir, &config);
|
||||
}
|
||||
config
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[Config] Erreur de lecture: {}. Creation d'une config par defaut.", e);
|
||||
let config = default_config();
|
||||
save_config(data_dir, &config);
|
||||
config
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let config = default_config();
|
||||
save_config(data_dir, &config);
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_config(data_dir: &Path, config: &Config) {
|
||||
fs::create_dir_all(data_dir).ok();
|
||||
let path = config_path(data_dir);
|
||||
let json = serde_json::to_string_pretty(config).expect("Serialisation config");
|
||||
if let Err(e) = fs::write(&path, json) {
|
||||
eprintln!("[Config] Erreur d'ecriture: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_alerts(data_dir: &Path) -> Vec<Alert> {
|
||||
let path = alerts_path(data_dir);
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(&path).unwrap_or_default();
|
||||
serde_json::from_str(&content).unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_alert(data_dir: &Path, alert: &Alert) {
|
||||
let mut alerts = load_alerts(data_dir);
|
||||
alerts.insert(0, alert.clone());
|
||||
alerts.truncate(MAX_ALERTS);
|
||||
let path = alerts_path(data_dir);
|
||||
let json = serde_json::to_string_pretty(&alerts).unwrap_or_default();
|
||||
fs::write(&path, json).ok();
|
||||
}
|
||||
|
||||
pub fn clear_alerts(data_dir: &Path) {
|
||||
let path = alerts_path(data_dir);
|
||||
fs::write(&path, "[]").ok();
|
||||
}
|
||||
922
src/main.rs
Normal file
922
src/main.rs
Normal file
@@ -0,0 +1,922 @@
|
||||
mod alerter;
|
||||
mod config;
|
||||
mod monitor;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{ConnectInfo, Form, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Json, Redirect, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use config::Alert;
|
||||
use serde::Deserialize;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use tera::{Context, Tera};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
// --- Etat applicatif ---
|
||||
|
||||
pub struct AppState {
|
||||
pub config: RwLock<config::Config>,
|
||||
pub metrics: RwLock<Option<monitor::Metrics>>,
|
||||
pub sessions: RwLock<HashMap<String, Session>>,
|
||||
pub rate_limiter: RwLock<RateLimiter>,
|
||||
pub monitoring_active: AtomicBool,
|
||||
pub tera: Tera,
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
pub username: String,
|
||||
pub flashes: Vec<FlashMessage>,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
pub struct FlashMessage {
|
||||
pub category: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
struct SessionInfo {
|
||||
session_id: String,
|
||||
#[allow(dead_code)]
|
||||
username: String,
|
||||
}
|
||||
|
||||
// --- Rate Limiter ---
|
||||
|
||||
pub struct RateLimiter {
|
||||
attempts: HashMap<IpAddr, VecDeque<Instant>>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
attempts: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn check(&mut self, ip: IpAddr, max: usize, window: Duration) -> bool {
|
||||
let now = Instant::now();
|
||||
let entry = self.attempts.entry(ip).or_default();
|
||||
while let Some(&front) = entry.front() {
|
||||
if now.duration_since(front) > window {
|
||||
entry.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if entry.len() >= max {
|
||||
return false;
|
||||
}
|
||||
entry.push_back(now);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers session / auth ---
|
||||
|
||||
fn get_session_id(headers: &HeaderMap) -> Option<String> {
|
||||
let cookie = headers.get("cookie")?.to_str().ok()?;
|
||||
for pair in cookie.split(';') {
|
||||
let pair = pair.trim();
|
||||
if let Some(value) = pair.strip_prefix("session_id=") {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<SessionInfo, Response> {
|
||||
if let Some(sid) = get_session_id(headers) {
|
||||
let sessions = state.sessions.read().unwrap();
|
||||
if let Some(session) = sessions.get(&sid) {
|
||||
return Ok(SessionInfo {
|
||||
session_id: sid,
|
||||
username: session.username.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(Redirect::to("/login").into_response())
|
||||
}
|
||||
|
||||
fn add_flash(state: &AppState, session_id: &str, category: &str, message: &str) {
|
||||
if let Ok(mut sessions) = state.sessions.write() {
|
||||
if let Some(session) = sessions.get_mut(session_id) {
|
||||
session.flashes.push(FlashMessage {
|
||||
category: category.into(),
|
||||
message: message.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn take_flashes(state: &AppState, session_id: &str) -> Vec<FlashMessage> {
|
||||
if let Ok(mut sessions) = state.sessions.write() {
|
||||
if let Some(session) = sessions.get_mut(session_id) {
|
||||
return std::mem::take(&mut session.flashes);
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn redirect_with_cookie(path: &str, cookie: &str) -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::SEE_OTHER)
|
||||
.header("location", path)
|
||||
.header("set-cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// --- Contexte template commun ---
|
||||
|
||||
fn base_context(state: &AppState, session: &SessionInfo) -> Context {
|
||||
let flashes = take_flashes(state, &session.session_id);
|
||||
let config = state.config.read().unwrap();
|
||||
let default_pw = config::is_default_password(&config);
|
||||
|
||||
let mut ctx = Context::new();
|
||||
ctx.insert("authenticated", &true);
|
||||
ctx.insert("flash_messages", &flashes);
|
||||
ctx.insert("default_pw", &default_pw);
|
||||
ctx.insert("username", &session.username);
|
||||
ctx
|
||||
}
|
||||
|
||||
fn render(tera: &Tera, template: &str, ctx: &Context) -> Result<Html<String>, Response> {
|
||||
tera.render(template, ctx).map(Html).map_err(|e| {
|
||||
eprintln!("[Template] Erreur {}: {}", template, e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Erreur interne").into_response()
|
||||
})
|
||||
}
|
||||
|
||||
// --- Middleware securite ---
|
||||
|
||||
fn apply_security_headers(response: &mut Response) {
|
||||
let h = response.headers_mut();
|
||||
h.insert("x-content-type-options", HeaderValue::from_static("nosniff"));
|
||||
h.insert("x-frame-options", HeaderValue::from_static("DENY"));
|
||||
h.insert(
|
||||
"x-xss-protection",
|
||||
HeaderValue::from_static("1; mode=block"),
|
||||
);
|
||||
h.insert(
|
||||
"referrer-policy",
|
||||
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
// GET /login
|
||||
async fn login_page(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let mut ctx = Context::new();
|
||||
ctx.insert("flash_messages", &Vec::<FlashMessage>::new());
|
||||
let mut resp = render(&state.tera, "login.html", &ctx)?.into_response();
|
||||
apply_security_headers(&mut resp);
|
||||
Ok::<_, Response>(resp)
|
||||
}
|
||||
|
||||
// POST /login
|
||||
#[derive(Deserialize)]
|
||||
struct LoginForm {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
async fn login_action(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Response {
|
||||
// Rate limiting : 10 tentatives par minute
|
||||
{
|
||||
let mut limiter = state.rate_limiter.write().unwrap();
|
||||
if !limiter.check(addr.ip(), 10, Duration::from_secs(60)) {
|
||||
let mut ctx = Context::new();
|
||||
ctx.insert(
|
||||
"flash_messages",
|
||||
&vec![FlashMessage {
|
||||
category: "danger".into(),
|
||||
message: "Trop de tentatives. Reessayez dans une minute.".into(),
|
||||
}],
|
||||
);
|
||||
let html = state.tera.render("login.html", &ctx).unwrap_or_default();
|
||||
return (StatusCode::TOO_MANY_REQUESTS, Html(html)).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let config = state.config.read().unwrap().clone();
|
||||
let username = form.username.trim();
|
||||
|
||||
if username == config.admin.username
|
||||
&& config::verify_password(&form.password, &config.admin.password_hash)
|
||||
{
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
{
|
||||
let mut sessions = state.sessions.write().unwrap();
|
||||
sessions.insert(
|
||||
session_id.clone(),
|
||||
Session {
|
||||
username: username.to_string(),
|
||||
flashes: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let cookie = format!(
|
||||
"session_id={}; HttpOnly; Path=/; SameSite=Strict; Max-Age=28800",
|
||||
session_id
|
||||
);
|
||||
redirect_with_cookie("/", &cookie)
|
||||
} else {
|
||||
let mut ctx = Context::new();
|
||||
ctx.insert(
|
||||
"flash_messages",
|
||||
&vec![FlashMessage {
|
||||
category: "danger".into(),
|
||||
message: "Identifiants incorrects.".into(),
|
||||
}],
|
||||
);
|
||||
let html = state.tera.render("login.html", &ctx).unwrap_or_default();
|
||||
Html(html).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
// GET /logout
|
||||
async fn logout(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
|
||||
if let Ok(session) = check_auth(&state, &headers) {
|
||||
let mut sessions = state.sessions.write().unwrap();
|
||||
sessions.remove(&session.session_id);
|
||||
}
|
||||
let cookie = "session_id=; HttpOnly; Path=/; Max-Age=0";
|
||||
redirect_with_cookie("/login", cookie)
|
||||
}
|
||||
|
||||
// GET /
|
||||
async fn dashboard(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, Response> {
|
||||
let session = check_auth(&state, &headers)?;
|
||||
let mut ctx = base_context(&state, &session);
|
||||
ctx.insert("active_page", "dashboard");
|
||||
|
||||
let metrics = state.metrics.read().unwrap().clone();
|
||||
ctx.insert("metrics", &metrics);
|
||||
|
||||
let mut resp = render(&state.tera, "dashboard.html", &ctx)?.into_response();
|
||||
apply_security_headers(&mut resp);
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
// GET /api/metrics
|
||||
async fn api_metrics(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, Response> {
|
||||
check_auth(&state, &headers)?;
|
||||
let metrics = state.metrics.read().unwrap().clone();
|
||||
let mut resp = match metrics {
|
||||
Some(m) => Json(m).into_response(),
|
||||
None => Json(serde_json::json!({})).into_response(),
|
||||
};
|
||||
apply_security_headers(&mut resp);
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
// POST /api/monitoring/toggle
|
||||
async fn toggle_monitoring(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, Response> {
|
||||
let session = check_auth(&state, &headers)?;
|
||||
let was_active = state.monitoring_active.load(Ordering::Relaxed);
|
||||
state.monitoring_active.store(!was_active, Ordering::Relaxed);
|
||||
|
||||
if was_active {
|
||||
add_flash(&state, &session.session_id, "warning", "Monitoring arrete.");
|
||||
} else {
|
||||
add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"success",
|
||||
"Monitoring demarre.",
|
||||
);
|
||||
}
|
||||
Ok(Redirect::to("/").into_response())
|
||||
}
|
||||
|
||||
// GET /settings
|
||||
async fn settings_page(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, Response> {
|
||||
let session = check_auth(&state, &headers)?;
|
||||
let mut ctx = base_context(&state, &session);
|
||||
ctx.insert("active_page", "settings");
|
||||
|
||||
let config = state.config.read().unwrap().clone();
|
||||
ctx.insert("config", &config);
|
||||
|
||||
// SMTP avec mot de passe masque
|
||||
let password_masked = if config.smtp.password.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
"********".into()
|
||||
};
|
||||
let mut smtp = serde_json::to_value(&config.smtp).unwrap();
|
||||
smtp["password_masked"] = serde_json::Value::String(password_masked);
|
||||
ctx.insert("smtp", &smtp);
|
||||
|
||||
let mut resp = render(&state.tera, "settings.html", &ctx)?.into_response();
|
||||
apply_security_headers(&mut resp);
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
// POST /settings/thresholds
|
||||
#[derive(Deserialize)]
|
||||
struct ThresholdsForm {
|
||||
cpu_percent: u32,
|
||||
ram_percent: u32,
|
||||
disk_percent: u32,
|
||||
}
|
||||
|
||||
async fn update_thresholds(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<ThresholdsForm>,
|
||||
) -> Result<Response, Response> {
|
||||
let session = check_auth(&state, &headers)?;
|
||||
|
||||
for (name, val) in [
|
||||
("cpu_percent", form.cpu_percent),
|
||||
("ram_percent", form.ram_percent),
|
||||
("disk_percent", form.disk_percent),
|
||||
] {
|
||||
if !(1..=100).contains(&val) {
|
||||
add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"danger",
|
||||
&format!("Le seuil {} doit etre entre 1 et 100.", name),
|
||||
);
|
||||
return Ok(Redirect::to("/settings").into_response());
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut config = state.config.write().unwrap();
|
||||
config.thresholds.cpu_percent = form.cpu_percent;
|
||||
config.thresholds.ram_percent = form.ram_percent;
|
||||
config.thresholds.disk_percent = form.disk_percent;
|
||||
config::save_config(&state.data_dir, &config);
|
||||
}
|
||||
add_flash(&state, &session.session_id, "success", "Seuils mis a jour.");
|
||||
Ok(Redirect::to("/settings").into_response())
|
||||
}
|
||||
|
||||
// POST /settings/monitoring
|
||||
#[derive(Deserialize)]
|
||||
struct MonitoringForm {
|
||||
check_interval_minutes: u64,
|
||||
alert_cooldown_minutes: u64,
|
||||
}
|
||||
|
||||
async fn update_monitoring(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<MonitoringForm>,
|
||||
) -> Result<Response, Response> {
|
||||
let session = check_auth(&state, &headers)?;
|
||||
|
||||
if form.check_interval_minutes < 1 {
|
||||
add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"danger",
|
||||
"L'intervalle doit etre d'au moins 1 minute.",
|
||||
);
|
||||
return Ok(Redirect::to("/settings").into_response());
|
||||
}
|
||||
if form.alert_cooldown_minutes < 1 {
|
||||
add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"danger",
|
||||
"Le cooldown doit etre d'au moins 1 minute.",
|
||||
);
|
||||
return Ok(Redirect::to("/settings").into_response());
|
||||
}
|
||||
|
||||
{
|
||||
let mut config = state.config.write().unwrap();
|
||||
config.check_interval_minutes = form.check_interval_minutes;
|
||||
config.alert_cooldown_minutes = form.alert_cooldown_minutes;
|
||||
config::save_config(&state.data_dir, &config);
|
||||
}
|
||||
add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"success",
|
||||
"Parametres de monitoring mis a jour.",
|
||||
);
|
||||
Ok(Redirect::to("/settings").into_response())
|
||||
}
|
||||
|
||||
// POST /settings/smtp
|
||||
#[derive(Deserialize)]
|
||||
struct SmtpForm {
|
||||
smtp_server: String,
|
||||
smtp_port: u16,
|
||||
smtp_tls: Option<String>,
|
||||
smtp_username: String,
|
||||
smtp_password: Option<String>,
|
||||
smtp_from: String,
|
||||
smtp_to: String,
|
||||
}
|
||||
|
||||
async fn update_smtp(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<SmtpForm>,
|
||||
) -> Result<Response, Response> {
|
||||
let session = check_auth(&state, &headers)?;
|
||||
|
||||
let to_emails: Vec<String> = form
|
||||
.smtp_to
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
{
|
||||
let mut config = state.config.write().unwrap();
|
||||
config.smtp.server = form.smtp_server.trim().to_string();
|
||||
config.smtp.port = form.smtp_port;
|
||||
config.smtp.use_tls = form.smtp_tls.is_some();
|
||||
config.smtp.username = form.smtp_username.trim().to_string();
|
||||
config.smtp.from_email = form.smtp_from.trim().to_string();
|
||||
config.smtp.to_emails = to_emails;
|
||||
|
||||
if let Some(ref pw) = form.smtp_password {
|
||||
if !pw.is_empty() {
|
||||
config.smtp.password = pw.clone();
|
||||
}
|
||||
}
|
||||
config::save_config(&state.data_dir, &config);
|
||||
}
|
||||
add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"success",
|
||||
"Configuration SMTP mise a jour.",
|
||||
);
|
||||
Ok(Redirect::to("/settings").into_response())
|
||||
}
|
||||
|
||||
// POST /settings/smtp/test
|
||||
async fn test_smtp(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, Response> {
|
||||
let session = check_auth(&state, &headers)?;
|
||||
|
||||
let smtp_config = state.config.read().unwrap().smtp.clone();
|
||||
let result = tokio::task::spawn_blocking(move || alerter::send_test(&smtp_config)).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(msg)) => add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"success",
|
||||
&format!("Test reussi : {}", msg),
|
||||
),
|
||||
Ok(Err(msg)) => add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"danger",
|
||||
&format!("Test echoue : {}", msg),
|
||||
),
|
||||
Err(e) => add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"danger",
|
||||
&format!("Erreur: {}", e),
|
||||
),
|
||||
}
|
||||
Ok(Redirect::to("/settings").into_response())
|
||||
}
|
||||
|
||||
// POST /settings/processes
|
||||
async fn update_processes(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
body: String,
|
||||
) -> Result<Response, Response> {
|
||||
let session = check_auth(&state, &headers)?;
|
||||
|
||||
let pairs: Vec<(String, String)> = form_urlencoded::parse(body.as_bytes())
|
||||
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||
.collect();
|
||||
|
||||
let names: Vec<&str> = pairs
|
||||
.iter()
|
||||
.filter(|(k, _)| k == "proc_name[]")
|
||||
.map(|(_, v)| v.as_str())
|
||||
.collect();
|
||||
let patterns: Vec<&str> = pairs
|
||||
.iter()
|
||||
.filter(|(k, _)| k == "proc_pattern[]")
|
||||
.map(|(_, v)| v.as_str())
|
||||
.collect();
|
||||
let mem_thresholds: Vec<&str> = pairs
|
||||
.iter()
|
||||
.filter(|(k, _)| k == "proc_mem_threshold[]")
|
||||
.map(|(_, v)| v.as_str())
|
||||
.collect();
|
||||
let enableds: Vec<&str> = pairs
|
||||
.iter()
|
||||
.filter(|(k, _)| k == "proc_enabled[]")
|
||||
.map(|(_, v)| v.as_str())
|
||||
.collect();
|
||||
let alert_downs: Vec<&str> = pairs
|
||||
.iter()
|
||||
.filter(|(k, _)| k == "proc_alert_down[]")
|
||||
.map(|(_, v)| v.as_str())
|
||||
.collect();
|
||||
|
||||
let mut processes = Vec::new();
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let pattern = patterns
|
||||
.get(i)
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
let mem_threshold = mem_thresholds
|
||||
.get(i)
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
let idx_str = i.to_string();
|
||||
let enabled = enableds.contains(&idx_str.as_str());
|
||||
let alert_on_down = alert_downs.contains(&idx_str.as_str());
|
||||
|
||||
processes.push(config::ProcessConfig {
|
||||
name: name.to_string(),
|
||||
pattern,
|
||||
memory_threshold_mb: mem_threshold,
|
||||
enabled,
|
||||
alert_on_down,
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let mut config = state.config.write().unwrap();
|
||||
config.processes = processes;
|
||||
config::save_config(&state.data_dir, &config);
|
||||
}
|
||||
add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"success",
|
||||
"Processus surveilles mis a jour.",
|
||||
);
|
||||
Ok(Redirect::to("/settings").into_response())
|
||||
}
|
||||
|
||||
// POST /settings/password
|
||||
#[derive(Deserialize)]
|
||||
struct PasswordForm {
|
||||
current_password: String,
|
||||
new_password: String,
|
||||
confirm_password: String,
|
||||
}
|
||||
|
||||
async fn update_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<PasswordForm>,
|
||||
) -> Result<Response, Response> {
|
||||
let session = check_auth(&state, &headers)?;
|
||||
|
||||
{
|
||||
let config = state.config.read().unwrap();
|
||||
if !config::verify_password(&form.current_password, &config.admin.password_hash) {
|
||||
add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"danger",
|
||||
"Mot de passe actuel incorrect.",
|
||||
);
|
||||
return Ok(Redirect::to("/settings").into_response());
|
||||
}
|
||||
}
|
||||
|
||||
if form.new_password.len() < 8 {
|
||||
add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"danger",
|
||||
"Le nouveau mot de passe doit faire au moins 8 caracteres.",
|
||||
);
|
||||
return Ok(Redirect::to("/settings").into_response());
|
||||
}
|
||||
|
||||
if form.new_password != form.confirm_password {
|
||||
add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"danger",
|
||||
"Les mots de passe ne correspondent pas.",
|
||||
);
|
||||
return Ok(Redirect::to("/settings").into_response());
|
||||
}
|
||||
|
||||
{
|
||||
let mut config = state.config.write().unwrap();
|
||||
config.admin.password_hash = config::hash_password(&form.new_password);
|
||||
config::save_config(&state.data_dir, &config);
|
||||
}
|
||||
add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"success",
|
||||
"Mot de passe mis a jour.",
|
||||
);
|
||||
Ok(Redirect::to("/settings").into_response())
|
||||
}
|
||||
|
||||
// POST /settings/port
|
||||
#[derive(Deserialize)]
|
||||
struct PortForm {
|
||||
port: u16,
|
||||
}
|
||||
|
||||
async fn update_port(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<PortForm>,
|
||||
) -> Result<Response, Response> {
|
||||
let session = check_auth(&state, &headers)?;
|
||||
|
||||
if !(1024..=65535).contains(&form.port) {
|
||||
add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"danger",
|
||||
"Le port doit etre entre 1024 et 65535.",
|
||||
);
|
||||
return Ok(Redirect::to("/settings").into_response());
|
||||
}
|
||||
|
||||
{
|
||||
let mut config = state.config.write().unwrap();
|
||||
config.port = form.port;
|
||||
config::save_config(&state.data_dir, &config);
|
||||
}
|
||||
add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"warning",
|
||||
&format!(
|
||||
"Port mis a jour a {}. Redemarrez l'application pour appliquer.",
|
||||
form.port
|
||||
),
|
||||
);
|
||||
Ok(Redirect::to("/settings").into_response())
|
||||
}
|
||||
|
||||
// GET /alerts
|
||||
async fn alerts_page(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, Response> {
|
||||
let session = check_auth(&state, &headers)?;
|
||||
let mut ctx = base_context(&state, &session);
|
||||
ctx.insert("active_page", "alerts");
|
||||
|
||||
let alerts = config::load_alerts(&state.data_dir);
|
||||
ctx.insert("alerts", &alerts);
|
||||
|
||||
let mut resp = render(&state.tera, "alerts.html", &ctx)?.into_response();
|
||||
apply_security_headers(&mut resp);
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
// POST /alerts/clear
|
||||
async fn clear_alerts(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, Response> {
|
||||
let session = check_auth(&state, &headers)?;
|
||||
config::clear_alerts(&state.data_dir);
|
||||
add_flash(
|
||||
&state,
|
||||
&session.session_id,
|
||||
"success",
|
||||
"Historique des alertes efface.",
|
||||
);
|
||||
Ok(Redirect::to("/alerts").into_response())
|
||||
}
|
||||
|
||||
// --- Boucle de monitoring (thread separee) ---
|
||||
|
||||
fn start_monitoring(state: Arc<AppState>) {
|
||||
std::thread::spawn(move || {
|
||||
let mut sys = sysinfo::System::new();
|
||||
|
||||
// Premiere mesure CPU (besoin de deux lectures)
|
||||
sys.refresh_cpu_usage();
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
sys.refresh_cpu_usage();
|
||||
sys.refresh_memory();
|
||||
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
|
||||
let disks = sysinfo::Disks::new_with_refreshed_list();
|
||||
|
||||
// Collecte initiale
|
||||
{
|
||||
let cfg = state.config.read().unwrap().clone();
|
||||
let active = state.monitoring_active.load(Ordering::Relaxed);
|
||||
let metrics = monitor::collect_metrics(&sys, &disks, &cfg, active);
|
||||
*state.metrics.write().unwrap() = Some(metrics);
|
||||
}
|
||||
|
||||
let mut last_alerts: HashMap<String, chrono::DateTime<chrono::Local>> = HashMap::new();
|
||||
|
||||
loop {
|
||||
let is_active = state.monitoring_active.load(Ordering::Relaxed);
|
||||
|
||||
if !is_active {
|
||||
if let Ok(mut m) = state.metrics.write() {
|
||||
if let Some(ref mut metrics) = *m {
|
||||
metrics.monitoring_active = false;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rafraichir les metriques systeme
|
||||
sys.refresh_cpu_usage();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
sys.refresh_cpu_usage();
|
||||
sys.refresh_memory();
|
||||
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
|
||||
let disks = sysinfo::Disks::new_with_refreshed_list();
|
||||
|
||||
let cfg = state.config.read().unwrap().clone();
|
||||
let metrics = monitor::collect_metrics(&sys, &disks, &cfg, true);
|
||||
|
||||
// Verification des seuils et envoi d'alertes
|
||||
let pending = monitor::check_thresholds(&metrics, &cfg);
|
||||
let cooldown_mins = cfg.alert_cooldown_minutes as i64;
|
||||
|
||||
for alert_info in &pending {
|
||||
let now = chrono::Local::now();
|
||||
let should_alert = match last_alerts.get(&alert_info.key) {
|
||||
Some(last) => (now - *last).num_minutes() >= cooldown_mins,
|
||||
None => true,
|
||||
};
|
||||
|
||||
if should_alert {
|
||||
let alert = Alert {
|
||||
timestamp: now.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||
alert_type: alert_info.alert_type.clone(),
|
||||
key: alert_info.key.clone(),
|
||||
message: alert_info.message.clone(),
|
||||
value: alert_info.value,
|
||||
threshold: alert_info.threshold,
|
||||
hostname: metrics.hostname.clone(),
|
||||
};
|
||||
|
||||
config::save_alert(&state.data_dir, &alert);
|
||||
|
||||
if alerter::is_configured(&cfg.smtp) {
|
||||
let subject =
|
||||
format!("[ALERTE] {} - {}", metrics.hostname, alert_info.message);
|
||||
let body = alerter::format_alert_body(&alert);
|
||||
if let Err(e) = alerter::send_email(&cfg.smtp, &subject, &body) {
|
||||
eprintln!("[Alerter] Erreur envoi: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
last_alerts.insert(alert_info.key.clone(), now);
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre a jour les metriques partagees
|
||||
*state.metrics.write().unwrap() = Some(metrics);
|
||||
|
||||
// Dormir jusqu'au prochain check (verifier toutes les 5s si on doit s'arreter)
|
||||
let interval = Duration::from_secs(cfg.check_interval_minutes * 60);
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < interval {
|
||||
if !state.monitoring_active.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Point d'entree ---
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let exe_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
// Chercher les templates et static dans le repertoire courant ou celui de l'exe
|
||||
let work_dir = if PathBuf::from("templates").exists() {
|
||||
PathBuf::from(".")
|
||||
} else {
|
||||
exe_dir
|
||||
};
|
||||
|
||||
let data_dir = work_dir.join("data");
|
||||
let config = config::load_config(&data_dir);
|
||||
let port = config.port;
|
||||
|
||||
// Charger les templates Tera
|
||||
let template_path = work_dir.join("templates").join("**").join("*.html");
|
||||
let tera = match Tera::new(template_path.to_str().unwrap()) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("[ERREUR] Chargement des templates: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if config::is_default_password(&config) {
|
||||
println!(
|
||||
"[ATTENTION] Le mot de passe admin est encore 'admin'. Changez-le immediatement !"
|
||||
);
|
||||
}
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
config: RwLock::new(config),
|
||||
metrics: RwLock::new(None),
|
||||
sessions: RwLock::new(HashMap::new()),
|
||||
rate_limiter: RwLock::new(RateLimiter::new()),
|
||||
monitoring_active: AtomicBool::new(true),
|
||||
tera,
|
||||
data_dir,
|
||||
});
|
||||
|
||||
// Demarrer la boucle de monitoring
|
||||
start_monitoring(Arc::clone(&state));
|
||||
println!("[Supervision] Monitoring actif");
|
||||
|
||||
// Toutes les routes
|
||||
let static_dir = work_dir.join("static");
|
||||
let app = Router::new()
|
||||
.route("/login", get(login_page).post(login_action))
|
||||
.route("/", get(dashboard))
|
||||
.route("/logout", get(logout))
|
||||
.route("/api/metrics", get(api_metrics))
|
||||
.route("/api/monitoring/toggle", post(toggle_monitoring))
|
||||
.route("/settings", get(settings_page))
|
||||
.route("/settings/thresholds", post(update_thresholds))
|
||||
.route("/settings/monitoring", post(update_monitoring))
|
||||
.route("/settings/smtp", post(update_smtp))
|
||||
.route("/settings/smtp/test", post(test_smtp))
|
||||
.route("/settings/processes", post(update_processes))
|
||||
.route("/settings/password", post(update_password))
|
||||
.route("/settings/port", post(update_port))
|
||||
.route("/alerts", get(alerts_page))
|
||||
.route("/alerts/clear", post(clear_alerts))
|
||||
.nest_service("/static", ServeDir::new(static_dir))
|
||||
.with_state(state);
|
||||
|
||||
// Demarrage du serveur
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
println!("[Supervision] Demarrage sur le port {}", port);
|
||||
println!("[Supervision] Interface : http://localhost:{}", port);
|
||||
|
||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||
Ok(l) => l,
|
||||
Err(_) => {
|
||||
eprintln!("[ERREUR] Le port {} est deja utilise.", port);
|
||||
eprintln!("Modifiez le port dans data/config.json ou liberez le port.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
344
src/monitor.rs
Normal file
344
src/monitor.rs
Normal file
@@ -0,0 +1,344 @@
|
||||
use crate::config::{Config, ProcessConfig};
|
||||
use serde::Serialize;
|
||||
use sysinfo::{Disks, System};
|
||||
|
||||
// --- Structures de metriques ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Metrics {
|
||||
pub timestamp: String,
|
||||
pub hostname: String,
|
||||
pub os: String,
|
||||
pub cpu: CpuMetrics,
|
||||
pub ram: RamMetrics,
|
||||
pub disks: Vec<DiskMetrics>,
|
||||
pub processes: Vec<ProcessMetrics>,
|
||||
pub uptime: String,
|
||||
pub boot_time: String,
|
||||
pub monitoring_active: bool,
|
||||
pub last_check: String,
|
||||
pub next_check: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CpuMetrics {
|
||||
pub percent: f32,
|
||||
pub cores: usize,
|
||||
pub threshold: u32,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RamMetrics {
|
||||
pub percent: f64,
|
||||
pub total_gb: f64,
|
||||
pub used_gb: f64,
|
||||
pub available_gb: f64,
|
||||
pub threshold: u32,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DiskMetrics {
|
||||
pub drive: String,
|
||||
pub mountpoint: String,
|
||||
pub percent: f64,
|
||||
pub total_gb: f64,
|
||||
pub used_gb: f64,
|
||||
pub free_gb: f64,
|
||||
pub threshold: u32,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProcessMetrics {
|
||||
pub name: String,
|
||||
pub pattern: String,
|
||||
pub running: bool,
|
||||
pub enabled: bool,
|
||||
pub alert_on_down: bool,
|
||||
pub instance_count: usize,
|
||||
pub total_memory_mb: f64,
|
||||
pub total_cpu_percent: f32,
|
||||
pub memory_threshold_mb: u64,
|
||||
pub memory_status: String,
|
||||
pub pids: Vec<u32>,
|
||||
}
|
||||
|
||||
// Info d'alerte retournee par check_thresholds
|
||||
pub struct AlertInfo {
|
||||
pub key: String,
|
||||
pub alert_type: String,
|
||||
pub message: String,
|
||||
pub value: f64,
|
||||
pub threshold: f64,
|
||||
}
|
||||
|
||||
// --- Collecte des metriques ---
|
||||
|
||||
pub fn collect_metrics(sys: &System, disks: &Disks, config: &Config, monitoring_active: bool) -> Metrics {
|
||||
let now = chrono::Local::now();
|
||||
let interval_mins = config.check_interval_minutes;
|
||||
|
||||
// CPU
|
||||
let cpu_percent = sys.global_cpu_usage();
|
||||
let cpu_status = eval_status(cpu_percent as f64, config.thresholds.cpu_percent as f64);
|
||||
|
||||
// RAM
|
||||
let total_mem = sys.total_memory() as f64;
|
||||
let used_mem = sys.used_memory() as f64;
|
||||
let available_mem = sys.available_memory() as f64;
|
||||
let ram_percent = if total_mem > 0.0 {
|
||||
(used_mem / total_mem) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let ram_status = eval_status(ram_percent, config.thresholds.ram_percent as f64);
|
||||
|
||||
// Disques
|
||||
let gb = 1024.0 * 1024.0 * 1024.0;
|
||||
let ignored_fs = ["squashfs", "tmpfs", "devtmpfs", "overlay", "iso9660"];
|
||||
let disk_metrics: Vec<DiskMetrics> = disks
|
||||
.list()
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
let fs = d.file_system().to_string_lossy().to_string();
|
||||
let name = d.name().to_string_lossy().to_string();
|
||||
!ignored_fs.contains(&fs.as_str())
|
||||
&& !name.starts_with("/dev/loop")
|
||||
&& d.total_space() >= (1024 * 1024 * 1024) // >= 1 Go
|
||||
})
|
||||
.filter_map(|d| {
|
||||
let total = d.total_space() as f64;
|
||||
let available = d.available_space() as f64;
|
||||
let used = total - available;
|
||||
let percent = if total > 0.0 {
|
||||
(used / total) * 100.0
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let status = eval_status(percent, config.thresholds.disk_percent as f64);
|
||||
Some(DiskMetrics {
|
||||
drive: d.name().to_string_lossy().to_string(),
|
||||
mountpoint: d.mount_point().to_string_lossy().to_string(),
|
||||
percent: round1(percent),
|
||||
total_gb: round1(total / gb),
|
||||
used_gb: round1(used / gb),
|
||||
free_gb: round1(available / gb),
|
||||
threshold: config.thresholds.disk_percent,
|
||||
status,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Processus surveilles
|
||||
let process_metrics = check_processes(sys, &config.processes);
|
||||
|
||||
// Uptime
|
||||
let boot_secs = System::boot_time();
|
||||
let boot_time = chrono::DateTime::from_timestamp(boot_secs as i64, 0)
|
||||
.unwrap_or_default()
|
||||
.with_timezone(&chrono::Local);
|
||||
let uptime_secs = System::uptime();
|
||||
let uptime_str = format_duration(uptime_secs);
|
||||
|
||||
Metrics {
|
||||
timestamp: now.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||
hostname: System::host_name().unwrap_or_else(|| "inconnu".into()),
|
||||
os: format!(
|
||||
"{} {}",
|
||||
System::name().unwrap_or_default(),
|
||||
System::os_version().unwrap_or_default()
|
||||
),
|
||||
cpu: CpuMetrics {
|
||||
percent: (cpu_percent * 10.0).round() / 10.0,
|
||||
cores: sys.cpus().len(),
|
||||
threshold: config.thresholds.cpu_percent,
|
||||
status: cpu_status,
|
||||
},
|
||||
ram: RamMetrics {
|
||||
percent: round1(ram_percent),
|
||||
total_gb: round1(total_mem / gb),
|
||||
used_gb: round1(used_mem / gb),
|
||||
available_gb: round1(available_mem / gb),
|
||||
threshold: config.thresholds.ram_percent,
|
||||
status: ram_status,
|
||||
},
|
||||
disks: disk_metrics,
|
||||
processes: process_metrics,
|
||||
uptime: uptime_str,
|
||||
boot_time: boot_time.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||
monitoring_active,
|
||||
last_check: now.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||
next_check: (now + chrono::Duration::minutes(interval_mins as i64))
|
||||
.format("%Y-%m-%dT%H:%M:%S")
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_processes(sys: &System, process_configs: &[ProcessConfig]) -> Vec<ProcessMetrics> {
|
||||
process_configs
|
||||
.iter()
|
||||
.map(|cfg| {
|
||||
let pattern = cfg.pattern.to_lowercase();
|
||||
let mut found_pids = Vec::new();
|
||||
let mut total_mem: f64 = 0.0;
|
||||
let mut total_cpu: f32 = 0.0;
|
||||
|
||||
if cfg.enabled {
|
||||
for (pid, proc) in sys.processes() {
|
||||
let pname = proc.name().to_string_lossy().to_lowercase();
|
||||
let cmdline = proc
|
||||
.cmd()
|
||||
.iter()
|
||||
.map(|s| s.to_string_lossy().to_lowercase())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
if pname.contains(&pattern) || cmdline.contains(&pattern) {
|
||||
let mem_mb = proc.memory() as f64 / (1024.0 * 1024.0);
|
||||
total_mem += mem_mb;
|
||||
total_cpu += proc.cpu_usage();
|
||||
found_pids.push(pid.as_u32());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let running = !found_pids.is_empty();
|
||||
let mem_status = if cfg.memory_threshold_mb > 0 && total_mem > 0.0 {
|
||||
eval_status(total_mem, cfg.memory_threshold_mb as f64)
|
||||
} else {
|
||||
"ok".into()
|
||||
};
|
||||
|
||||
ProcessMetrics {
|
||||
name: cfg.name.clone(),
|
||||
pattern: cfg.pattern.clone(),
|
||||
running,
|
||||
enabled: cfg.enabled,
|
||||
alert_on_down: cfg.alert_on_down,
|
||||
instance_count: found_pids.len(),
|
||||
total_memory_mb: round1(total_mem),
|
||||
total_cpu_percent: (total_cpu * 10.0).round() / 10.0,
|
||||
memory_threshold_mb: cfg.memory_threshold_mb,
|
||||
memory_status: mem_status,
|
||||
pids: found_pids,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// --- Verification des seuils ---
|
||||
|
||||
pub fn check_thresholds(metrics: &Metrics, _config: &Config) -> Vec<AlertInfo> {
|
||||
let mut alerts = Vec::new();
|
||||
|
||||
// CPU
|
||||
if metrics.cpu.status == "critical" {
|
||||
alerts.push(AlertInfo {
|
||||
key: "cpu".into(),
|
||||
alert_type: "threshold".into(),
|
||||
message: format!(
|
||||
"CPU a {}% (seuil: {}%)",
|
||||
metrics.cpu.percent, metrics.cpu.threshold
|
||||
),
|
||||
value: metrics.cpu.percent as f64,
|
||||
threshold: metrics.cpu.threshold as f64,
|
||||
});
|
||||
}
|
||||
|
||||
// RAM
|
||||
if metrics.ram.status == "critical" {
|
||||
alerts.push(AlertInfo {
|
||||
key: "ram".into(),
|
||||
alert_type: "threshold".into(),
|
||||
message: format!(
|
||||
"RAM a {}% (seuil: {}%)",
|
||||
metrics.ram.percent, metrics.ram.threshold
|
||||
),
|
||||
value: metrics.ram.percent,
|
||||
threshold: metrics.ram.threshold as f64,
|
||||
});
|
||||
}
|
||||
|
||||
// Disques
|
||||
for disk in &metrics.disks {
|
||||
if disk.status == "critical" {
|
||||
alerts.push(AlertInfo {
|
||||
key: format!("disk_{}", disk.drive),
|
||||
alert_type: "threshold".into(),
|
||||
message: format!(
|
||||
"Disque {} a {}% (seuil: {}%)",
|
||||
disk.drive, disk.percent, disk.threshold
|
||||
),
|
||||
value: disk.percent,
|
||||
threshold: disk.threshold as f64,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Processus
|
||||
for proc in &metrics.processes {
|
||||
if !proc.enabled {
|
||||
continue;
|
||||
}
|
||||
if proc.alert_on_down && !proc.running {
|
||||
alerts.push(AlertInfo {
|
||||
key: format!("process_down_{}", proc.name),
|
||||
alert_type: "process_down".into(),
|
||||
message: format!(
|
||||
"Processus '{}' non detecte (pattern: {})",
|
||||
proc.name, proc.pattern
|
||||
),
|
||||
value: 0.0,
|
||||
threshold: 0.0,
|
||||
});
|
||||
}
|
||||
if proc.memory_threshold_mb > 0 && proc.memory_status == "critical" {
|
||||
alerts.push(AlertInfo {
|
||||
key: format!("process_mem_{}", proc.name),
|
||||
alert_type: "threshold".into(),
|
||||
message: format!(
|
||||
"Processus '{}' utilise {} Mo (seuil: {} Mo)",
|
||||
proc.name, proc.total_memory_mb, proc.memory_threshold_mb
|
||||
),
|
||||
value: proc.total_memory_mb,
|
||||
threshold: proc.memory_threshold_mb as f64,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
alerts
|
||||
}
|
||||
|
||||
// --- Utilitaires ---
|
||||
|
||||
fn eval_status(value: f64, threshold: f64) -> String {
|
||||
if threshold <= 0.0 {
|
||||
return "ok".into();
|
||||
}
|
||||
let ratio = value / threshold;
|
||||
if ratio >= 1.0 {
|
||||
"critical".into()
|
||||
} else if ratio >= 0.80 {
|
||||
"warning".into()
|
||||
} else {
|
||||
"ok".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn round1(v: f64) -> f64 {
|
||||
(v * 10.0).round() / 10.0
|
||||
}
|
||||
|
||||
fn format_duration(secs: u64) -> String {
|
||||
let days = secs / 86400;
|
||||
let hours = (secs % 86400) / 3600;
|
||||
let mins = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
if days > 0 {
|
||||
format!("{} jour(s), {:02}:{:02}:{:02}", days, hours, mins, s)
|
||||
} else {
|
||||
format!("{:02}:{:02}:{:02}", hours, mins, s)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Supervision - Alertes{% endblock %}
|
||||
{% block title %}Supervision - Alertes{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0"><i class="bi bi-bell"></i> Historique des alertes</h4>
|
||||
{% if alerts %}
|
||||
<form method="POST" action="{{ url_for('clear_alerts') }}">
|
||||
<form method="POST" action="/alerts/clear">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Effacer tout l\'historique ?')">
|
||||
<i class="bi bi-trash"></i> Effacer l'historique
|
||||
@@ -35,10 +35,10 @@
|
||||
{% for alert in alerts %}
|
||||
<tr>
|
||||
<td class="text-nowrap">
|
||||
<small>{{ alert.timestamp[:19] | replace('T', ' ') }}</small>
|
||||
<small>{{ alert.timestamp | truncate(length=19, end="") | replace(from="T", to=" ") }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if alert.type == 'process_down' %}
|
||||
{% if alert.type == "process_down" %}
|
||||
<span class="badge bg-danger">Processus</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Seuil</span>
|
||||
@@ -57,4 +57,4 @@
|
||||
{{ alerts | length }} alerte(s) — les 500 dernieres sont conservees.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endblock content %}
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Supervision{% endblock %}</title>
|
||||
<title>{% block title %}Supervision{% endblock title %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='style.css') }}" rel="stylesheet">
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
{% if current_user.is_authenticated %}
|
||||
{% if authenticated %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('dashboard') }}">
|
||||
<a class="navbar-brand" href="/">
|
||||
<i class="bi bi-activity"></i> Supervision
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
@@ -21,33 +21,27 @@
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}"
|
||||
href="{{ url_for('dashboard') }}">
|
||||
<a class="nav-link {% if active_page == "dashboard" %}active{% endif %}"
|
||||
href="/">
|
||||
<i class="bi bi-speedometer2"></i> Tableau de bord
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'settings' %}active{% endif %}"
|
||||
href="{{ url_for('settings') }}">
|
||||
<a class="nav-link {% if active_page == "settings" %}active{% endif %}"
|
||||
href="/settings">
|
||||
<i class="bi bi-gear"></i> Configuration
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'alerts' %}active{% endif %}"
|
||||
href="{{ url_for('alerts') }}">
|
||||
<a class="nav-link {% if active_page == "alerts" %}active{% endif %}"
|
||||
href="/alerts">
|
||||
<i class="bi bi-bell"></i> Alertes
|
||||
</a>
|
||||
</li>
|
||||
<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 class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('logout') }}">
|
||||
<a class="nav-link" href="/logout">
|
||||
<i class="bi bi-box-arrow-right"></i> Deconnexion
|
||||
</a>
|
||||
</li>
|
||||
@@ -58,29 +52,27 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
{% if flash_messages %}
|
||||
{% for msg in flash_messages %}
|
||||
<div class="alert alert-{{ msg.category }} alert-dismissible fade show" role="alert">
|
||||
{{ msg.message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if default_pw is defined and default_pw %}
|
||||
{% if default_pw %}
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<strong>Securite :</strong> Le mot de passe par defaut est encore actif.
|
||||
<a href="{{ url_for('settings') }}#password" class="alert-link">Changez-le maintenant</a>.
|
||||
<a href="/settings#password" class="alert-link">Changez-le maintenant</a>.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
{% block content %}{% endblock content %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
{% block scripts %}{% endblock scripts %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Supervision - Tableau de bord{% endblock %}
|
||||
{% block title %}Supervision - Tableau de bord{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -11,7 +11,7 @@
|
||||
</h4>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span id="last-update" class="text-muted small"></span>
|
||||
<form method="POST" action="{{ url_for('toggle_monitoring') }}" class="d-inline">
|
||||
<form method="POST" action="/api/monitoring/toggle" class="d-inline">
|
||||
{% if metrics and metrics.monitoring_active %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning">
|
||||
<i class="bi bi-pause-circle"></i> Pause
|
||||
@@ -86,7 +86,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Placeholder pour les disques - sera rempli par JS aussi -->
|
||||
<!-- Disques -->
|
||||
{% for disk in metrics.disks %}
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card metric-card" id="card-disk-{{ loop.index0 }}">
|
||||
@@ -154,7 +154,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ proc.total_cpu_percent }}%</td>
|
||||
<td><small>{{ proc.pids | join(', ') }}</small></td>
|
||||
<td><small>{{ proc.pids | join(sep=", ") }}</small></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -170,7 +170,7 @@
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<h6 class="mb-0"><i class="bi bi-bell"></i> Alertes recentes</h6>
|
||||
<a href="{{ url_for('alerts') }}" class="btn btn-sm btn-outline-primary">Voir tout</a>
|
||||
<a href="/alerts" class="btn btn-sm btn-outline-primary">Voir tout</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0" id="recent-alerts">
|
||||
@@ -187,7 +187,7 @@
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
@@ -237,16 +237,7 @@ function refreshMetrics() {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function loadRecentAlerts() {
|
||||
fetch('/api/metrics')
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
// Charger les alertes separement via la page
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Rafraichir toutes les 30 secondes
|
||||
setInterval(refreshMetrics, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock scripts %}
|
||||
|
||||
@@ -23,20 +23,18 @@
|
||||
<small class="text-secondary">Monitoring systeme</small>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
{% if flash_messages %}
|
||||
{% for msg in flash_messages %}
|
||||
<div class="alert alert-{{ msg.category }} alert-dismissible fade show" role="alert">
|
||||
{{ msg.message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" action="{{ url_for('login') }}">
|
||||
<form method="POST" action="/login">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Identifiant</label>
|
||||
<div class="input-group">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Supervision - Configuration{% endblock %}
|
||||
{% block title %}Supervision - Configuration{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<h4 class="mb-3"><i class="bi bi-gear"></i> Configuration</h4>
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="card">
|
||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-sliders"></i> Seuils d'alerte (%)</h6></div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('update_thresholds') }}">
|
||||
<form method="POST" action="/settings/thresholds">
|
||||
<div class="mb-3">
|
||||
<label for="cpu_percent" class="form-label">CPU (%)</label>
|
||||
<input type="number" class="form-control" id="cpu_percent" name="cpu_percent"
|
||||
@@ -39,7 +39,7 @@
|
||||
<div class="card">
|
||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-clock"></i> Frequence et alertes</h6></div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('update_monitoring') }}">
|
||||
<form method="POST" action="/settings/monitoring">
|
||||
<div class="mb-3">
|
||||
<label for="check_interval_minutes" class="form-label">Intervalle de verification (minutes)</label>
|
||||
<input type="number" class="form-control" id="check_interval_minutes"
|
||||
@@ -64,7 +64,7 @@
|
||||
<div class="card mt-4">
|
||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-diagram-3"></i> Port de l'application</h6></div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('update_port') }}">
|
||||
<form method="POST" action="/settings/port">
|
||||
<div class="mb-3">
|
||||
<label for="port" class="form-label">Port (redemarrage requis)</label>
|
||||
<input type="number" class="form-control" id="port" name="port"
|
||||
@@ -85,7 +85,7 @@
|
||||
<div class="card">
|
||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-envelope"></i> Configuration SMTP</h6></div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('update_smtp') }}">
|
||||
<form method="POST" action="/settings/smtp">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="smtp_server" class="form-label">Serveur SMTP</label>
|
||||
@@ -128,21 +128,10 @@
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="smtp_to" class="form-label">Destinataires (separes par des virgules)</label>
|
||||
<input type="text" class="form-control" id="smtp_to" name="smtp_to"
|
||||
value="{{ smtp.to_emails | join(', ') }}"
|
||||
value="{{ smtp.to_emails | join(sep=", ") }}"
|
||||
placeholder="admin@example.com, tech@example.com">
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Enregistrer
|
||||
@@ -150,7 +139,7 @@
|
||||
</div>
|
||||
</form>
|
||||
<hr>
|
||||
<form method="POST" action="{{ url_for('test_smtp') }}" class="d-inline">
|
||||
<form method="POST" action="/settings/smtp/test" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-info">
|
||||
<i class="bi bi-send"></i> Envoyer un email de test
|
||||
</button>
|
||||
@@ -168,7 +157,7 @@
|
||||
<h6 class="mb-0"><i class="bi bi-list-task"></i> Processus surveilles</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('update_processes') }}" id="process-form">
|
||||
<form method="POST" action="/settings/processes" id="process-form">
|
||||
<table class="table" id="proc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -242,7 +231,7 @@
|
||||
<h6 class="mb-0"><i class="bi bi-shield-lock"></i> Mot de passe administrateur</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('update_password') }}">
|
||||
<form method="POST" action="/settings/password">
|
||||
<div class="mb-3">
|
||||
<label for="current_password" class="form-label">Mot de passe actuel</label>
|
||||
<input type="password" class="form-control" id="current_password"
|
||||
@@ -268,63 +257,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 < (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 < (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 %}
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
@@ -352,4 +285,4 @@ document.addEventListener('click', function(e) {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock scripts %}
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
{% 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 — ' + (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 %}
|
||||
@@ -1,110 +0,0 @@
|
||||
"""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
313
user_monitor.py
@@ -1,313 +0,0 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user