From 61d17968a07d871ffc927899b80ede82b0e0e58b Mon Sep 17 00:00:00 2001 From: Dom Date: Thu, 26 Mar 2026 09:48:35 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20init=20projet=20supervision=20=E2=80=94?= =?UTF-8?q?=20monitoring=20systeme=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interface web Flask securisee pour surveiller CPU, RAM, disques et processus (JVM, Nginx, Amadea Web 8 x64). Alertes email SMTP configurables, seuils reglables, compilation PyInstaller en .exe, installation service Windows via NSSM. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 12 ++ README.md | 180 ++++++++++++++++++++ alerter.py | 81 +++++++++ app.py | 350 +++++++++++++++++++++++++++++++++++++++ build.bat | 27 +++ config_manager.py | 126 ++++++++++++++ install_service.bat | 41 +++++ monitor.py | 291 ++++++++++++++++++++++++++++++++ requirements.txt | 6 + run.bat | 33 ++++ run.sh | 18 ++ static/style.css | 48 ++++++ templates/alerts.html | 60 +++++++ templates/base.html | 80 +++++++++ templates/dashboard.html | 252 ++++++++++++++++++++++++++++ templates/login.html | 65 ++++++++ templates/settings.html | 288 ++++++++++++++++++++++++++++++++ 17 files changed, 1958 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 alerter.py create mode 100644 app.py create mode 100644 build.bat create mode 100644 config_manager.py create mode 100644 install_service.bat create mode 100644 monitor.py create mode 100644 requirements.txt create mode 100644 run.bat create mode 100644 run.sh create mode 100644 static/style.css create mode 100644 templates/alerts.html create mode 100644 templates/base.html create mode 100644 templates/dashboard.html create mode 100644 templates/login.html create mode 100644 templates/settings.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..000e99a --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.pyc +.venv/ +venv/ +data/config.json +data/alerts.json +*.log +.env +imput/ +*.spec +build/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb65ed9 --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# Supervision — Monitoring Systeme Windows + +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. + +--- + +## Fonctionnalites + +- **Dashboard temps reel** : CPU, RAM, disques, processus surveilles (rafraichissement auto) +- **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 + - Port de l'application + - Mot de passe administrateur +- **Securite** : authentification par login/mot de passe, rate limiting anti-bruteforce, en-tetes HTTP securises +- **Historique des alertes** consultable dans l'interface + +--- + +## Installation rapide (executable) + +### Pre-requis + +- Windows 10/11 ou Windows Server 2016+ +- Acces administrateur (pour le firewall et l'installation en service) + +### Etapes + +1. **Dezipper** `supervision_portable.zip` dans un dossier, par exemple : + ``` + C:\supervision\ + ``` + +2. **Lancer** l'executable : + ``` + C:\supervision\supervision.exe + ``` + +3. **Ouvrir le navigateur** a l'adresse : + ``` + http://localhost:5000 + ``` + +4. **Se connecter** avec les identifiants par defaut : + - Identifiant : `admin` + - Mot de passe : `admin` + +5. **Changer le mot de passe** immediatement dans Configuration > Mot de passe administrateur + +6. **Configurer le SMTP** dans Configuration > Configuration SMTP, puis cliquer sur "Envoyer un email de test" pour valider + +7. **Ajuster les seuils** si necessaire (valeurs par defaut : CPU 90%, RAM 85%, Disque 90%) + +--- + +## Acces distant + +Pour acceder a l'interface depuis une autre machine : + +1. **Ouvrir le port dans le firewall Windows** (PowerShell en administrateur) : + ```powershell + New-NetFirewallRule -DisplayName "Supervision Monitoring" -Direction Inbound -LocalPort 5000 -Protocol TCP -Action Allow + ``` + +2. Acceder via : `http://:5000` + +> **Recommandation** : pour un acces depuis internet, placer l'application derriere un reverse proxy (IIS, Nginx) avec HTTPS. + +--- + +## Installation en tant que service Windows + +Pour que Supervision demarre automatiquement avec Windows, utiliser [NSSM](https://nssm.cc/) : + +1. Telecharger NSSM et le placer dans le PATH + +2. Executer en administrateur : + ```cmd + nssm install Supervision "C:\supervision\supervision.exe" + nssm set Supervision AppDirectory "C:\supervision" + nssm set Supervision Description "Monitoring systeme - Supervision" + nssm set Supervision Start SERVICE_AUTO_START + nssm start Supervision + ``` + +3. Gestion du service : + ```cmd + nssm start Supervision + nssm stop Supervision + nssm restart Supervision + nssm remove Supervision confirm + ``` + +--- + +## Installation depuis les sources (developpement) + +### Pre-requis + +- Python 3.10 ou superieur + +### Etapes + +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 +``` + +L'executable est genere dans `dist\supervision\`. + +--- + +## Structure des fichiers + +``` +supervision\ +├── supervision.exe # Executable principal +├── _internal\ # Dependances Python embarquees +├── templates\ # Pages HTML de l'interface +├── static\ # CSS +└── data\ # (cree au 1er lancement) + ├── config.json # Configuration (seuils, SMTP, processus) + └── alerts.json # Historique des alertes +``` + +> **Important** : ne pas supprimer le dossier `_internal\`, il est necessaire au fonctionnement. + +--- + +## Configuration par defaut + +| Parametre | Valeur par defaut | +|--------------------------|-------------------| +| Port | 5000 | +| Intervalle de check | 1 minute | +| Cooldown entre alertes | 30 minutes | +| Seuil CPU | 90% | +| Seuil RAM | 85% | +| Seuil Disque | 90% | + +### Processus surveilles par defaut + +| Nom | Pattern de recherche | Alerte si arrete | +|--------------------|----------------------|------------------| +| JVM | java | Oui | +| Nginx | nginx | Non (desactive) | +| Amadea Web 8 x64 | amadea | Oui | + +Tous les parametres sont modifiables depuis l'interface web. + +--- + +## Depannage + +| Probleme | Solution | +|----------|----------| +| "Le port 5000 est deja utilise" | Changer le port dans `data\config.json` ou fermer le programme qui occupe le port | +| 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` | diff --git a/alerter.py b/alerter.py new file mode 100644 index 0000000..d18a5dd --- /dev/null +++ b/alerter.py @@ -0,0 +1,81 @@ +"""Envoi d'alertes par email via SMTP.""" + +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + + +class EmailAlerter: + def __init__(self, config_manager): + self.config = config_manager + + def _get_smtp_config(self): + return self.config.get("smtp", {}) + + def is_configured(self): + smtp = self._get_smtp_config() + 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 SMTP non configure.""" + if not self.is_configured(): + return False, "SMTP non configure" + return self._send_email(subject, body) + + def send_test(self): + """Envoie un email de test pour valider la configuration SMTP.""" + if not self.is_configured(): + 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 SMTP est correcte.\n\n" + "-- Supervision" + ) + return self._send_email(subject, body) + + def _send_email(self, subject, body): + smtp_cfg = self._get_smtp_config() + try: + msg = MIMEMultipart() + msg["From"] = smtp_cfg["from_email"] + msg["To"] = ", ".join(smtp_cfg["to_emails"]) + msg["Subject"] = subject + msg.attach(MIMEText(body, "plain", "utf-8")) + + server_addr = smtp_cfg["server"] + port = int(smtp_cfg.get("port", 587)) + use_tls = smtp_cfg.get("use_tls", True) + + if use_tls: + server = smtplib.SMTP(server_addr, port, timeout=15) + server.ehlo() + server.starttls() + server.ehlo() + else: + server = smtplib.SMTP(server_addr, port, timeout=15) + server.ehlo() + + username = smtp_cfg.get("username", "") + password = smtp_cfg.get("password", "") + if username and password: + server.login(username, password) + + server.sendmail( + smtp_cfg["from_email"], + smtp_cfg["to_emails"], + msg.as_string(), + ) + server.quit() + return True, "Email envoye avec succes" + + except smtplib.SMTPAuthenticationError: + return False, "Erreur d'authentification SMTP (identifiants incorrects)" + except smtplib.SMTPConnectError: + return False, f"Impossible de se connecter au serveur SMTP {server_addr}:{port}" + except smtplib.SMTPRecipientsRefused: + return False, "Destinataire(s) refuse(s) par le serveur" + except TimeoutError: + return False, f"Timeout de connexion vers {server_addr}:{port}" + except Exception as e: + return False, f"Erreur SMTP: {str(e)}" diff --git a/app.py b/app.py new file mode 100644 index 0000000..2fbbee9 --- /dev/null +++ b/app.py @@ -0,0 +1,350 @@ +"""Supervision — Monitoring systeme avec interface web.""" + +import socket +import sys +from datetime import timedelta +from functools import wraps + +from flask import ( + Flask, render_template, request, redirect, url_for, + flash, jsonify, session, +) +from flask_login import ( + LoginManager, UserMixin, login_user, logout_user, + login_required, current_user, +) +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from werkzeug.security import check_password_hash, generate_password_hash + +from config_manager import ConfigManager +from monitor import SystemMonitor +from alerter import EmailAlerter + +# --- Init --- +config = ConfigManager() +app = Flask(__name__) +app.secret_key = config.get("secret_key") +app.permanent_session_lifetime = timedelta(hours=8) + +# En-tetes de securite +@app.after_request +def security_headers(response): + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + return response + +# Rate limiting +limiter = Limiter( + app=app, + key_func=get_remote_address, + default_limits=[], + storage_uri="memory://", +) + +# Flask-Login +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = "login" +login_manager.login_message = "Veuillez vous connecter." + +# Services +alerter = EmailAlerter(config) +monitor = SystemMonitor(config, alerter) + + +class AdminUser(UserMixin): + def __init__(self, username): + self.id = username + + +@login_manager.user_loader +def load_user(user_id): + admin = config.get("admin", {}) + if user_id == admin.get("username"): + return AdminUser(user_id) + return None + + +def is_default_password(): + admin = config.get("admin", {}) + return check_password_hash(admin.get("password_hash", ""), "admin") + + +# --- Routes --- + +@app.route("/login", methods=["GET", "POST"]) +@limiter.limit("10 per minute") +def login(): + if current_user.is_authenticated: + return redirect(url_for("dashboard")) + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + admin = config.get("admin", {}) + if username == admin.get("username") and check_password_hash( + admin.get("password_hash", ""), password + ): + user = AdminUser(username) + login_user(user, remember=True) + session.permanent = True + next_page = request.args.get("next") + return redirect(next_page or url_for("dashboard")) + flash("Identifiants incorrects.", "danger") + return render_template("login.html") + + +@app.route("/logout") +@login_required +def logout(): + logout_user() + return redirect(url_for("login")) + + +@app.route("/") +@login_required +def dashboard(): + metrics = monitor.metrics + default_pw = is_default_password() + return render_template("dashboard.html", metrics=metrics, default_pw=default_pw) + + +@app.route("/api/metrics") +@login_required +def api_metrics(): + return jsonify(monitor.metrics) + + +@app.route("/settings", methods=["GET"]) +@login_required +def settings(): + cfg = config.config + smtp = cfg.get("smtp", {}) + # Masquer le mot de passe SMTP dans l'affichage + smtp_display = dict(smtp) + if smtp_display.get("password"): + smtp_display["password_masked"] = "*" * 8 + else: + smtp_display["password_masked"] = "" + return render_template( + "settings.html", + config=cfg, + smtp=smtp_display, + default_pw=is_default_password(), + ) + + +@app.route("/settings/thresholds", methods=["POST"]) +@login_required +def update_thresholds(): + try: + thresholds = { + "cpu_percent": int(request.form["cpu_percent"]), + "ram_percent": int(request.form["ram_percent"]), + "disk_percent": int(request.form["disk_percent"]), + } + # Validation: seuils entre 1 et 100 + for key, val in thresholds.items(): + if not 1 <= val <= 100: + flash(f"Le seuil {key} doit etre entre 1 et 100.", "danger") + return redirect(url_for("settings")) + config.set("thresholds", thresholds) + flash("Seuils mis a jour.", "success") + except (ValueError, KeyError) as e: + flash(f"Erreur de validation: {e}", "danger") + return redirect(url_for("settings")) + + +@app.route("/settings/monitoring", methods=["POST"]) +@login_required +def update_monitoring(): + try: + interval = int(request.form["check_interval_minutes"]) + cooldown = int(request.form["alert_cooldown_minutes"]) + if interval < 1: + flash("L'intervalle doit etre d'au moins 1 minute.", "danger") + return redirect(url_for("settings")) + if cooldown < 1: + flash("Le cooldown doit etre d'au moins 1 minute.", "danger") + return redirect(url_for("settings")) + config.set("check_interval_minutes", interval) + config.set("alert_cooldown_minutes", cooldown) + flash("Parametres de monitoring mis a jour.", "success") + except (ValueError, KeyError) as e: + flash(f"Erreur: {e}", "danger") + return redirect(url_for("settings")) + + +@app.route("/settings/smtp", methods=["POST"]) +@login_required +def update_smtp(): + try: + smtp = { + "server": request.form["smtp_server"].strip(), + "port": int(request.form["smtp_port"]), + "use_tls": "smtp_tls" in request.form, + "username": request.form["smtp_username"].strip(), + "from_email": request.form["smtp_from"].strip(), + "to_emails": [ + e.strip() for e in request.form["smtp_to"].split(",") if e.strip() + ], + } + # Ne mettre a jour le mot de passe que s'il est fourni + new_password = request.form.get("smtp_password", "") + 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") + except (ValueError, KeyError) as e: + flash(f"Erreur: {e}", "danger") + return redirect(url_for("settings")) + + +@app.route("/settings/smtp/test", methods=["POST"]) +@login_required +def test_smtp(): + success, message = alerter.send_test() + if success: + flash(f"Test reussi : {message}", "success") + else: + flash(f"Test echoue : {message}", "danger") + return redirect(url_for("settings")) + + +@app.route("/settings/processes", methods=["POST"]) +@login_required +def update_processes(): + try: + processes = [] + names = request.form.getlist("proc_name[]") + patterns = request.form.getlist("proc_pattern[]") + mem_thresholds = request.form.getlist("proc_mem_threshold[]") + enableds = request.form.getlist("proc_enabled[]") + alert_downs = request.form.getlist("proc_alert_down[]") + + for i in range(len(names)): + if not names[i].strip(): + continue + processes.append({ + "name": names[i].strip(), + "pattern": patterns[i].strip().lower() if i < len(patterns) else "", + "memory_threshold_mb": int(mem_thresholds[i]) if i < len(mem_thresholds) and mem_thresholds[i] else 0, + "enabled": str(i) in enableds, + "alert_on_down": str(i) in alert_downs, + }) + config.set("processes", processes) + flash("Processus surveilles mis a jour.", "success") + except (ValueError, KeyError) as e: + flash(f"Erreur: {e}", "danger") + return redirect(url_for("settings")) + + +@app.route("/settings/password", methods=["POST"]) +@login_required +def update_password(): + current_pw = request.form.get("current_password", "") + new_pw = request.form.get("new_password", "") + confirm_pw = request.form.get("confirm_password", "") + + admin = config.get("admin", {}) + if not check_password_hash(admin.get("password_hash", ""), current_pw): + flash("Mot de passe actuel incorrect.", "danger") + return redirect(url_for("settings")) + if len(new_pw) < 8: + flash("Le nouveau mot de passe doit faire au moins 8 caracteres.", "danger") + return redirect(url_for("settings")) + if new_pw != confirm_pw: + flash("Les mots de passe ne correspondent pas.", "danger") + return redirect(url_for("settings")) + + admin["password_hash"] = generate_password_hash(new_pw) + config.set("admin", admin) + flash("Mot de passe mis a jour.", "success") + return redirect(url_for("settings")) + + +@app.route("/settings/port", methods=["POST"]) +@login_required +def update_port(): + try: + port = int(request.form["port"]) + if not 1024 <= port <= 65535: + flash("Le port doit etre entre 1024 et 65535.", "danger") + return redirect(url_for("settings")) + config.set("port", port) + flash(f"Port mis a jour a {port}. Redemarrez l'application pour appliquer.", "warning") + except (ValueError, KeyError): + flash("Port invalide.", "danger") + return redirect(url_for("settings")) + + +@app.route("/alerts") +@login_required +def alerts(): + alert_list = config.load_alerts() + return render_template("alerts.html", alerts=alert_list) + + +@app.route("/alerts/clear", methods=["POST"]) +@login_required +def clear_alerts(): + config.clear_alerts() + flash("Historique des alertes efface.", "success") + return redirect(url_for("alerts")) + + +@app.route("/api/monitoring/toggle", methods=["POST"]) +@login_required +def toggle_monitoring(): + if monitor._running: + monitor.stop() + flash("Monitoring arrete.", "warning") + else: + monitor.start() + flash("Monitoring demarre.", "success") + return redirect(url_for("dashboard")) + + +def check_port_available(port): + """Verifie si un port est disponible.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("0.0.0.0", port)) + return True + except OSError: + return False + + +def main(): + port = config.get("port", 5000) + + if not check_port_available(port): + print(f"[ERREUR] Le port {port} est deja utilise.") + print("Modifiez le port dans data/config.json ou liberez le port.") + sys.exit(1) + + print(f"[Supervision] Demarrage sur le port {port}") + print(f"[Supervision] Interface : http://localhost:{port}") + + if is_default_password(): + print("[ATTENTION] Le mot de passe admin est encore 'admin'. Changez-le immediatement !") + + # Demarrer le monitoring + monitor.start() + # Collecte initiale + monitor.collect_metrics() + + print("[Supervision] Monitoring actif") + + app.run(host="0.0.0.0", port=port, debug=False) + + +if __name__ == "__main__": + main() diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..5d0804a --- /dev/null +++ b/build.bat @@ -0,0 +1,27 @@ +@echo off +title Build Supervision .exe +echo ========================================== +echo Build Supervision en executable +echo ========================================== +echo. + +call .venv\Scripts\activate.bat + +echo [INFO] Compilation avec PyInstaller... +pyinstaller ^ + --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 + +echo. +echo [OK] Executable genere dans dist\supervision\ +echo [INFO] Copiez le dossier dist\supervision\ sur le serveur cible. +echo [INFO] Le dossier data\ sera cree automatiquement au premier lancement. +pause diff --git a/config_manager.py b/config_manager.py new file mode 100644 index 0000000..5dbd7e9 --- /dev/null +++ b/config_manager.py @@ -0,0 +1,126 @@ +"""Gestion de la configuration persistante (JSON).""" + +import json +import os +import secrets +from pathlib import Path +from werkzeug.security import generate_password_hash + +DATA_DIR = Path(__file__).parent / "data" +CONFIG_FILE = DATA_DIR / "config.json" +ALERTS_FILE = DATA_DIR / "alerts.json" +MAX_ALERTS = 500 + + +def get_default_config(): + return { + "secret_key": secrets.token_hex(32), + "port": 5000, + "check_interval_minutes": 1, + "alert_cooldown_minutes": 30, + "thresholds": { + "cpu_percent": 90, + "ram_percent": 85, + "disk_percent": 90, + }, + "processes": [ + { + "name": "JVM", + "pattern": "java", + "memory_threshold_mb": 0, + "enabled": True, + "alert_on_down": True, + }, + { + "name": "Nginx", + "pattern": "nginx", + "memory_threshold_mb": 0, + "enabled": False, + "alert_on_down": False, + }, + { + "name": "Amadea Web 8 x64", + "pattern": "amadea", + "memory_threshold_mb": 0, + "enabled": True, + "alert_on_down": True, + }, + ], + "smtp": { + "server": "", + "port": 587, + "use_tls": True, + "username": "", + "password": "", + "from_email": "", + "to_emails": [], + }, + "admin": { + "username": "admin", + "password_hash": generate_password_hash("admin"), + }, + } + + +class ConfigManager: + def __init__(self): + DATA_DIR.mkdir(exist_ok=True) + self._config = self._load() + + def _load(self): + if CONFIG_FILE.exists(): + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + saved = json.load(f) + # Fusionner avec les valeurs par defaut (nouvelles cles) + default = get_default_config() + for key, val in default.items(): + if key not in saved: + saved[key] = val + return saved + else: + config = get_default_config() + self._save(config) + return config + + def _save(self, config=None): + if config is None: + config = self._config + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + + def reload(self): + self._config = self._load() + + def get(self, key, default=None): + return self._config.get(key, default) + + def set(self, key, value): + self._config[key] = value + self._save() + + def update(self, data: dict): + self._config.update(data) + self._save() + + @property + def config(self): + return self._config + + # --- Alertes persistees --- + + def load_alerts(self): + if ALERTS_FILE.exists(): + with open(ALERTS_FILE, "r", encoding="utf-8") as f: + return json.load(f) + return [] + + def save_alert(self, alert: dict): + alerts = self.load_alerts() + alerts.insert(0, alert) + alerts = alerts[:MAX_ALERTS] + with open(ALERTS_FILE, "w", encoding="utf-8") as f: + json.dump(alerts, f, indent=2, ensure_ascii=False) + + def clear_alerts(self): + with open(ALERTS_FILE, "w", encoding="utf-8") as f: + json.dump([], f) diff --git a/install_service.bat b/install_service.bat new file mode 100644 index 0000000..1f3bbdd --- /dev/null +++ b/install_service.bat @@ -0,0 +1,41 @@ +@echo off +title Installation Service Windows +echo ========================================== +echo Installation de Supervision en service +echo ========================================== +echo. +echo PRE-REQUIS: NSSM (https://nssm.cc/) doit etre dans le PATH +echo. + +REM Verifier les droits admin +net session >nul 2>&1 +if errorlevel 1 ( + echo [ERREUR] Ce script doit etre execute en tant qu'administrateur. + pause + exit /b 1 +) + +set SERVICE_NAME=Supervision +set /p INSTALL_PATH="Chemin du dossier supervision (ex: C:\supervision): " + +echo. +echo [INFO] Installation du service '%SERVICE_NAME%'... +nssm install %SERVICE_NAME% "%INSTALL_PATH%\supervision.exe" +nssm set %SERVICE_NAME% AppDirectory "%INSTALL_PATH%" +nssm set %SERVICE_NAME% Description "Monitoring systeme - Supervision" +nssm set %SERVICE_NAME% Start SERVICE_AUTO_START +nssm set %SERVICE_NAME% AppStdout "%INSTALL_PATH%\logs\stdout.log" +nssm set %SERVICE_NAME% AppStderr "%INSTALL_PATH%\logs\stderr.log" +nssm set %SERVICE_NAME% AppRotateFiles 1 +nssm set %SERVICE_NAME% AppRotateBytes 5000000 + +mkdir "%INSTALL_PATH%\logs" 2>nul + +echo. +echo [OK] Service installe. Demarrage... +nssm start %SERVICE_NAME% + +echo. +echo [INFO] Le service demarre automatiquement avec Windows. +echo [INFO] Gestion: nssm start/stop/restart %SERVICE_NAME% +pause diff --git a/monitor.py b/monitor.py new file mode 100644 index 0000000..53ee8d1 --- /dev/null +++ b/monitor.py @@ -0,0 +1,291 @@ +"""Collecte des metriques systeme et surveillance des seuils.""" + +import platform +import threading +import time +from datetime import datetime, timedelta + +import psutil + + +class SystemMonitor: + def __init__(self, config_manager, alerter): + self.config = config_manager + self.alerter = alerter + self._metrics = {} + self._lock = threading.Lock() + self._running = False + self._thread = None + self._last_alerts = {} # cle -> datetime derniere alerte + + @property + def metrics(self): + with self._lock: + return dict(self._metrics) + + def collect_metrics(self): + """Collecte toutes les metriques systeme.""" + cfg = self.config.config + thresholds = cfg["thresholds"] + + # CPU + cpu_percent = psutil.cpu_percent(interval=1) + cpu_status = self._eval_status(cpu_percent, thresholds["cpu_percent"]) + + # RAM + ram = psutil.virtual_memory() + ram_status = self._eval_status(ram.percent, thresholds["ram_percent"]) + + # Disques + disks = [] + IGNORED_FS = {"squashfs", "tmpfs", "devtmpfs", "overlay", "iso9660"} + for part in psutil.disk_partitions(): + # Ignorer les pseudo-filesystems (loop, snap, tmpfs, etc.) + if part.fstype in IGNORED_FS: + continue + if part.device.startswith("/dev/loop"): + continue + try: + usage = psutil.disk_usage(part.mountpoint) + except (PermissionError, OSError): + continue + # Ignorer les partitions minuscules (< 1 Go) + if usage.total < 1024 ** 3: + continue + disk_status = self._eval_status(usage.percent, thresholds["disk_percent"]) + disks.append({ + "drive": part.device.rstrip("\\"), + "mountpoint": part.mountpoint, + "percent": round(usage.percent, 1), + "total_gb": round(usage.total / (1024 ** 3), 1), + "used_gb": round(usage.used / (1024 ** 3), 1), + "free_gb": round(usage.free / (1024 ** 3), 1), + "threshold": thresholds["disk_percent"], + "status": disk_status, + }) + + # Processus surveilles + processes = self._check_processes(cfg.get("processes", [])) + + # Infos systeme + boot_time = datetime.fromtimestamp(psutil.boot_time()) + uptime = datetime.now() - boot_time + + now = datetime.now() + interval = cfg.get("check_interval_minutes", 1) + + metrics = { + "timestamp": now.isoformat(), + "hostname": platform.node(), + "os": f"{platform.system()} {platform.release()}", + "cpu": { + "percent": cpu_percent, + "cores": psutil.cpu_count(), + "threshold": thresholds["cpu_percent"], + "status": cpu_status, + }, + "ram": { + "percent": round(ram.percent, 1), + "total_gb": round(ram.total / (1024 ** 3), 1), + "used_gb": round(ram.used / (1024 ** 3), 1), + "available_gb": round(ram.available / (1024 ** 3), 1), + "threshold": thresholds["ram_percent"], + "status": ram_status, + }, + "disks": disks, + "processes": processes, + "uptime": str(uptime).split(".")[0], + "boot_time": boot_time.isoformat(), + "monitoring_active": self._running, + "last_check": now.isoformat(), + "next_check": (now + timedelta(minutes=interval)).isoformat(), + } + + with self._lock: + self._metrics = metrics + + return metrics + + def _check_processes(self, process_configs): + """Verifie l'etat des processus surveilles.""" + results = [] + for proc_cfg in process_configs: + pattern = proc_cfg["pattern"].lower() + name = proc_cfg["name"] + enabled = proc_cfg.get("enabled", True) + mem_threshold = proc_cfg.get("memory_threshold_mb", 0) + + found = [] + if enabled: + for proc in psutil.process_iter(["pid", "name", "cmdline", "memory_info", "cpu_percent"]): + try: + pname = (proc.info["name"] or "").lower() + cmdline = " ".join(proc.info["cmdline"] or []).lower() + if pattern in pname or pattern in cmdline: + mem_mb = round(proc.info["memory_info"].rss / (1024 ** 2), 1) if proc.info["memory_info"] else 0 + found.append({ + "pid": proc.info["pid"], + "memory_mb": mem_mb, + "cpu_percent": proc.info["cpu_percent"] or 0, + }) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + + total_memory = sum(p["memory_mb"] for p in found) + total_cpu = sum(p["cpu_percent"] for p in found) + running = len(found) > 0 + + # Statut memoire + mem_status = "ok" + if mem_threshold > 0 and total_memory > 0: + mem_status = self._eval_status(total_memory, mem_threshold, is_mb=True) + + results.append({ + "name": name, + "pattern": proc_cfg["pattern"], + "running": running, + "enabled": enabled, + "alert_on_down": proc_cfg.get("alert_on_down", True), + "instance_count": len(found), + "total_memory_mb": round(total_memory, 1), + "total_cpu_percent": round(total_cpu, 1), + "memory_threshold_mb": mem_threshold, + "memory_status": mem_status, + "pids": [p["pid"] for p in found], + }) + + return results + + def _eval_status(self, value, threshold, is_mb=False): + if is_mb: + ratio = value / threshold if threshold > 0 else 0 + else: + ratio = value / threshold if threshold > 0 else 0 + if ratio >= 1.0: + return "critical" + elif ratio >= 0.80: + return "warning" + return "ok" + + def check_and_alert(self, metrics): + """Verifie les seuils et envoie des alertes si necessaire.""" + cfg = self.config.config + cooldown = cfg.get("alert_cooldown_minutes", 30) + alerts_sent = [] + + # CPU + if metrics["cpu"]["status"] == "critical": + msg = f"CPU a {metrics['cpu']['percent']}% (seuil: {metrics['cpu']['threshold']}%)" + if self._should_alert("cpu", cooldown): + self._send_and_log("cpu", msg, metrics["cpu"]["percent"], metrics["cpu"]["threshold"]) + alerts_sent.append(msg) + + # RAM + if metrics["ram"]["status"] == "critical": + msg = f"RAM a {metrics['ram']['percent']}% (seuil: {metrics['ram']['threshold']}%)" + if self._should_alert("ram", cooldown): + self._send_and_log("ram", msg, metrics["ram"]["percent"], metrics["ram"]["threshold"]) + alerts_sent.append(msg) + + # Disques + for disk in metrics["disks"]: + key = f"disk_{disk['drive']}" + if disk["status"] == "critical": + msg = f"Disque {disk['drive']} a {disk['percent']}% (seuil: {disk['threshold']}%)" + if self._should_alert(key, cooldown): + self._send_and_log(key, msg, disk["percent"], disk["threshold"]) + alerts_sent.append(msg) + + # Processus + for proc in metrics["processes"]: + if not proc["enabled"]: + continue + + # Alerte processus arrete + if proc["alert_on_down"] and not proc["running"]: + key = f"process_down_{proc['name']}" + msg = f"Processus '{proc['name']}' non detecte (pattern: {proc['pattern']})" + if self._should_alert(key, cooldown): + self._send_and_log(key, msg, 0, 0, alert_type="process_down") + alerts_sent.append(msg) + + # Alerte memoire processus + if proc["memory_threshold_mb"] > 0 and proc["memory_status"] == "critical": + key = f"process_mem_{proc['name']}" + msg = ( + f"Processus '{proc['name']}' utilise {proc['total_memory_mb']} Mo " + f"(seuil: {proc['memory_threshold_mb']} Mo)" + ) + if self._should_alert(key, cooldown): + self._send_and_log(key, msg, proc["total_memory_mb"], proc["memory_threshold_mb"]) + alerts_sent.append(msg) + + return alerts_sent + + def _should_alert(self, key, cooldown_minutes): + now = datetime.now() + last = self._last_alerts.get(key) + if last and (now - last) < timedelta(minutes=cooldown_minutes): + return False + return True + + def _send_and_log(self, key, message, value, threshold, alert_type="threshold"): + now = datetime.now() + hostname = self._metrics.get("hostname", platform.node()) + + # Enregistrer l'alerte + alert = { + "timestamp": now.isoformat(), + "type": alert_type, + "key": key, + "message": message, + "value": value, + "threshold": threshold, + "hostname": hostname, + } + self.config.save_alert(alert) + + # Envoyer l'email + subject = f"[ALERTE] {hostname} - {message}" + self.alerter.send_alert(subject, self._format_alert_body(alert)) + + # Mettre a jour le cooldown + self._last_alerts[key] = now + + def _format_alert_body(self, alert): + return ( + f"Alerte de supervision\n" + f"{'=' * 40}\n\n" + f"Serveur : {alert['hostname']}\n" + f"Date : {alert['timestamp']}\n" + f"Type : {alert['type']}\n\n" + f"Message : {alert['message']}\n\n" + f"{'=' * 40}\n" + f"Supervision - Monitoring automatique" + ) + + # --- Thread de monitoring --- + + def start(self): + if self._running: + return + self._running = True + self._thread = threading.Thread(target=self._monitoring_loop, daemon=True) + self._thread.start() + + def stop(self): + self._running = False + + def _monitoring_loop(self): + last_check = 0 + while self._running: + interval = self.config.get("check_interval_minutes", 1) * 60 + elapsed = time.time() - last_check + if elapsed >= interval: + try: + metrics = self.collect_metrics() + self.check_and_alert(metrics) + except Exception as e: + print(f"[Monitoring] Erreur: {e}") + last_check = time.time() + time.sleep(5) # Verifie toutes les 5s si c'est le moment diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1fff652 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +flask==3.1.* +flask-login==0.6.* +flask-limiter==3.9.* +psutil==6.1.* +werkzeug==3.1.* +pyinstaller==6.12.* diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..b074e2a --- /dev/null +++ b/run.bat @@ -0,0 +1,33 @@ +@echo off +title Supervision - Monitoring Systeme +echo ========================================== +echo Supervision - Monitoring Systeme +echo ========================================== +echo. + +REM Verifier si Python est disponible +python --version >nul 2>&1 +if errorlevel 1 ( + echo [ERREUR] Python n'est pas installe ou pas dans le PATH. + pause + exit /b 1 +) + +REM Creer le venv si absent +if not exist ".venv" ( + echo [INFO] Creation de l'environnement virtuel... + python -m venv .venv +) + +REM Activer le venv +call .venv\Scripts\activate.bat + +REM Installer les dependances +echo [INFO] Verification des dependances... +pip install -q -r requirements.txt + +REM Lancer l'application +echo [INFO] Demarrage de Supervision... +python app.py + +pause diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..2c2757b --- /dev/null +++ b/run.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Supervision — Script de demarrage (dev Linux) +set -e + +cd "$(dirname "$0")" + +if [ ! -d ".venv" ]; then + echo "[INFO] Creation de l'environnement virtuel..." + python3 -m venv .venv +fi + +source .venv/bin/activate + +echo "[INFO] Verification des dependances..." +pip install -q -r requirements.txt + +echo "[INFO] Demarrage de Supervision..." +python app.py diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..78104a9 --- /dev/null +++ b/static/style.css @@ -0,0 +1,48 @@ +/* Supervision — Style */ + +body { + background-color: #f4f6f9; + font-size: 0.9rem; +} + +.metric-card { + transition: border-color 0.3s; +} + +.metric-card .metric-value { + font-size: 2.2rem; + font-weight: 700; + line-height: 1.1; +} + +.card { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.badge { + font-size: 0.75rem; + text-transform: uppercase; +} + +.table th { + font-size: 0.8rem; + text-transform: uppercase; + color: #6c757d; + border-bottom-width: 1px; +} + +.navbar-brand i { + color: #4fc3f7; +} + +/* Statut couleurs */ +.border-success { border-left: 4px solid #198754 !important; } +.border-warning { border-left: 4px solid #ffc107 !important; } +.border-danger { border-left: 4px solid #dc3545 !important; } + +/* Responsive */ +@media (max-width: 768px) { + .metric-card .metric-value { + font-size: 1.8rem; + } +} diff --git a/templates/alerts.html b/templates/alerts.html new file mode 100644 index 0000000..fcaef00 --- /dev/null +++ b/templates/alerts.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Supervision - Alertes{% endblock %} + +{% block content %} +
+

Historique des alertes

+ {% if alerts %} +
+ +
+ {% endif %} +
+ +{% if not alerts %} +
+ Aucune alerte enregistree. +
+{% else %} +
+
+ + + + + + + + + + + + {% for alert in alerts %} + + + + + + + + {% endfor %} + +
DateTypeMessageValeurSeuil
+ {{ alert.timestamp[:19] | replace('T', ' ') }} + + {% if alert.type == 'process_down' %} + Processus + {% else %} + Seuil + {% endif %} + {{ alert.message }}{{ alert.value }}{{ alert.threshold }}
+
+
+
+ {{ alerts | length }} alerte(s) — les 500 dernieres sont conservees. +
+{% endif %} +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..e4715cd --- /dev/null +++ b/templates/base.html @@ -0,0 +1,80 @@ + + + + + + {% block title %}Supervision{% endblock %} + + + + + + {% if current_user.is_authenticated %} + + {% endif %} + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% if default_pw is defined and default_pw %} +
+ + Securite : Le mot de passe par defaut est encore actif. + Changez-le maintenant. +
+ {% endif %} + + {% block content %}{% endblock %} +
+ + + {% block scripts %}{% endblock %} + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..e7f466a --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,252 @@ +{% extends "base.html" %} +{% block title %}Supervision - Tableau de bord{% endblock %} + +{% block content %} +
+

+ Tableau de bord + {% if metrics and metrics.hostname %} + — {{ metrics.hostname }} + {% endif %} +

+
+ +
+ {% if metrics and metrics.monitoring_active %} + + {% else %} + + {% endif %} +
+
+
+ +{% if not metrics %} +
+ Collecte des metriques en cours... +
+{% else %} + + +
+
+
+
+ {{ metrics.hostname }} + {{ metrics.os }} + Uptime: {{ metrics.uptime }} + {{ metrics.cpu.cores }} coeurs + {{ metrics.ram.total_gb }} Go RAM +
+
+
+
+ + +
+ +
+
+
+
+
CPU
+ {{ metrics.cpu.status }} +
+
{{ metrics.cpu.percent }}%
+
+
+
+ Seuil: {{ metrics.cpu.threshold }}% +
+
+
+ +
+
+
+
+
RAM
+ {{ metrics.ram.status }} +
+
{{ metrics.ram.percent }}%
+
+
+
+ + {{ metrics.ram.used_gb }} / + {{ metrics.ram.total_gb }} Go + — Seuil: {{ metrics.ram.threshold }}% + +
+
+
+ + {% for disk in metrics.disks %} +
+
+
+
+
{{ disk.drive }}
+ {{ disk.status }} +
+
{{ disk.percent }}%
+
+
+
+ + {{ disk.used_gb }} / {{ disk.total_gb }} Go + ({{ disk.free_gb }} Go libres) + — Seuil: {{ disk.threshold }}% + +
+
+
+ {% endfor %} +
+ + +
+
+
+
+
Processus surveilles
+
+
+ + + + + + + + + + + + + {% for proc in metrics.processes %} + + + + + + + + + {% endfor %} + +
ProcessusStatutInstancesMemoireCPUPID(s)
+ {{ proc.name }} +
pattern: {{ proc.pattern }} +
+ {% if not proc.enabled %} + Desactive + {% elif proc.running %} + Actif + {% else %} + Arrete + {% endif %} + {{ proc.instance_count }} + {{ proc.total_memory_mb }} Mo + {% if proc.memory_threshold_mb > 0 %} +
seuil: {{ proc.memory_threshold_mb }} Mo + {% endif %} +
{{ proc.total_cpu_percent }}%{{ proc.pids | join(', ') }}
+
+
+
+
+ + +
+
+
+
+
Alertes recentes
+ Voir tout +
+
+ + + + +
+
+ Aucune alerte recente. +
+
+
+
+
+ +{% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..b82835c --- /dev/null +++ b/templates/login.html @@ -0,0 +1,65 @@ + + + + + + Supervision - Connexion + + + + + +
+ +
+ + diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..b69990a --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,288 @@ +{% extends "base.html" %} +{% block title %}Supervision - Configuration{% endblock %} + +{% block content %} +

Configuration

+ +
+
+ +
+
Seuils d'alerte (%)
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ +
+ +
+
Frequence et alertes
+
+
+
+ + +
+
+ + +
Delai minimum entre deux alertes du meme type.
+
+ +
+
+
+ + +
+
Port de l'application
+
+
+
+ + +
+ +
+
+
+
+
+ + +
+
+
+
Configuration SMTP
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+ + +
+
+ + +
Laissez vide pour conserver le mot de passe actuel.
+
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+
Processus surveilles
+
+
+
+ + + + + + + + + + + + + {% for proc in config.processes %} + + + + + + + + + {% endfor %} + +
NomPattern (recherche)Seuil memoire (Mo)ActifAlerte si arrete
+ + + + + +
0 = pas de seuil
+
+
+ +
+
+
+ +
+
+ +
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
Mot de passe administrateur
+
+
+
+
+ + +
+
+ + +
Minimum 8 caracteres.
+
+
+ + +
+ +
+
+
+
+
+ +{% endblock %} + +{% block scripts %} + +{% endblock %}