Compare commits
15 Commits
v1.0.0
...
c3999d5215
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3999d5215 | ||
|
|
29df58331a | ||
|
|
f2f41c47ae | ||
|
|
537a3d6f55 | ||
|
|
a9b505c73c | ||
|
|
65edffbbc1 | ||
|
|
b3e91bb0e3 | ||
|
|
8887d97f90 | ||
|
|
1abe0f2657 | ||
|
|
7abe46a6c4 | ||
|
|
5354c9983d | ||
|
|
52ef8143a6 | ||
|
|
891bb7ab9a | ||
|
|
5f9a71da10 | ||
|
|
90c5c154a7 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -7,6 +7,11 @@ data/alerts.json
|
||||
*.log
|
||||
.env
|
||||
imput/
|
||||
logTest/
|
||||
log/
|
||||
CLAUDE.md
|
||||
docs/
|
||||
.claude/
|
||||
*.spec
|
||||
build/
|
||||
dist/
|
||||
|
||||
53
README.md
53
README.md
@@ -9,12 +9,15 @@ Envoie des alertes email lorsque les seuils configures sont depasses.
|
||||
## 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
|
||||
@@ -138,10 +141,24 @@ supervision\
|
||||
├── templates\ # Pages HTML de l'interface
|
||||
├── static\ # CSS
|
||||
└── data\ # (cree au 1er lancement)
|
||||
├── config.json # Configuration (seuils, SMTP, processus)
|
||||
├── config.json # Configuration (seuils, SMTP, processus, logs Amadea)
|
||||
└── 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.
|
||||
|
||||
---
|
||||
@@ -156,6 +173,9 @@ 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
|
||||
|
||||
@@ -169,6 +189,35 @@ Tous les parametres sont modifiables depuis l'interface web.
|
||||
|
||||
---
|
||||
|
||||
## Suivi des utilisateurs Amadea
|
||||
|
||||
L'onglet **Utilisateurs** affiche en temps reel les utilisateurs connectes a Amadea Web 8 x64.
|
||||
|
||||
### Fichiers de logs lus
|
||||
|
||||
| Fichier | Role |
|
||||
|---------|------|
|
||||
| `awevents_JJ-MM-AA_N.txt` | Source principale : actions utilisateurs, deconnexions explicites |
|
||||
| `isoft_JJ-MM-AA_N.txt` | Complement : evenements de session |
|
||||
|
||||
Seuls les fichiers du jour courant sont lus (les fichiers des jours precedents zipes sont ignores pour le tableau temps reel). Le graphique "7 derniers jours" utilise les fichiers `.txt` non zipes s'ils sont disponibles.
|
||||
|
||||
### 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 `awevents_*.txt` et `isoft_*.txt`.
|
||||
|
||||
Valeur par defaut : `C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs`
|
||||
|
||||
---
|
||||
|
||||
## Depannage
|
||||
|
||||
| Probleme | Solution |
|
||||
@@ -178,3 +227,5 @@ Tous les parametres sont modifiables depuis l'interface web.
|
||||
| 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 |
|
||||
|
||||
71
app.py
71
app.py
@@ -20,6 +20,7 @@ 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()
|
||||
@@ -53,6 +54,7 @@ login_manager.login_message = "Veuillez vous connecter."
|
||||
# Services
|
||||
alerter = EmailAlerter(config)
|
||||
monitor = SystemMonitor(config, alerter)
|
||||
user_monitor = UserMonitor(config)
|
||||
|
||||
|
||||
class AdminUser(UserMixin):
|
||||
@@ -312,6 +314,72 @@ 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("/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:
|
||||
@@ -341,6 +409,9 @@ 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)
|
||||
|
||||
@@ -55,6 +55,11 @@ def get_default_config():
|
||||
"from_email": "",
|
||||
"to_emails": [],
|
||||
},
|
||||
"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",
|
||||
"password_hash": generate_password_hash("admin"),
|
||||
|
||||
1002
docs/superpowers/plans/2026-04-02-users-tab.md
Normal file
1002
docs/superpowers/plans/2026-04-02-users-tab.md
Normal file
File diff suppressed because it is too large
Load Diff
3276
docs/superpowers/plans/2026-04-07-supervision-rust.md
Normal file
3276
docs/superpowers/plans/2026-04-07-supervision-rust.md
Normal file
File diff suppressed because it is too large
Load Diff
188
docs/superpowers/specs/2026-04-02-users-tab-design.md
Normal file
188
docs/superpowers/specs/2026-04-02-users-tab-design.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Design — Onglet Utilisateurs Amadea
|
||||
|
||||
**Date :** 2026-04-02
|
||||
**Statut :** Approuvé
|
||||
|
||||
---
|
||||
|
||||
## Contexte
|
||||
|
||||
Ajouter un onglet "Utilisateurs" à l'application de supervision, affichant en temps réel les utilisateurs connectés à Amadea Web 8 x64, leur statut d'activité, et un graphique d'utilisation horaire. Le chemin des logs Amadea et les seuils de statut sont rendus configurables dans l'onglet Configuration existant.
|
||||
|
||||
---
|
||||
|
||||
## Architecture générale
|
||||
|
||||
### Nouveaux fichiers
|
||||
|
||||
- **`user_monitor.py`** — classe `UserMonitor` : thread de fond, parsing des logs, cache thread-safe. Miroir exact de `SystemMonitor`.
|
||||
|
||||
### Fichiers modifiés
|
||||
|
||||
| Fichier | Modification |
|
||||
|---|---|
|
||||
| `config_manager.py` | Ajout des clés `amadea_log_path` et `user_status_thresholds` dans la config par défaut |
|
||||
| `app.py` | Instanciation de `UserMonitor`, routes `/users` et `/api/users` |
|
||||
| `templates/base.html` | Lien "Utilisateurs" dans la navbar |
|
||||
| `templates/settings.html` | 2 nouveaux blocs de configuration |
|
||||
| `templates/users.html` | Nouvelle page (tableau + graphique CSS) |
|
||||
|
||||
### Flux de données
|
||||
|
||||
```
|
||||
UserMonitor (background thread)
|
||||
├─ parse awevents_YY-MM-DD_*.log → activité utilisateur + déconnexions explicites
|
||||
└─ parse isoft_YY-MM-DD_*.log → événements de session (login/timeout)
|
||||
↓ cache thread-safe (dict par login)
|
||||
/api/users → JSON
|
||||
/users → rendu Jinja2 initial + auto-refresh JS (30s, même pattern que dashboard)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parsing et modèle de données
|
||||
|
||||
### Sélection des fichiers du jour
|
||||
|
||||
Les fichiers de logs suivent le pattern `PREFIX_YY-MM-DD_N.ext` (ex: `awevents_26-04-02_1.log`).
|
||||
|
||||
- Date du jour formatée en `%y-%m-%d` (ex: `26-04-02` pour 2026-04-02)
|
||||
- Tous les fichiers du jour sont lus, triés par index `N` croissant
|
||||
- Si plusieurs fichiers du même jour existent (index incrémental), ils sont tous parsés dans l'ordre
|
||||
|
||||
### Format `awevents_YY-MM-DD_N.log` (source principale)
|
||||
|
||||
```
|
||||
2026-03-30 10:34:24.034;INFO ;;;;"login=JENKINS,action=SelectionChange,Label=BAO_Main/..."
|
||||
```
|
||||
|
||||
Regex d'extraction :
|
||||
```python
|
||||
r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*login=([^,]+),action=([^,]+),Label=(.+)"?$'
|
||||
```
|
||||
|
||||
- `timestamp` → groupe 1
|
||||
- `login` → groupe 2 (identifiant utilisateur)
|
||||
- `action` → groupe 3 (SelectionChange, Action, Click, ValueChange…)
|
||||
- `label` → groupe 4 (contexte UI)
|
||||
|
||||
**Déconnexion explicite :** ligne dont le label contient `se deconnecter`
|
||||
|
||||
### Format `isoft_YY-MM-DD_N.log` (complément sessions)
|
||||
|
||||
```
|
||||
2026-03-30 10:33:05.830;INFO ;"ISExecutingThread...";...;"method=OpenUserSession,...,login=JENKINS"
|
||||
```
|
||||
|
||||
Regex pour login réussi :
|
||||
```python
|
||||
r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*method=OpenUserSession.*login=([A-Za-z0-9_]+)'
|
||||
```
|
||||
|
||||
Regex pour timeout de session :
|
||||
```python
|
||||
r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*method=closeSession'
|
||||
```
|
||||
|
||||
### Modèle par utilisateur (cache)
|
||||
|
||||
```python
|
||||
{
|
||||
"login": str, # identifiant (ex: "JENKINS")
|
||||
"last_action_time": datetime, # horodatage de la dernière action
|
||||
"last_action_label": str, # label de la dernière action (tronqué à 60 chars)
|
||||
"action_count_24h": int, # nombre d'actions dans les dernières 24h
|
||||
"status": str, # "actif" | "inactif" | "deconnecte"
|
||||
"explicit_logout": bool, # True si déconnexion explicite détectée
|
||||
"connected_since": datetime | None, # heure de la première action du jour (premier awevents du jour pour cet utilisateur)
|
||||
}
|
||||
```
|
||||
|
||||
### Règles de statut
|
||||
|
||||
Seuils configurables (clé `user_status_thresholds`, défauts : `active_minutes=5`, `inactive_minutes=30`) :
|
||||
|
||||
| Condition | Statut |
|
||||
|---|---|
|
||||
| Déconnexion explicite détectée | DÉCONNECTÉ |
|
||||
| Dernière action > `inactive_minutes` | DÉCONNECTÉ |
|
||||
| Dernière action entre `active_minutes` et `inactive_minutes` | INACTIF |
|
||||
| Dernière action < `active_minutes` | ACTIF |
|
||||
|
||||
### Graphique d'activité horaire
|
||||
|
||||
Comptage du nombre d'utilisateurs distincts ayant au moins une action par tranche horaire (H:00 → H:59). Données issues uniquement des fichiers du jour (`awevents_*.log`).
|
||||
|
||||
Pour le sélecteur "7 derniers jours" : si les fichiers zippés ne sont pas disponibles (cas nominal en prod), afficher un `alert-info` : *"Les données historiques des jours précédents ne sont pas disponibles (fichiers archivés)."*
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Nouvelles clés dans `config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"amadea_log_path": "C:\\ProgramData\\ISoft\\Amadea Web 8 x64\\data\\logs",
|
||||
"user_status_thresholds": {
|
||||
"active_minutes": 5,
|
||||
"inactive_minutes": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Nouveaux blocs dans `settings.html`
|
||||
|
||||
**Bloc 1 — Chemin des logs Amadea** (même style `card` + `form-control` + `btn btn-primary`) :
|
||||
- Champ texte pré-rempli avec la valeur actuelle
|
||||
- Bouton "Enregistrer"
|
||||
- Route POST : `/settings/amadea-log-path`
|
||||
|
||||
**Bloc 2 — Seuils statut utilisateurs** (même style que le bloc "Seuils d'alerte") :
|
||||
- Champ numérique "Actif si dernière action < N min" (défaut: 5)
|
||||
- Champ numérique "Inactif si dernière action < N min" (défaut: 30)
|
||||
- Bouton "Enregistrer"
|
||||
- Route POST : `/settings/user-thresholds`
|
||||
|
||||
---
|
||||
|
||||
## Interface utilisateurs (`users.html`)
|
||||
|
||||
### Tableau
|
||||
|
||||
Colonnes : **Utilisateur | Statut | Dernière action | Actions (24h) | Depuis**
|
||||
|
||||
Tri par défaut : Actifs → Inactifs → Déconnectés (ordre de priorité statut).
|
||||
|
||||
Badges Bootstrap cohérents avec l'existant :
|
||||
- ACTIF → `<span class="badge bg-success">ACTIF</span>`
|
||||
- INACTIF → `<span class="badge bg-warning text-dark">INACTIF</span>`
|
||||
- DÉCONNECTÉ → `<span class="badge bg-secondary">DÉCONNECTÉ</span>`
|
||||
|
||||
### Graphique d'activité
|
||||
|
||||
Barres CSS Bootstrap (divs avec hauteur proportionnelle), aucune librairie externe.
|
||||
Une barre par heure de la journée (00h–23h), largeur fixe, hauteur = `(valeur / max) * 100%`.
|
||||
Couleur : `bg-primary`. Tooltip au survol (attribut `title`).
|
||||
|
||||
### Auto-refresh
|
||||
|
||||
`setInterval` toutes les 30 secondes, appel `fetch('/api/users')`, même pattern que `refreshMetrics()` dans `dashboard.html`.
|
||||
|
||||
### Gestion d'erreurs
|
||||
|
||||
| Cas | Affichage |
|
||||
|---|---|
|
||||
| Dossier de logs introuvable | `alert alert-warning` avec le chemin configuré |
|
||||
| Aucun fichier du jour trouvé | `alert alert-info "Aucun log disponible pour aujourd'hui"` |
|
||||
| Fichier verrouillé/illisible | Ignoré silencieusement, parsing continue |
|
||||
| Aucun utilisateur détecté | Message `text-muted` dans le tableau |
|
||||
|
||||
---
|
||||
|
||||
## Choix techniques
|
||||
|
||||
- **Librairie graphique** : barres CSS Bootstrap pures (aucune dépendance externe)
|
||||
- **Thread** : daemon thread (comme `SystemMonitor`), s'arrête avec l'application
|
||||
- **Encodage fichiers** : `utf-8` avec `errors='ignore'` pour tolérer les caractères invalides
|
||||
- **Performance** : les fichiers `isoft_*.log` peuvent être très volumineux (index > 80). Le parsing lit ligne par ligne sans charger le fichier en mémoire (`for line in f`)
|
||||
151
docs/superpowers/specs/2026-04-07-supervision-rust-design.md
Normal file
151
docs/superpowers/specs/2026-04-07-supervision-rust-design.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Design — supervision-rs
|
||||
|
||||
**Date :** 2026-04-07
|
||||
**Objectif :** Réécrire l'application de supervision système en Rust pour produire un exécutable Windows standalone, sans dépendances, déployable par simple copie.
|
||||
|
||||
---
|
||||
|
||||
## Contexte
|
||||
|
||||
L'application actuelle est en Python (Flask + psutil + PyInstaller). Le tuteur a recommandé une réécriture en Rust pour obtenir un exécutable plus stable sur Windows. Le binaire doit être autonome : on le copie sur n'importe quel serveur Windows, on l'installe comme service, et c'est tout.
|
||||
|
||||
---
|
||||
|
||||
## Périmètre fonctionnel
|
||||
|
||||
Parité complète avec l'app Python actuelle :
|
||||
|
||||
- Interface web sécurisée (login admin, cookie de session)
|
||||
- Dashboard temps réel : CPU, RAM, disques, processus surveillés
|
||||
- Alertes email (SMTP plain ou STARTTLS) avec cooldown configurable
|
||||
- Configuration via UI (seuils, SMTP, intervalle, processus)
|
||||
- Historique des alertes (max 500, rotation automatique)
|
||||
- Fonctionne comme service Windows (démarre avec Windows, tourne en arrière-plan)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Structure du projet
|
||||
|
||||
```
|
||||
supervision-rs/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── main.rs # Point d'entrée + intégration service Windows
|
||||
│ ├── config.rs # Lecture/écriture config.json et alerts.json
|
||||
│ ├── monitor.rs # Thread de collecte métriques + évaluation seuils
|
||||
│ ├── alerter.rs # Envoi email SMTP
|
||||
│ ├── auth.rs # Session admin, rate limiting login
|
||||
│ └── routes.rs # Endpoints Axum (dashboard, API, config, login)
|
||||
├── templates/ # Templates Tera (portés depuis Jinja2)
|
||||
│ ├── base.html
|
||||
│ ├── dashboard.html
|
||||
│ ├── login.html
|
||||
│ ├── config.html
|
||||
│ └── alerts.html
|
||||
└── data/ # Créé au premier lancement (gitignored)
|
||||
├── config.json
|
||||
└── alerts.json
|
||||
```
|
||||
|
||||
### Crates
|
||||
|
||||
| Crate | Rôle | Équivalent Python |
|
||||
|---|---|---|
|
||||
| `axum` | Serveur HTTP async | Flask |
|
||||
| `tokio` | Runtime async | — |
|
||||
| `tera` | Templates HTML | Jinja2 |
|
||||
| `sysinfo` | Métriques CPU/RAM/disque/processus | psutil |
|
||||
| `lettre` | Email SMTP | smtplib |
|
||||
| `serde` + `serde_json` | Sérialisation config/alertes | json |
|
||||
| `windows-service` | Intégration service Windows | — |
|
||||
| `tower-sessions` | Sessions auth (cookie signé) | Flask-Login |
|
||||
| `tower_governor` | Rate limiting | Flask-Limiter |
|
||||
| `tracing` | Logs | logging |
|
||||
|
||||
---
|
||||
|
||||
## Modes de démarrage
|
||||
|
||||
```
|
||||
supervision.exe → mode console (test, développement)
|
||||
supervision.exe install → installe le service Windows
|
||||
supervision.exe uninstall → supprime le service Windows
|
||||
(lancé par Windows SCM) → mode service (background automatique)
|
||||
```
|
||||
|
||||
Détection automatique dans `main.rs` : si lancé par le Service Control Manager de Windows, entre en mode service. Sinon, mode console.
|
||||
|
||||
**Installation sur un serveur :**
|
||||
```cmd
|
||||
supervision.exe install
|
||||
sc start Supervision
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State partagé (concurrence)
|
||||
|
||||
```
|
||||
Arc<AppState>
|
||||
├── config: RwLock<Config> # Config lue/écrite par routes + monitor
|
||||
├── metrics: RwLock<Metrics> # Écrit par monitor, lu par /api/metrics
|
||||
└── alerter: Alerter # Utilisé par monitor
|
||||
```
|
||||
|
||||
Le thread de monitoring est une `tokio::task` qui tourne en arrière-plan. Il met à jour `metrics` via `RwLock`. Les routes Axum lisent ce state sans bloquer.
|
||||
|
||||
---
|
||||
|
||||
## Authentification
|
||||
|
||||
- Un seul admin, identifiants stockés dans `config.json` (mot de passe hashé bcrypt)
|
||||
- Session via cookie signé (`tower-sessions`)
|
||||
- Rate limiting sur `POST /login` : 10 tentatives/minute
|
||||
- Toutes les routes (sauf `/login`, `/static`) redirigent vers login si non authentifié
|
||||
|
||||
---
|
||||
|
||||
## Métriques & seuils
|
||||
|
||||
Collecte via `sysinfo` :
|
||||
- CPU : pourcentage global
|
||||
- RAM : pourcentage utilisé, total/utilisé/disponible en Go
|
||||
- Disques : partitions physiques ≥1 Go, pourcentage, espace total/utilisé/libre
|
||||
- Processus : recherche par pattern dans nom ou ligne de commande, mémoire RSS, CPU
|
||||
|
||||
Niveaux de statut (identique au Python) :
|
||||
- `ok` : < 80% du seuil
|
||||
- `warning` : ≥ 80% du seuil
|
||||
- `critical` : ≥ 100% du seuil
|
||||
|
||||
---
|
||||
|
||||
## Alertes email
|
||||
|
||||
- SMTP plain ou STARTTLS (configurable)
|
||||
- Cooldown par clé (en mémoire, réinitialisé au redémarrage)
|
||||
- Alertes déclenchées sur : CPU critique, RAM critique, disque critique, processus arrêté, mémoire processus critique
|
||||
- Stockage dans `alerts.json` (max 500 entrées, rotation FIFO)
|
||||
|
||||
---
|
||||
|
||||
## Persistance
|
||||
|
||||
- `data/config.json` : configuration complète (seuils, SMTP, admin, processus surveillés, intervalle)
|
||||
- `data/alerts.json` : historique des alertes
|
||||
- Dossier `data/` créé automatiquement au premier lancement, dans le même répertoire que l'exe
|
||||
- Pas de base de données
|
||||
|
||||
---
|
||||
|
||||
## Déploiement
|
||||
|
||||
1. Compiler sur Windows : `cargo build --release`
|
||||
2. Récupérer `target/release/supervision.exe`
|
||||
3. Copier l'exe seul sur le serveur cible
|
||||
4. Lancer `supervision.exe install` puis `sc start Supervision`
|
||||
5. Accéder à `http://localhost:5000` dans le navigateur
|
||||
|
||||
Aucune autre dépendance requise sur le serveur cible.
|
||||
@@ -4,3 +4,4 @@ flask-limiter==3.9.*
|
||||
psutil==6.1.*
|
||||
werkzeug==3.1.*
|
||||
pyinstaller==6.12.*
|
||||
pytest==8.3.*
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
<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">
|
||||
|
||||
@@ -257,6 +257,62 @@
|
||||
</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 %}
|
||||
|
||||
{% block scripts %}
|
||||
|
||||
300
templates/users.html
Normal file
300
templates/users.html
Normal file
@@ -0,0 +1,300 @@
|
||||
{% 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-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"><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>
|
||||
<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';
|
||||
|
||||
/* --- 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) {
|
||||
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 = '';
|
||||
|
||||
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)';
|
||||
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 allNull = weekly.every(function(d) { return d.count === null; });
|
||||
if (allNull) {
|
||||
container.style.display = 'none';
|
||||
labelsEl.style.display = 'none';
|
||||
document.getElementById('chart-yaxis').textContent = '';
|
||||
unavail.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
container.style.display = 'flex';
|
||||
labelsEl.style.display = 'flex';
|
||||
unavail.classList.add('d-none');
|
||||
renderChart(weekly.map(function(d) {
|
||||
return { date: d.date, count: d.count === null ? 0 : d.count };
|
||||
}));
|
||||
}
|
||||
|
||||
/* --- Tableau --- */
|
||||
|
||||
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');
|
||||
|
||||
/* Utilisateur */
|
||||
var tdUser = document.createElement('td');
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = u.login || '';
|
||||
tdUser.appendChild(strong);
|
||||
tr.appendChild(tdUser);
|
||||
|
||||
/* Statut */
|
||||
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);
|
||||
|
||||
/* Derniere action */
|
||||
var tdAction = document.createElement('td');
|
||||
tdAction.textContent = u.last_action_time || '\u2014';
|
||||
if (u.last_action_label) {
|
||||
var br = document.createElement('br');
|
||||
var small = document.createElement('small');
|
||||
small.className = 'text-muted';
|
||||
small.textContent = u.last_action_label;
|
||||
tdAction.appendChild(br);
|
||||
tdAction.appendChild(small);
|
||||
}
|
||||
tr.appendChild(tdAction);
|
||||
|
||||
/* Actions 24h */
|
||||
var tdCount = document.createElement('td');
|
||||
tdCount.textContent = String(u.action_count_24h || 0);
|
||||
tr.appendChild(tdCount);
|
||||
|
||||
/* Depuis */
|
||||
var tdSince = document.createElement('td');
|
||||
tdSince.textContent = u.connected_since || '\u2014';
|
||||
tr.appendChild(tdSince);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
/* --- 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);
|
||||
renderTable([]);
|
||||
renderChart([]);
|
||||
return;
|
||||
}
|
||||
if (data.no_files) {
|
||||
showAlert('info', "Aucun log disponible pour aujourd'hui.");
|
||||
renderTable([]);
|
||||
renderChart([]);
|
||||
return;
|
||||
}
|
||||
clearAlert();
|
||||
renderTable(data.users || []);
|
||||
currentHourly = data.hourly || [];
|
||||
if (currentPeriod === 'today') {
|
||||
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;
|
||||
if (currentPeriod === 'today') {
|
||||
document.getElementById('chart-container').style.display = 'flex';
|
||||
document.getElementById('chart-labels').style.display = 'flex';
|
||||
document.getElementById('chart-unavailable').classList.add('d-none');
|
||||
renderChart(currentHourly);
|
||||
} else {
|
||||
fetch('/api/users/activity/weekly')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) { renderWeekly(data.weekly || []); })
|
||||
.catch(function() {});
|
||||
}
|
||||
});
|
||||
|
||||
refreshUsers();
|
||||
setInterval(refreshUsers, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
110
tests/test_user_monitor.py
Normal file
110
tests/test_user_monitor.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Tests unitaires pour user_monitor.py"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import pytest
|
||||
from user_monitor import UserMonitor
|
||||
|
||||
|
||||
class FakeConfig:
|
||||
def get(self, key, default=None):
|
||||
return {
|
||||
"amadea_log_path": "/nonexistent",
|
||||
"user_status_thresholds": {"active_minutes": 5, "inactive_minutes": 30},
|
||||
"check_interval_minutes": 1,
|
||||
}.get(key, default)
|
||||
|
||||
|
||||
def make_monitor():
|
||||
return UserMonitor(FakeConfig())
|
||||
|
||||
|
||||
# --- Parsing awevents ---
|
||||
|
||||
def test_parse_awevents_line_basic():
|
||||
monitor = make_monitor()
|
||||
users, hourly = {}, {h: set() for h in range(24)}
|
||||
cutoff = datetime(2026, 3, 30, 0, 0, 0)
|
||||
line = '2026-03-30 10:34:24.034;INFO ;;;;"login=JENKINS,action=SelectionChange,Label=BAO_Main/MenuPrincipal"\n'
|
||||
monitor._parse_awevents_line(line, users, cutoff, hourly)
|
||||
assert "JENKINS" in users
|
||||
assert users["JENKINS"]["last_action_time"] == datetime(2026, 3, 30, 10, 34, 24)
|
||||
assert users["JENKINS"]["action_count_24h"] == 1
|
||||
assert users["JENKINS"]["explicit_logout"] is False
|
||||
assert hourly[10] == {"JENKINS"}
|
||||
|
||||
|
||||
def test_parse_awevents_line_explicit_logout():
|
||||
monitor = make_monitor()
|
||||
users, hourly = {}, {h: set() for h in range(24)}
|
||||
cutoff = datetime(2026, 3, 30, 0, 0, 0)
|
||||
line = '2026-03-30 11:34:00.500;INFO ;;;;"login=MB,action=Action,Label=Main/se deconnecter"\n'
|
||||
monitor._parse_awevents_line(line, users, cutoff, hourly)
|
||||
assert users["MB"]["explicit_logout"] is True
|
||||
assert users["MB"]["logout_time"] == datetime(2026, 3, 30, 11, 34, 0)
|
||||
|
||||
|
||||
def test_parse_awevents_line_reconnect_after_logout():
|
||||
monitor = make_monitor()
|
||||
users, hourly = {}, {h: set() for h in range(24)}
|
||||
cutoff = datetime(2026, 3, 30, 0, 0, 0)
|
||||
logout_line = '2026-03-30 11:34:00.500;INFO ;;;;"login=MB,action=Action,Label=Main/se deconnecter"\n'
|
||||
reconnect_line = '2026-03-30 11:34:19.594;INFO ;;;;"login=MB,action=Action,Label=Main/OuvrirCTRL 1/Table"\n'
|
||||
monitor._parse_awevents_line(logout_line, users, cutoff, hourly)
|
||||
assert users["MB"]["explicit_logout"] is True
|
||||
monitor._parse_awevents_line(reconnect_line, users, cutoff, hourly)
|
||||
assert users["MB"]["explicit_logout"] is False
|
||||
|
||||
|
||||
def test_parse_awevents_line_invalid_ignored():
|
||||
monitor = make_monitor()
|
||||
users, hourly = {}, {h: set() for h in range(24)}
|
||||
cutoff = datetime(2026, 3, 30, 0, 0, 0)
|
||||
monitor._parse_awevents_line("ligne invalide sans format attendu\n", users, cutoff, hourly)
|
||||
assert users == {}
|
||||
|
||||
|
||||
def test_parse_awevents_action_count_outside_24h():
|
||||
monitor = make_monitor()
|
||||
users, hourly = {}, {h: set() for h in range(24)}
|
||||
cutoff = datetime(2026, 3, 30, 12, 0, 0)
|
||||
old_line = '2026-03-30 08:00:00.000;INFO ;;;;"login=JENKINS,action=Click,Label=Main/Page"\n'
|
||||
monitor._parse_awevents_line(old_line, users, cutoff, hourly)
|
||||
assert users["JENKINS"]["action_count_24h"] == 0
|
||||
|
||||
|
||||
# --- Calcul de statut ---
|
||||
|
||||
def test_compute_statuses_actif():
|
||||
monitor = make_monitor()
|
||||
now = datetime(2026, 3, 30, 12, 0, 0)
|
||||
thresholds = {"active_minutes": 5, "inactive_minutes": 30}
|
||||
users = {"JENKINS": {"last_action_time": now - timedelta(minutes=2), "explicit_logout": False}}
|
||||
monitor._compute_statuses(users, thresholds, now)
|
||||
assert users["JENKINS"]["status"] == "actif"
|
||||
|
||||
|
||||
def test_compute_statuses_inactif():
|
||||
monitor = make_monitor()
|
||||
now = datetime(2026, 3, 30, 12, 0, 0)
|
||||
thresholds = {"active_minutes": 5, "inactive_minutes": 30}
|
||||
users = {"MB": {"last_action_time": now - timedelta(minutes=15), "explicit_logout": False}}
|
||||
monitor._compute_statuses(users, thresholds, now)
|
||||
assert users["MB"]["status"] == "inactif"
|
||||
|
||||
|
||||
def test_compute_statuses_deconnecte_timeout():
|
||||
monitor = make_monitor()
|
||||
now = datetime(2026, 3, 30, 12, 0, 0)
|
||||
thresholds = {"active_minutes": 5, "inactive_minutes": 30}
|
||||
users = {"KO": {"last_action_time": now - timedelta(minutes=45), "explicit_logout": False}}
|
||||
monitor._compute_statuses(users, thresholds, now)
|
||||
assert users["KO"]["status"] == "deconnecte"
|
||||
|
||||
|
||||
def test_compute_statuses_deconnecte_explicit():
|
||||
monitor = make_monitor()
|
||||
now = datetime(2026, 3, 30, 12, 0, 0)
|
||||
thresholds = {"active_minutes": 5, "inactive_minutes": 30}
|
||||
users = {"MB": {"last_action_time": now - timedelta(minutes=2), "explicit_logout": True}}
|
||||
monitor._compute_statuses(users, thresholds, now)
|
||||
assert users["MB"]["status"] == "deconnecte"
|
||||
232
user_monitor.py
Normal file
232
user_monitor.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Suivi des utilisateurs connectes a Amadea via parsing des logs."""
|
||||
|
||||
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 _log_files_for_date(log_path, prefix, date_str):
|
||||
"""Retourne les fichiers de logs pour un prefixe et une date donnes, tries par index."""
|
||||
pattern = os.path.join(log_path, f"{prefix}_{date_str}_*")
|
||||
files = [f for f in glob.glob(pattern) if not f.endswith('.zip')]
|
||||
return sorted(files, key=lambda f: int(re.search(r'_(\d+)\.[^.]+$', f).group(1)))
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
||||
for line in f:
|
||||
self._parse_awevents_line(line, users, cutoff_24h, hourly)
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
|
||||
isoft_files = _log_files_for_date(log_path, "isoft", date_str)
|
||||
for filepath in isoft_files:
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
||||
for line in f:
|
||||
self._parse_isoft_line(line, users)
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
|
||||
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))
|
||||
)
|
||||
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_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:
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
||||
for line in f:
|
||||
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)
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
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