Compare commits
13 Commits
5354c9983d
...
khalid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62a478a92a | ||
|
|
8c59a1ab31 | ||
|
|
da51482cbd | ||
|
|
c3999d5215 | ||
|
|
29df58331a | ||
|
|
f2f41c47ae | ||
|
|
537a3d6f55 | ||
|
|
a9b505c73c | ||
|
|
65edffbbc1 | ||
|
|
b3e91bb0e3 | ||
|
|
8887d97f90 | ||
|
|
1abe0f2657 | ||
|
|
7abe46a6c4 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -7,6 +7,12 @@ data/alerts.json
|
|||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
imput/
|
imput/
|
||||||
|
logTest/
|
||||||
|
log/
|
||||||
|
CLAUDE.md
|
||||||
|
docs/
|
||||||
|
.claude/
|
||||||
*.spec
|
*.spec
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
docs/
|
||||||
|
|||||||
65
README.md
65
README.md
@@ -9,12 +9,15 @@ Envoie des alertes email lorsque les seuils configures sont depasses.
|
|||||||
## Fonctionnalites
|
## Fonctionnalites
|
||||||
|
|
||||||
- **Dashboard temps reel** : CPU, RAM, disques, processus surveilles (rafraichissement auto)
|
- **Dashboard temps reel** : CPU, RAM, disques, processus surveilles (rafraichissement auto)
|
||||||
|
- **Suivi des utilisateurs Amadea** : statut en temps reel (Actif / Inactif / Deconnecte), derniere action, nombre d'actions sur 24h, graphique d'activite horaire
|
||||||
- **Alertes email** : envoi automatique quand un seuil est depasse, avec cooldown anti-spam
|
- **Alertes email** : envoi automatique quand un seuil est depasse, avec cooldown anti-spam
|
||||||
- **Configuration complete via l'interface** :
|
- **Configuration complete via l'interface** :
|
||||||
- Seuils d'alerte (CPU, RAM, disque)
|
- Seuils d'alerte (CPU, RAM, disque)
|
||||||
- Frequence de verification (en minutes)
|
- Frequence de verification (en minutes)
|
||||||
- Serveur SMTP + test d'envoi integre
|
- Serveur SMTP + test d'envoi integre
|
||||||
- Ajout/suppression de processus a surveiller
|
- Ajout/suppression de processus a surveiller
|
||||||
|
- Chemin du dossier de logs Amadea
|
||||||
|
- Seuils de statut utilisateurs (actif / inactif)
|
||||||
- Port de l'application
|
- Port de l'application
|
||||||
- Mot de passe administrateur
|
- Mot de passe administrateur
|
||||||
- **Securite** : authentification par login/mot de passe, rate limiting anti-bruteforce, en-tetes HTTP securises
|
- **Securite** : authentification par login/mot de passe, rate limiting anti-bruteforce, en-tetes HTTP securises
|
||||||
@@ -138,10 +141,24 @@ supervision\
|
|||||||
├── templates\ # Pages HTML de l'interface
|
├── templates\ # Pages HTML de l'interface
|
||||||
├── static\ # CSS
|
├── static\ # CSS
|
||||||
└── data\ # (cree au 1er lancement)
|
└── data\ # (cree au 1er lancement)
|
||||||
├── config.json # Configuration (seuils, SMTP, processus)
|
├── config.json # Configuration (seuils, SMTP, processus, logs Amadea)
|
||||||
└── alerts.json # Historique des alertes
|
└── alerts.json # Historique des alertes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Structure des sources (developpement)
|
||||||
|
|
||||||
|
```
|
||||||
|
supervision\
|
||||||
|
├── app.py # Application Flask, routes
|
||||||
|
├── monitor.py # Surveillance CPU/RAM/disques/processus
|
||||||
|
├── user_monitor.py # Suivi utilisateurs Amadea (parsing logs)
|
||||||
|
├── alerter.py # Envoi d'alertes email
|
||||||
|
├── config_manager.py # Persistance configuration JSON
|
||||||
|
├── templates\ # Pages HTML Jinja2
|
||||||
|
├── static\ # CSS
|
||||||
|
└── tests\ # Tests unitaires (pytest)
|
||||||
|
```
|
||||||
|
|
||||||
> **Important** : ne pas supprimer le dossier `_internal\`, il est necessaire au fonctionnement.
|
> **Important** : ne pas supprimer le dossier `_internal\`, il est necessaire au fonctionnement.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -156,6 +173,9 @@ supervision\
|
|||||||
| Seuil CPU | 90% |
|
| Seuil CPU | 90% |
|
||||||
| Seuil RAM | 85% |
|
| Seuil RAM | 85% |
|
||||||
| Seuil Disque | 90% |
|
| Seuil Disque | 90% |
|
||||||
|
| Chemin logs Amadea | `C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs` |
|
||||||
|
| Statut Actif si | derniere action < 5 minutes |
|
||||||
|
| Statut Inactif si | derniere action < 30 minutes |
|
||||||
|
|
||||||
### Processus surveilles par defaut
|
### Processus surveilles par defaut
|
||||||
|
|
||||||
@@ -169,6 +189,47 @@ 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.log(.gz)` | Source principale : actions utilisateurs, deconnexions explicites |
|
||||||
|
| `awevents.log` | Log actif du jour (sans date dans le nom, serveur HDS) |
|
||||||
|
| `isoft_JJ-MM-AA_N.log(.gz)` | Complement : evenements de session |
|
||||||
|
|
||||||
|
La detection gere les deux cas du serveur HDS : log actif sans date dans le nom (`awevents.log`) et log zip en cours de journee avec date dans le nom (`awevents_26-04-13_1.log.gz`).
|
||||||
|
|
||||||
|
### Tableau temps reel (aujourd'hui)
|
||||||
|
|
||||||
|
- Colonnes : Utilisateur, Statut, Derniere action, Actions (24h), Depuis
|
||||||
|
- Tri : statut (actif → inactif → deconnecte), puis derniere action la plus recente en premier au sein de chaque groupe
|
||||||
|
|
||||||
|
### Graphique 7 derniers jours
|
||||||
|
|
||||||
|
- Affiche le pic d'utilisateurs simultanes par jour
|
||||||
|
- **Cliquer sur une barre** charge le tableau des utilisateurs de ce jour : Utilisateur, Derniere utilisation, Actions (jour), Duree de presence (premiere → derniere action)
|
||||||
|
- Tri par nombre d'actions decroissant
|
||||||
|
|
||||||
|
### Regles de statut
|
||||||
|
|
||||||
|
| Statut | Condition |
|
||||||
|
|--------|-----------|
|
||||||
|
| **ACTIF** | Derniere action < 5 min (configurable) |
|
||||||
|
| **INACTIF** | Derniere action entre 5 et 30 min (configurable) |
|
||||||
|
| **DECONNECTE** | Derniere action > 30 min ou deconnexion explicite detectee |
|
||||||
|
|
||||||
|
### Configuration du chemin des logs
|
||||||
|
|
||||||
|
Dans **Configuration > Chemin des logs Amadea**, renseignez le chemin complet du dossier contenant les fichiers de logs.
|
||||||
|
|
||||||
|
Valeur par defaut : `C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Depannage
|
## Depannage
|
||||||
|
|
||||||
| Probleme | Solution |
|
| Probleme | Solution |
|
||||||
@@ -178,3 +239,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" |
|
| Pas d'email recu | Verifier la configuration SMTP et utiliser le bouton "Envoyer un email de test" |
|
||||||
| Mot de passe oublie | Supprimer `data\config.json` et relancer (reinitialise a admin/admin) |
|
| Mot de passe oublie | Supprimer `data\config.json` et relancer (reinitialise a admin/admin) |
|
||||||
| L'executable ne se lance pas | Verifier que le dossier `_internal\` est present a cote de `supervision.exe` |
|
| 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 |
|
||||||
|
|||||||
55
alerter.py
55
alerter.py
@@ -1,8 +1,11 @@
|
|||||||
"""Envoi d'alertes par email via SMTP."""
|
"""Envoi d'alertes par email via Brevo API ou SMTP."""
|
||||||
|
|
||||||
|
import json
|
||||||
import smtplib
|
import smtplib
|
||||||
from email.mime.text import MIMEText
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
|
||||||
class EmailAlerter:
|
class EmailAlerter:
|
||||||
@@ -14,28 +17,64 @@ class EmailAlerter:
|
|||||||
|
|
||||||
def is_configured(self):
|
def is_configured(self):
|
||||||
smtp = self._get_smtp_config()
|
smtp = self._get_smtp_config()
|
||||||
return bool(smtp.get("server") and smtp.get("from_email") and smtp.get("to_emails"))
|
has_recipients = bool(smtp.get("from_email") and smtp.get("to_emails"))
|
||||||
|
if smtp.get("brevo_api_key"):
|
||||||
|
return has_recipients
|
||||||
|
return has_recipients and bool(smtp.get("server"))
|
||||||
|
|
||||||
def send_alert(self, subject, body):
|
def send_alert(self, subject, body):
|
||||||
"""Envoie un email d'alerte. Silencieux si SMTP non configure."""
|
"""Envoie un email d'alerte. Silencieux si non configure."""
|
||||||
if not self.is_configured():
|
if not self.is_configured():
|
||||||
return False, "SMTP non configure"
|
return False, "Email non configure"
|
||||||
return self._send_email(subject, body)
|
return self._send_email(subject, body)
|
||||||
|
|
||||||
def send_test(self):
|
def send_test(self):
|
||||||
"""Envoie un email de test pour valider la configuration SMTP."""
|
"""Envoie un email de test pour valider la configuration."""
|
||||||
if not self.is_configured():
|
if not self.is_configured():
|
||||||
return False, "Configuration SMTP incomplete"
|
return False, "Configuration email incomplete"
|
||||||
subject = "[TEST] Supervision - Test de configuration email"
|
subject = "[TEST] Supervision - Test de configuration email"
|
||||||
body = (
|
body = (
|
||||||
"Ceci est un email de test.\n\n"
|
"Ceci est un email de test.\n\n"
|
||||||
"Si vous recevez ce message, la configuration SMTP est correcte.\n\n"
|
"Si vous recevez ce message, la configuration est correcte.\n\n"
|
||||||
"-- Supervision"
|
"-- Supervision"
|
||||||
)
|
)
|
||||||
return self._send_email(subject, body)
|
return self._send_email(subject, body)
|
||||||
|
|
||||||
def _send_email(self, subject, body):
|
def _send_email(self, subject, body):
|
||||||
smtp_cfg = self._get_smtp_config()
|
smtp_cfg = self._get_smtp_config()
|
||||||
|
if smtp_cfg.get("brevo_api_key"):
|
||||||
|
return self._send_via_brevo(subject, body, smtp_cfg)
|
||||||
|
return self._send_via_smtp(subject, body, smtp_cfg)
|
||||||
|
|
||||||
|
def _send_via_brevo(self, subject, body, smtp_cfg):
|
||||||
|
"""Envoi via l'API REST Brevo."""
|
||||||
|
data = {
|
||||||
|
"sender": {"email": smtp_cfg["from_email"]},
|
||||||
|
"to": [{"email": e} for e in smtp_cfg["to_emails"]],
|
||||||
|
"subject": subject,
|
||||||
|
"textContent": body,
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://api.brevo.com/v3/smtp/email",
|
||||||
|
data=json.dumps(data).encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"api-key": smtp_cfg["brevo_api_key"],
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15):
|
||||||
|
return True, "Email envoye via Brevo"
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return False, f"Erreur Brevo API: {e.code} {e.reason}"
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return False, f"Impossible de joindre Brevo: {e.reason}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Erreur Brevo: {str(e)}"
|
||||||
|
|
||||||
|
def _send_via_smtp(self, subject, body, smtp_cfg):
|
||||||
|
"""Envoi via SMTP classique."""
|
||||||
try:
|
try:
|
||||||
msg = MIMEMultipart()
|
msg = MIMEMultipart()
|
||||||
msg["From"] = smtp_cfg["from_email"]
|
msg["From"] = smtp_cfg["from_email"]
|
||||||
|
|||||||
31
app.py
31
app.py
@@ -126,10 +126,8 @@ def settings():
|
|||||||
smtp = cfg.get("smtp", {})
|
smtp = cfg.get("smtp", {})
|
||||||
# Masquer le mot de passe SMTP dans l'affichage
|
# Masquer le mot de passe SMTP dans l'affichage
|
||||||
smtp_display = dict(smtp)
|
smtp_display = dict(smtp)
|
||||||
if smtp_display.get("password"):
|
smtp_display["password_masked"] = "*" * 8 if smtp_display.get("password") else ""
|
||||||
smtp_display["password_masked"] = "*" * 8
|
smtp_display["brevo_api_key_masked"] = "*" * 8 if smtp_display.get("brevo_api_key") else ""
|
||||||
else:
|
|
||||||
smtp_display["password_masked"] = ""
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"settings.html",
|
"settings.html",
|
||||||
config=cfg,
|
config=cfg,
|
||||||
@@ -183,6 +181,7 @@ def update_monitoring():
|
|||||||
@login_required
|
@login_required
|
||||||
def update_smtp():
|
def update_smtp():
|
||||||
try:
|
try:
|
||||||
|
old_smtp = config.get("smtp", {})
|
||||||
smtp = {
|
smtp = {
|
||||||
"server": request.form["smtp_server"].strip(),
|
"server": request.form["smtp_server"].strip(),
|
||||||
"port": int(request.form["smtp_port"]),
|
"port": int(request.form["smtp_port"]),
|
||||||
@@ -193,14 +192,12 @@ def update_smtp():
|
|||||||
e.strip() for e in request.form["smtp_to"].split(",") if e.strip()
|
e.strip() for e in request.form["smtp_to"].split(",") if e.strip()
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
# Ne mettre a jour le mot de passe que s'il est fourni
|
# Conserver le mot de passe si non fourni
|
||||||
new_password = request.form.get("smtp_password", "")
|
new_password = request.form.get("smtp_password", "")
|
||||||
if new_password:
|
smtp["password"] = new_password if new_password else old_smtp.get("password", "")
|
||||||
smtp["password"] = new_password
|
# Conserver la cle Brevo si non fournie
|
||||||
else:
|
new_brevo_key = request.form.get("brevo_api_key", "").strip()
|
||||||
# Garder l'ancien mot de passe
|
smtp["brevo_api_key"] = new_brevo_key if new_brevo_key else old_smtp.get("brevo_api_key", "")
|
||||||
old_smtp = config.get("smtp", {})
|
|
||||||
smtp["password"] = old_smtp.get("password", "")
|
|
||||||
|
|
||||||
config.set("smtp", smtp)
|
config.set("smtp", smtp)
|
||||||
flash("Configuration SMTP mise a jour.", "success")
|
flash("Configuration SMTP mise a jour.", "success")
|
||||||
@@ -349,6 +346,18 @@ def api_users_weekly():
|
|||||||
return jsonify({"weekly": user_monitor.get_weekly_activity()})
|
return jsonify({"weekly": user_monitor.get_weekly_activity()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/users/day/<date_str>")
|
||||||
|
@login_required
|
||||||
|
def api_users_day(date_str):
|
||||||
|
from datetime import datetime as dt
|
||||||
|
try:
|
||||||
|
date = dt.strptime(date_str, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "Date invalide"}), 400
|
||||||
|
users = user_monitor.get_users_for_date(date)
|
||||||
|
return jsonify({"users": users, "date": date_str})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/settings/amadea-log-path", methods=["POST"])
|
@app.route("/settings/amadea-log-path", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def update_amadea_log_path():
|
def update_amadea_log_path():
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ def get_default_config():
|
|||||||
"password": "",
|
"password": "",
|
||||||
"from_email": "",
|
"from_email": "",
|
||||||
"to_emails": [],
|
"to_emails": [],
|
||||||
|
"brevo_api_key": "",
|
||||||
},
|
},
|
||||||
"amadea_log_path": r"C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs",
|
"amadea_log_path": r"C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs",
|
||||||
"user_status_thresholds": {
|
"user_status_thresholds": {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,188 +0,0 @@
|
|||||||
# 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`)
|
|
||||||
@@ -38,6 +38,12 @@
|
|||||||
<i class="bi bi-bell"></i> Alertes
|
<i class="bi bi-bell"></i> Alertes
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'users' %}active{% endif %}"
|
||||||
|
href="{{ url_for('users') }}">
|
||||||
|
<i class="bi bi-people"></i> Utilisateurs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
|
|||||||
@@ -132,6 +132,17 @@
|
|||||||
placeholder="admin@example.com, tech@example.com">
|
placeholder="admin@example.com, tech@example.com">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<label for="brevo_api_key" class="form-label">
|
||||||
|
<i class="bi bi-key"></i> Cle API Brevo
|
||||||
|
</label>
|
||||||
|
<input type="password" class="form-control" id="brevo_api_key" name="brevo_api_key"
|
||||||
|
placeholder="{% if smtp.brevo_api_key_masked %}{{ smtp.brevo_api_key_masked }}{% else %}Non definie{% endif %}"
|
||||||
|
autocomplete="new-password">
|
||||||
|
<div class="form-text">Si renseignee, la cle API Brevo est utilisee a la place du SMTP. Laissez vide pour conserver la cle actuelle.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-check-lg"></i> Enregistrer
|
<i class="bi bi-check-lg"></i> Enregistrer
|
||||||
@@ -257,6 +268,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Chemin des logs Amadea + Seuils statut utilisateurs -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-6 mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0"><i class="bi bi-folder2-open"></i> Chemin des logs Amadea</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('update_amadea_log_path') }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="amadea_log_path" class="form-label">Dossier des logs</label>
|
||||||
|
<input type="text" class="form-control" id="amadea_log_path"
|
||||||
|
name="amadea_log_path"
|
||||||
|
value="{{ config.amadea_log_path }}"
|
||||||
|
placeholder="C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs">
|
||||||
|
<div class="form-text">Dossier contenant les fichiers isoft_*.txt et awevents_*.txt.</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> Enregistrer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6 mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0"><i class="bi bi-people"></i> Seuils statut utilisateurs</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('update_user_thresholds') }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="active_minutes" class="form-label">Actif si derniere action < (minutes)</label>
|
||||||
|
<input type="number" class="form-control" id="active_minutes"
|
||||||
|
name="active_minutes"
|
||||||
|
value="{{ config.user_status_thresholds.active_minutes }}"
|
||||||
|
min="1" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="inactive_minutes" class="form-label">Inactif si derniere action < (minutes)</label>
|
||||||
|
<input type="number" class="form-control" id="inactive_minutes"
|
||||||
|
name="inactive_minutes"
|
||||||
|
value="{{ config.user_status_thresholds.inactive_minutes }}"
|
||||||
|
min="1" required>
|
||||||
|
<div class="form-text">Au-dela du seuil inactif, le statut passe a Deconnecte.</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> Enregistrer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|||||||
398
templates/users.html
Normal file
398
templates/users.html
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Supervision - Utilisateurs{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0"><i class="bi bi-people"></i> Utilisateurs Amadea</h4>
|
||||||
|
<span id="last-update" class="text-muted small"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="alert-zone"></div>
|
||||||
|
|
||||||
|
<!-- Graphique d'activite horaire -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0"><i class="bi bi-bar-chart"></i> Utilisateurs actifs par heure</h6>
|
||||||
|
<select id="period-select" class="form-select form-select-sm" style="width: auto;">
|
||||||
|
<option value="today">Aujourd'hui</option>
|
||||||
|
<option value="7days">7 derniers jours</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div style="display: flex; gap: 6px;">
|
||||||
|
<div id="chart-yaxis"
|
||||||
|
style="display: flex; flex-direction: column; justify-content: space-between;
|
||||||
|
align-items: flex-end; width: 20px; height: 100px; flex-shrink: 0;">
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; min-width: 0;">
|
||||||
|
<div id="chart-container"
|
||||||
|
style="height: 100px; display: flex; align-items: flex-end; gap: 3px;">
|
||||||
|
</div>
|
||||||
|
<div id="chart-labels" style="display: flex; gap: 3px; margin-top: 4px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="chart-hint" class="d-none mt-2">
|
||||||
|
<small class="text-muted"><i class="bi bi-hand-index"></i> Cliquez sur une barre pour voir les utilisateurs de ce jour.</small>
|
||||||
|
</div>
|
||||||
|
<div id="chart-unavailable" class="d-none">
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
Les donnees historiques des jours precedents ne sont pas disponibles (fichiers archives).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tableau utilisateurs -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0" id="table-title"><i class="bi bi-person-lines-fill"></i> Utilisateurs connectes aujourd'hui</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr id="table-headers">
|
||||||
|
<th>Utilisateur</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th>Derniere action</th>
|
||||||
|
<th>Actions (24h)</th>
|
||||||
|
<th>Depuis</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="users-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
var STATUS_CONFIG = {
|
||||||
|
actif: { badge: 'bg-success', label: 'ACTIF' },
|
||||||
|
inactif: { badge: 'bg-warning text-dark', label: 'INACTIF' },
|
||||||
|
deconnecte: { badge: 'bg-secondary', label: 'DECONNECTE' },
|
||||||
|
};
|
||||||
|
|
||||||
|
var currentHourly = [];
|
||||||
|
var currentPeriod = 'today';
|
||||||
|
var selectedBarEl = null;
|
||||||
|
var lastTodayUsers = [];
|
||||||
|
|
||||||
|
/* --- Graphique CSS pur --- */
|
||||||
|
|
||||||
|
function renderYAxis(max) {
|
||||||
|
var yaxis = document.getElementById('chart-yaxis');
|
||||||
|
yaxis.textContent = '';
|
||||||
|
var values = [max, Math.round(max / 2), 0];
|
||||||
|
values.forEach(function(v) {
|
||||||
|
var lbl = document.createElement('div');
|
||||||
|
lbl.style.fontSize = '0.6rem';
|
||||||
|
lbl.style.color = '#6c757d';
|
||||||
|
lbl.style.lineHeight = '1';
|
||||||
|
lbl.textContent = v;
|
||||||
|
yaxis.appendChild(lbl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart(data, options) {
|
||||||
|
var container = document.getElementById('chart-container');
|
||||||
|
var labelsEl = document.getElementById('chart-labels');
|
||||||
|
var unavail = document.getElementById('chart-unavailable');
|
||||||
|
|
||||||
|
container.style.display = 'flex';
|
||||||
|
labelsEl.style.display = 'flex';
|
||||||
|
unavail.classList.add('d-none');
|
||||||
|
container.textContent = '';
|
||||||
|
labelsEl.textContent = '';
|
||||||
|
selectedBarEl = null;
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
renderYAxis(0);
|
||||||
|
var msg = document.createElement('span');
|
||||||
|
msg.className = 'text-muted small';
|
||||||
|
msg.textContent = 'Aucune donnee disponible.';
|
||||||
|
container.appendChild(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var max = 1;
|
||||||
|
data.forEach(function(d) { if ((d.count || 0) > max) max = d.count; });
|
||||||
|
renderYAxis(max);
|
||||||
|
|
||||||
|
data.forEach(function(item) {
|
||||||
|
var count = item.count || 0;
|
||||||
|
var heightPct = count > 0 ? Math.max((count / max) * 100, 6) : 0;
|
||||||
|
|
||||||
|
var bar = document.createElement('div');
|
||||||
|
bar.style.flex = '1';
|
||||||
|
bar.style.minWidth = '14px';
|
||||||
|
bar.style.height = heightPct + '%';
|
||||||
|
bar.style.background = '#0d6efd';
|
||||||
|
bar.style.borderRadius = '3px 3px 0 0';
|
||||||
|
bar.style.opacity = count > 0 ? '1' : '0.15';
|
||||||
|
bar.style.transition = 'height 0.3s';
|
||||||
|
bar.title = item.hour !== undefined
|
||||||
|
? item.hour + 'h : ' + count + ' utilisateur(s)'
|
||||||
|
: item.date + ' : ' + count + ' utilisateur(s)';
|
||||||
|
|
||||||
|
if (options && options.onBarClick && item.date !== undefined) {
|
||||||
|
bar.style.cursor = 'pointer';
|
||||||
|
(function(capturedItem, capturedBar) {
|
||||||
|
capturedBar.addEventListener('click', function() {
|
||||||
|
if (selectedBarEl) {
|
||||||
|
selectedBarEl.style.background = '#0d6efd';
|
||||||
|
selectedBarEl.style.outline = '';
|
||||||
|
}
|
||||||
|
capturedBar.style.background = '#0a58ca';
|
||||||
|
capturedBar.style.outline = '2px solid #0a3fa8';
|
||||||
|
selectedBarEl = capturedBar;
|
||||||
|
options.onBarClick(capturedItem);
|
||||||
|
});
|
||||||
|
})(item, bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(bar);
|
||||||
|
|
||||||
|
var lbl = document.createElement('div');
|
||||||
|
lbl.style.flex = '1';
|
||||||
|
lbl.style.minWidth = '14px';
|
||||||
|
lbl.style.textAlign = 'center';
|
||||||
|
lbl.style.fontSize = '0.6rem';
|
||||||
|
lbl.style.color = '#6c757d';
|
||||||
|
lbl.style.overflow = 'hidden';
|
||||||
|
if (item.hour !== undefined) {
|
||||||
|
lbl.textContent = item.hour % 3 === 0 ? item.hour + 'h' : '';
|
||||||
|
} else {
|
||||||
|
var d = new Date(item.date);
|
||||||
|
lbl.textContent = d.getUTCDate() + '/' + (d.getUTCMonth() + 1);
|
||||||
|
}
|
||||||
|
labelsEl.appendChild(lbl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWeekly(weekly) {
|
||||||
|
var unavail = document.getElementById('chart-unavailable');
|
||||||
|
var container = document.getElementById('chart-container');
|
||||||
|
var labelsEl = document.getElementById('chart-labels');
|
||||||
|
var hint = document.getElementById('chart-hint');
|
||||||
|
|
||||||
|
var allNull = weekly.every(function(d) { return d.count === null; });
|
||||||
|
if (allNull) {
|
||||||
|
container.style.display = 'none';
|
||||||
|
labelsEl.style.display = 'none';
|
||||||
|
hint.classList.add('d-none');
|
||||||
|
document.getElementById('chart-yaxis').textContent = '';
|
||||||
|
unavail.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hint.classList.remove('d-none');
|
||||||
|
renderChart(
|
||||||
|
weekly.map(function(d) {
|
||||||
|
return { date: d.date, count: d.count === null ? 0 : d.count };
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
onBarClick: function(item) {
|
||||||
|
loadDayUsers(item.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tableau --- */
|
||||||
|
|
||||||
|
function setTableMode(mode, dateLabel) {
|
||||||
|
var title = document.getElementById('table-title');
|
||||||
|
var thead = document.getElementById('table-headers');
|
||||||
|
if (mode === 'today') {
|
||||||
|
title.innerHTML = '<i class="bi bi-person-lines-fill"></i> Utilisateurs connectes aujourd\'hui';
|
||||||
|
thead.innerHTML = '<th>Utilisateur</th><th>Statut</th><th>Derniere action</th><th>Actions (24h)</th><th>Depuis</th>';
|
||||||
|
} else {
|
||||||
|
title.innerHTML = '<i class="bi bi-person-lines-fill"></i> Utilisateurs — ' + (dateLabel || '');
|
||||||
|
thead.innerHTML = '<th>Utilisateur</th><th>Derniere utilisation</th><th>Actions (jour)</th><th>Duree de presence</th>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(users) {
|
||||||
|
var tbody = document.getElementById('users-tbody');
|
||||||
|
tbody.textContent = '';
|
||||||
|
|
||||||
|
if (!users || users.length === 0) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
var td = document.createElement('td');
|
||||||
|
td.colSpan = 5;
|
||||||
|
td.className = 'text-center text-muted py-3';
|
||||||
|
td.textContent = "Aucun utilisateur detecte aujourd'hui.";
|
||||||
|
tr.appendChild(td);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
users.forEach(function(u) {
|
||||||
|
var sc = STATUS_CONFIG[u.status] || { badge: 'bg-secondary', label: (u.status || '').toUpperCase() };
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
|
||||||
|
var tdUser = document.createElement('td');
|
||||||
|
var strong = document.createElement('strong');
|
||||||
|
strong.textContent = u.login || '';
|
||||||
|
tdUser.appendChild(strong);
|
||||||
|
tr.appendChild(tdUser);
|
||||||
|
|
||||||
|
var tdStatus = document.createElement('td');
|
||||||
|
var badge = document.createElement('span');
|
||||||
|
badge.className = 'badge ' + sc.badge;
|
||||||
|
badge.textContent = sc.label;
|
||||||
|
tdStatus.appendChild(badge);
|
||||||
|
tr.appendChild(tdStatus);
|
||||||
|
|
||||||
|
var tdAction = document.createElement('td');
|
||||||
|
tdAction.textContent = u.last_action_time || '\u2014';
|
||||||
|
if (u.last_action_label) {
|
||||||
|
var small = document.createElement('small');
|
||||||
|
small.className = 'text-muted d-block';
|
||||||
|
small.textContent = u.last_action_label;
|
||||||
|
tdAction.appendChild(small);
|
||||||
|
}
|
||||||
|
tr.appendChild(tdAction);
|
||||||
|
|
||||||
|
var tdCount = document.createElement('td');
|
||||||
|
tdCount.textContent = String(u.action_count_24h || 0);
|
||||||
|
tr.appendChild(tdCount);
|
||||||
|
|
||||||
|
var tdSince = document.createElement('td');
|
||||||
|
tdSince.textContent = u.connected_since || '\u2014';
|
||||||
|
tr.appendChild(tdSince);
|
||||||
|
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDayTable(users, dateStr) {
|
||||||
|
setTableMode('day', dateStr);
|
||||||
|
var tbody = document.getElementById('users-tbody');
|
||||||
|
tbody.textContent = '';
|
||||||
|
|
||||||
|
if (!users || users.length === 0) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
var td = document.createElement('td');
|
||||||
|
td.colSpan = 4;
|
||||||
|
td.className = 'text-center text-muted py-3';
|
||||||
|
td.textContent = 'Aucun utilisateur detecte ce jour.';
|
||||||
|
tr.appendChild(td);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
users.forEach(function(u) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
|
||||||
|
var tdUser = document.createElement('td');
|
||||||
|
var strong = document.createElement('strong');
|
||||||
|
strong.textContent = u.login || '';
|
||||||
|
tdUser.appendChild(strong);
|
||||||
|
tr.appendChild(tdUser);
|
||||||
|
|
||||||
|
var tdAction = document.createElement('td');
|
||||||
|
tdAction.textContent = u.last_action_time || '\u2014';
|
||||||
|
if (u.last_action_label) {
|
||||||
|
var small = document.createElement('small');
|
||||||
|
small.className = 'text-muted d-block';
|
||||||
|
small.textContent = u.last_action_label;
|
||||||
|
tdAction.appendChild(small);
|
||||||
|
}
|
||||||
|
tr.appendChild(tdAction);
|
||||||
|
|
||||||
|
var tdCount = document.createElement('td');
|
||||||
|
tdCount.textContent = String(u.action_count || 0);
|
||||||
|
tr.appendChild(tdCount);
|
||||||
|
|
||||||
|
var tdDuration = document.createElement('td');
|
||||||
|
tdDuration.textContent = u.duration || '\u2014';
|
||||||
|
tr.appendChild(tdDuration);
|
||||||
|
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDayUsers(date) {
|
||||||
|
fetch('/api/users/day/' + date)
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.error) { showAlert('warning', data.error); return; }
|
||||||
|
clearAlert();
|
||||||
|
renderDayTable(data.users || [], date);
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Alertes --- */
|
||||||
|
|
||||||
|
function showAlert(type, message) {
|
||||||
|
var zone = document.getElementById('alert-zone');
|
||||||
|
zone.textContent = '';
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.className = 'alert alert-' + type;
|
||||||
|
var icon = document.createElement('i');
|
||||||
|
icon.className = 'bi bi-exclamation-triangle me-1';
|
||||||
|
div.appendChild(icon);
|
||||||
|
div.appendChild(document.createTextNode(message));
|
||||||
|
zone.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAlert() {
|
||||||
|
document.getElementById('alert-zone').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Refresh --- */
|
||||||
|
|
||||||
|
function refreshUsers() {
|
||||||
|
fetch('/api/users')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.error) {
|
||||||
|
showAlert('warning', data.error);
|
||||||
|
if (currentPeriod === 'today') { renderTable([]); renderChart([]); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.no_files) {
|
||||||
|
showAlert('info', "Aucun log disponible pour aujourd'hui.");
|
||||||
|
if (currentPeriod === 'today') { renderTable([]); renderChart([]); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearAlert();
|
||||||
|
lastTodayUsers = data.users || [];
|
||||||
|
currentHourly = data.hourly || [];
|
||||||
|
if (currentPeriod === 'today') {
|
||||||
|
setTableMode('today');
|
||||||
|
renderTable(lastTodayUsers);
|
||||||
|
renderChart(currentHourly);
|
||||||
|
}
|
||||||
|
document.getElementById('last-update').textContent =
|
||||||
|
'Mis a jour : ' + new Date().toLocaleTimeString('fr-FR');
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('period-select').addEventListener('change', function() {
|
||||||
|
currentPeriod = this.value;
|
||||||
|
var hint = document.getElementById('chart-hint');
|
||||||
|
if (currentPeriod === 'today') {
|
||||||
|
hint.classList.add('d-none');
|
||||||
|
document.getElementById('chart-container').style.display = 'flex';
|
||||||
|
document.getElementById('chart-labels').style.display = 'flex';
|
||||||
|
document.getElementById('chart-unavailable').classList.add('d-none');
|
||||||
|
setTableMode('today');
|
||||||
|
renderTable(lastTodayUsers);
|
||||||
|
renderChart(currentHourly);
|
||||||
|
} else {
|
||||||
|
fetch('/api/users/activity/weekly')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) { renderWeekly(data.weekly || []); })
|
||||||
|
.catch(function() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshUsers();
|
||||||
|
setInterval(refreshUsers, 30000);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
119
user_monitor.py
119
user_monitor.py
@@ -1,5 +1,6 @@
|
|||||||
"""Suivi des utilisateurs connectes a Amadea via parsing des logs."""
|
"""Suivi des utilisateurs connectes a Amadea via parsing des logs."""
|
||||||
|
|
||||||
|
import gzip
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -16,11 +17,50 @@ _ISOFT_LOGIN_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_log_file(filepath):
|
||||||
|
"""Lit un fichier log, supporte .log et .log.gz."""
|
||||||
|
try:
|
||||||
|
if filepath.endswith('.gz'):
|
||||||
|
with gzip.open(filepath, 'rt', encoding='utf-8', errors='ignore') as f:
|
||||||
|
return f.read()
|
||||||
|
else:
|
||||||
|
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
||||||
|
return f.read()
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_DATE_IN_NAME_RE = re.compile(r'_\d{2}-\d{2}-\d{2}[_.]')
|
||||||
|
|
||||||
|
|
||||||
def _log_files_for_date(log_path, prefix, date_str):
|
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."""
|
"""Retourne les fichiers de logs pour un prefixe et une date donnes, tries par index.
|
||||||
|
|
||||||
|
Cherche les fichiers avec la date dans le nom (ex: awevents_26-04-13_1.log.gz).
|
||||||
|
Pour le jour courant uniquement, inclut aussi les fichiers sans date dans le nom
|
||||||
|
(ex: awevents.log, isoft.log) qui sont les logs actifs du jour.
|
||||||
|
"""
|
||||||
|
def is_valid(f):
|
||||||
|
return f.endswith('.log') or f.endswith('.log.gz')
|
||||||
|
|
||||||
|
def sort_key(f):
|
||||||
|
m = re.search(r'_(\d+)\.log(\.gz)?$', f)
|
||||||
|
return int(m.group(1)) if m else 0
|
||||||
|
|
||||||
|
# Fichiers avec la date dans le nom
|
||||||
pattern = os.path.join(log_path, f"{prefix}_{date_str}_*")
|
pattern = os.path.join(log_path, f"{prefix}_{date_str}_*")
|
||||||
files = [f for f in glob.glob(pattern) if not f.endswith('.zip')]
|
files = [f for f in glob.glob(pattern) if is_valid(f)]
|
||||||
return sorted(files, key=lambda f: int(re.search(r'_(\d+)\.[^.]+$', f).group(1)))
|
|
||||||
|
# Pour le jour courant uniquement : inclure aussi les fichiers sans date dans le nom
|
||||||
|
today_str = datetime.now().strftime("%y-%m-%d")
|
||||||
|
if date_str == today_str:
|
||||||
|
active_pattern = os.path.join(log_path, f"{prefix}*")
|
||||||
|
for f in glob.glob(active_pattern):
|
||||||
|
fname = os.path.basename(f)
|
||||||
|
if is_valid(f) and not _DATE_IN_NAME_RE.search(fname) and f not in files:
|
||||||
|
files.append(f)
|
||||||
|
|
||||||
|
return sorted(files, key=sort_key)
|
||||||
|
|
||||||
|
|
||||||
class UserMonitor:
|
class UserMonitor:
|
||||||
@@ -90,27 +130,26 @@ class UserMonitor:
|
|||||||
hourly = {h: set() for h in range(24)}
|
hourly = {h: set() for h in range(24)}
|
||||||
|
|
||||||
for filepath in awevents_files:
|
for filepath in awevents_files:
|
||||||
try:
|
content = _read_log_file(filepath)
|
||||||
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
if content:
|
||||||
for line in f:
|
for line in content.splitlines():
|
||||||
self._parse_awevents_line(line, users, cutoff_24h, hourly)
|
self._parse_awevents_line(line, users, cutoff_24h, hourly)
|
||||||
except (PermissionError, OSError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
isoft_files = _log_files_for_date(log_path, "isoft", date_str)
|
isoft_files = _log_files_for_date(log_path, "isoft", date_str)
|
||||||
for filepath in isoft_files:
|
for filepath in isoft_files:
|
||||||
try:
|
content = _read_log_file(filepath)
|
||||||
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
if content:
|
||||||
for line in f:
|
for line in content.splitlines():
|
||||||
self._parse_isoft_line(line, users)
|
self._parse_isoft_line(line, users)
|
||||||
except (PermissionError, OSError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
self._compute_statuses(users, thresholds, now)
|
self._compute_statuses(users, thresholds, now)
|
||||||
|
|
||||||
status_order = {"actif": 0, "inactif": 1, "deconnecte": 2}
|
status_order = {"actif": 0, "inactif": 1, "deconnecte": 2}
|
||||||
sorted_users = dict(
|
sorted_users = dict(
|
||||||
sorted(users.items(), key=lambda x: status_order.get(x[1]["status"], 3))
|
sorted(users.items(), key=lambda x: (
|
||||||
|
status_order.get(x[1]["status"], 3),
|
||||||
|
-(x[1]["last_action_time"].timestamp() if x[1].get("last_action_time") else 0),
|
||||||
|
))
|
||||||
)
|
)
|
||||||
hourly_data = [{"hour": h, "count": len(logins)} for h, logins in sorted(hourly.items())]
|
hourly_data = [{"hour": h, "count": len(logins)} for h, logins in sorted(hourly.items())]
|
||||||
|
|
||||||
@@ -193,6 +232,49 @@ class UserMonitor:
|
|||||||
else:
|
else:
|
||||||
user["status"] = "actif"
|
user["status"] = "actif"
|
||||||
|
|
||||||
|
def get_users_for_date(self, date):
|
||||||
|
"""Retourne la liste des utilisateurs ayant agi a une date donnee, tries par nb d'actions."""
|
||||||
|
log_path = self.config.get(
|
||||||
|
"amadea_log_path",
|
||||||
|
r"C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs"
|
||||||
|
)
|
||||||
|
if not os.path.isdir(log_path):
|
||||||
|
return []
|
||||||
|
|
||||||
|
date_str = date.strftime("%y-%m-%d")
|
||||||
|
cutoff = datetime(date.year, date.month, date.day)
|
||||||
|
files = _log_files_for_date(log_path, "awevents", date_str)
|
||||||
|
if not files:
|
||||||
|
return []
|
||||||
|
|
||||||
|
users = {}
|
||||||
|
hourly = {h: set() for h in range(24)}
|
||||||
|
for filepath in files:
|
||||||
|
content = _read_log_file(filepath)
|
||||||
|
if content:
|
||||||
|
for line in content.splitlines():
|
||||||
|
self._parse_awevents_line(line, users, cutoff, hourly)
|
||||||
|
|
||||||
|
result = sorted(users.values(), key=lambda u: -u.get("action_count_24h", 0))
|
||||||
|
output = []
|
||||||
|
for u in result:
|
||||||
|
duration = None
|
||||||
|
if u.get("connected_since") and u.get("last_action_time"):
|
||||||
|
mins = int((u["last_action_time"] - u["connected_since"]).total_seconds() / 60)
|
||||||
|
if mins >= 60:
|
||||||
|
duration = f"{mins // 60}h{mins % 60:02d}"
|
||||||
|
else:
|
||||||
|
duration = f"{mins}min"
|
||||||
|
output.append({
|
||||||
|
"login": u["login"],
|
||||||
|
"last_action_time": u["last_action_time"].strftime("%H:%M:%S") if u.get("last_action_time") else None,
|
||||||
|
"last_action_label": u.get("last_action_label", ""),
|
||||||
|
"action_count": u.get("action_count_24h", 0),
|
||||||
|
"first_action_time": u["connected_since"].strftime("%H:%M") if u.get("connected_since") else None,
|
||||||
|
"duration": duration,
|
||||||
|
})
|
||||||
|
return output
|
||||||
|
|
||||||
def get_weekly_activity(self):
|
def get_weekly_activity(self):
|
||||||
"""Retourne le nombre max d'utilisateurs actifs simultanes par jour (7 derniers jours)."""
|
"""Retourne le nombre max d'utilisateurs actifs simultanes par jour (7 derniers jours)."""
|
||||||
log_path = self.config.get(
|
log_path = self.config.get(
|
||||||
@@ -213,9 +295,10 @@ class UserMonitor:
|
|||||||
continue
|
continue
|
||||||
hourly = {h: set() for h in range(24)}
|
hourly = {h: set() for h in range(24)}
|
||||||
for filepath in files:
|
for filepath in files:
|
||||||
try:
|
content = _read_log_file(filepath)
|
||||||
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
if not content:
|
||||||
for line in f:
|
continue
|
||||||
|
for line in content.splitlines():
|
||||||
m = re.match(
|
m = re.match(
|
||||||
r'^(\d{4}-\d{2}-\d{2} (\d{2}):\d{2}:\d{2}).*login=([^,]+),',
|
r'^(\d{4}-\d{2}-\d{2} (\d{2}):\d{2}:\d{2}).*login=([^,]+),',
|
||||||
line
|
line
|
||||||
@@ -225,8 +308,6 @@ class UserMonitor:
|
|||||||
login = m.group(3).strip()
|
login = m.group(3).strip()
|
||||||
if login:
|
if login:
|
||||||
hourly[hour].add(login)
|
hourly[hour].add(login)
|
||||||
except (PermissionError, OSError):
|
|
||||||
continue
|
|
||||||
max_concurrent = max((len(v) for v in hourly.values()), default=0)
|
max_concurrent = max((len(v) for v in hourly.values()), default=0)
|
||||||
result.append({"date": day.isoformat(), "count": max_concurrent})
|
result.append({"date": day.isoformat(), "count": max_concurrent})
|
||||||
return result
|
return result
|
||||||
|
|||||||
Reference in New Issue
Block a user