Compare commits
5 Commits
V1.0.3
...
5354c9983d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5354c9983d | ||
|
|
52ef8143a6 | ||
|
|
891bb7ab9a | ||
|
|
5f9a71da10 | ||
|
|
90c5c154a7 |
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 config_manager import ConfigManager
|
||||||
from monitor import SystemMonitor
|
from monitor import SystemMonitor
|
||||||
from alerter import EmailAlerter
|
from alerter import EmailAlerter
|
||||||
|
from user_monitor import UserMonitor
|
||||||
|
|
||||||
# --- Init ---
|
# --- Init ---
|
||||||
config = ConfigManager()
|
config = ConfigManager()
|
||||||
@@ -53,6 +54,7 @@ login_manager.login_message = "Veuillez vous connecter."
|
|||||||
# Services
|
# Services
|
||||||
alerter = EmailAlerter(config)
|
alerter = EmailAlerter(config)
|
||||||
monitor = SystemMonitor(config, alerter)
|
monitor = SystemMonitor(config, alerter)
|
||||||
|
user_monitor = UserMonitor(config)
|
||||||
|
|
||||||
|
|
||||||
class AdminUser(UserMixin):
|
class AdminUser(UserMixin):
|
||||||
@@ -312,6 +314,72 @@ def toggle_monitoring():
|
|||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/users")
|
||||||
|
@login_required
|
||||||
|
def users():
|
||||||
|
return render_template("users.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/users")
|
||||||
|
@login_required
|
||||||
|
def api_users():
|
||||||
|
cache = user_monitor.data
|
||||||
|
if cache.get("error"):
|
||||||
|
return jsonify({"error": cache["error"]})
|
||||||
|
if cache.get("no_files"):
|
||||||
|
return jsonify({"no_files": True})
|
||||||
|
users_list = [
|
||||||
|
{
|
||||||
|
"login": u["login"],
|
||||||
|
"status": u["status"],
|
||||||
|
"last_action_time": u["last_action_time"].strftime("%H:%M:%S") if u.get("last_action_time") else None,
|
||||||
|
"last_action_label": u.get("last_action_label", ""),
|
||||||
|
"action_count_24h": u.get("action_count_24h", 0),
|
||||||
|
"connected_since": u["connected_since"].strftime("%H:%M") if u.get("connected_since") else None,
|
||||||
|
"explicit_logout": u.get("explicit_logout", False),
|
||||||
|
}
|
||||||
|
for u in cache.get("users", {}).values()
|
||||||
|
]
|
||||||
|
return jsonify({"users": users_list, "hourly": cache.get("hourly", [])})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/users/activity/weekly")
|
||||||
|
@login_required
|
||||||
|
def api_users_weekly():
|
||||||
|
return jsonify({"weekly": user_monitor.get_weekly_activity()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/settings/amadea-log-path", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def update_amadea_log_path():
|
||||||
|
path = request.form.get("amadea_log_path", "").strip()
|
||||||
|
if not path:
|
||||||
|
flash("Le chemin ne peut pas etre vide.", "danger")
|
||||||
|
return redirect(url_for("settings"))
|
||||||
|
config.set("amadea_log_path", path)
|
||||||
|
flash("Chemin des logs Amadea mis a jour.", "success")
|
||||||
|
return redirect(url_for("settings"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/settings/user-thresholds", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def update_user_thresholds():
|
||||||
|
try:
|
||||||
|
active = int(request.form["active_minutes"])
|
||||||
|
inactive = int(request.form["inactive_minutes"])
|
||||||
|
if active < 1 or inactive < 1:
|
||||||
|
flash("Les seuils doivent etre d'au moins 1 minute.", "danger")
|
||||||
|
return redirect(url_for("settings"))
|
||||||
|
if active >= inactive:
|
||||||
|
flash("Le seuil 'actif' doit etre inferieur au seuil 'inactif'.", "danger")
|
||||||
|
return redirect(url_for("settings"))
|
||||||
|
config.set("user_status_thresholds", {"active_minutes": active, "inactive_minutes": inactive})
|
||||||
|
flash("Seuils utilisateurs mis a jour.", "success")
|
||||||
|
except (ValueError, KeyError) as e:
|
||||||
|
flash(f"Erreur: {e}", "danger")
|
||||||
|
return redirect(url_for("settings"))
|
||||||
|
|
||||||
|
|
||||||
def check_port_available(port):
|
def check_port_available(port):
|
||||||
"""Verifie si un port est disponible."""
|
"""Verifie si un port est disponible."""
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
@@ -341,6 +409,9 @@ def main():
|
|||||||
# Collecte initiale
|
# Collecte initiale
|
||||||
monitor.collect_metrics()
|
monitor.collect_metrics()
|
||||||
|
|
||||||
|
user_monitor.start()
|
||||||
|
user_monitor.parse_logs()
|
||||||
|
|
||||||
print("[Supervision] Monitoring actif")
|
print("[Supervision] Monitoring actif")
|
||||||
|
|
||||||
app.run(host="0.0.0.0", port=port, debug=False)
|
app.run(host="0.0.0.0", port=port, debug=False)
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ def get_default_config():
|
|||||||
"from_email": "",
|
"from_email": "",
|
||||||
"to_emails": [],
|
"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": {
|
"admin": {
|
||||||
"username": "admin",
|
"username": "admin",
|
||||||
"password_hash": generate_password_hash("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
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`)
|
||||||
@@ -4,3 +4,4 @@ flask-limiter==3.9.*
|
|||||||
psutil==6.1.*
|
psutil==6.1.*
|
||||||
werkzeug==3.1.*
|
werkzeug==3.1.*
|
||||||
pyinstaller==6.12.*
|
pyinstaller==6.12.*
|
||||||
|
pytest==8.3.*
|
||||||
|
|||||||
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