13 Commits

Author SHA1 Message Date
oussi
62a478a92a V1.0.1 2026-04-20 16:54:55 +02:00
oussi
8c59a1ab31 Résolution de bugs 2026-04-13 16:12:42 +02:00
oussi
da51482cbd fix: remove docs/ from tracking and add to .gitignore 2026-04-07 15:33:48 +02:00
oussi
c3999d5215 doc: plan d'implémentation supervision-rs (14 tâches)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 11:25:26 +02:00
oussi
29df58331a doc: ajout du spec de design supervision-rs (réécriture Rust)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 11:04:57 +02:00
oussi
f2f41c47ae ajout du suivie utilisateur 2026-04-07 10:41:25 +02:00
oussi
537a3d6f55 ajout du suivi utilisateur 2026-04-02 14:41:11 +02:00
oussi
a9b505c73c ajout du suivi utilisateur 2026-04-02 14:39:25 +02:00
oussi
65edffbbc1 ajout du suivi utilisateur 2026-04-02 14:32:04 +02:00
oussi
b3e91bb0e3 ajout du suivi utilisateur 2026-04-02 12:18:53 +02:00
oussi
8887d97f90 ajout du suivi utilisateur 2026-04-02 11:45:21 +02:00
oussi
1abe0f2657 ajout du suivi utilisateur 2026-04-02 11:44:31 +02:00
oussi
7abe46a6c4 ajout du suivi utilisateur 2026-04-02 11:44:10 +02:00
11 changed files with 719 additions and 1239 deletions

6
.gitignore vendored
View File

@@ -7,6 +7,12 @@ data/alerts.json
*.log
.env
imput/
logTest/
log/
CLAUDE.md
docs/
.claude/
*.spec
build/
dist/
docs/

View File

@@ -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,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
| 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" |
| 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 |

View File

@@ -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
from email.mime.text import MIMEText
import urllib.error
import urllib.request
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
class EmailAlerter:
@@ -14,28 +17,64 @@ class EmailAlerter:
def is_configured(self):
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):
"""Envoie un email d'alerte. Silencieux si SMTP non configure."""
"""Envoie un email d'alerte. Silencieux si non configure."""
if not self.is_configured():
return False, "SMTP non configure"
return False, "Email non configure"
return self._send_email(subject, body)
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():
return False, "Configuration SMTP incomplete"
return False, "Configuration email incomplete"
subject = "[TEST] Supervision - Test de configuration email"
body = (
"Ceci est un email de test.\n\n"
"Si vous recevez ce message, la configuration SMTP est correcte.\n\n"
"Si vous recevez ce message, la configuration est correcte.\n\n"
"-- Supervision"
)
return self._send_email(subject, body)
def _send_email(self, subject, body):
smtp_cfg = self._get_smtp_config()
if smtp_cfg.get("brevo_api_key"):
return self._send_via_brevo(subject, body, smtp_cfg)
return self._send_via_smtp(subject, body, smtp_cfg)
def _send_via_brevo(self, subject, body, smtp_cfg):
"""Envoi via l'API REST Brevo."""
data = {
"sender": {"email": smtp_cfg["from_email"]},
"to": [{"email": e} for e in smtp_cfg["to_emails"]],
"subject": subject,
"textContent": body,
}
req = urllib.request.Request(
"https://api.brevo.com/v3/smtp/email",
data=json.dumps(data).encode("utf-8"),
headers={
"Content-Type": "application/json",
"api-key": smtp_cfg["brevo_api_key"],
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15):
return True, "Email envoye via Brevo"
except urllib.error.HTTPError as e:
return False, f"Erreur Brevo API: {e.code} {e.reason}"
except urllib.error.URLError as e:
return False, f"Impossible de joindre Brevo: {e.reason}"
except Exception as e:
return False, f"Erreur Brevo: {str(e)}"
def _send_via_smtp(self, subject, body, smtp_cfg):
"""Envoi via SMTP classique."""
try:
msg = MIMEMultipart()
msg["From"] = smtp_cfg["from_email"]

31
app.py
View File

@@ -126,10 +126,8 @@ def settings():
smtp = cfg.get("smtp", {})
# Masquer le mot de passe SMTP dans l'affichage
smtp_display = dict(smtp)
if smtp_display.get("password"):
smtp_display["password_masked"] = "*" * 8
else:
smtp_display["password_masked"] = ""
smtp_display["password_masked"] = "*" * 8 if smtp_display.get("password") else ""
smtp_display["brevo_api_key_masked"] = "*" * 8 if smtp_display.get("brevo_api_key") else ""
return render_template(
"settings.html",
config=cfg,
@@ -183,6 +181,7 @@ def update_monitoring():
@login_required
def update_smtp():
try:
old_smtp = config.get("smtp", {})
smtp = {
"server": request.form["smtp_server"].strip(),
"port": int(request.form["smtp_port"]),
@@ -193,14 +192,12 @@ def update_smtp():
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", "")
if new_password:
smtp["password"] = new_password
else:
# Garder l'ancien mot de passe
old_smtp = config.get("smtp", {})
smtp["password"] = old_smtp.get("password", "")
smtp["password"] = new_password if new_password else old_smtp.get("password", "")
# Conserver la cle Brevo si non fournie
new_brevo_key = request.form.get("brevo_api_key", "").strip()
smtp["brevo_api_key"] = new_brevo_key if new_brevo_key else old_smtp.get("brevo_api_key", "")
config.set("smtp", smtp)
flash("Configuration SMTP mise a jour.", "success")
@@ -349,6 +346,18 @@ def api_users_weekly():
return jsonify({"weekly": user_monitor.get_weekly_activity()})
@app.route("/api/users/day/<date_str>")
@login_required
def api_users_day(date_str):
from datetime import datetime as dt
try:
date = dt.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
return jsonify({"error": "Date invalide"}), 400
users = user_monitor.get_users_for_date(date)
return jsonify({"users": users, "date": date_str})
@app.route("/settings/amadea-log-path", methods=["POST"])
@login_required
def update_amadea_log_path():

View File

@@ -54,6 +54,7 @@ def get_default_config():
"password": "",
"from_email": "",
"to_emails": [],
"brevo_api_key": "",
},
"amadea_log_path": r"C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs",
"user_status_thresholds": {

File diff suppressed because it is too large Load Diff

View File

@@ -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 (00h23h), 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`)

View File

@@ -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">

View File

@@ -132,6 +132,17 @@
placeholder="admin@example.com, tech@example.com">
</div>
</div>
<div class="row">
<div class="col-12 mb-3">
<label for="brevo_api_key" class="form-label">
<i class="bi bi-key"></i> Cle API Brevo
</label>
<input type="password" class="form-control" id="brevo_api_key" name="brevo_api_key"
placeholder="{% if smtp.brevo_api_key_masked %}{{ smtp.brevo_api_key_masked }}{% else %}Non definie{% endif %}"
autocomplete="new-password">
<div class="form-text">Si renseignee, la cle API Brevo est utilisee a la place du SMTP. Laissez vide pour conserver la cle actuelle.</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Enregistrer
@@ -257,6 +268,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 &lt; (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 &lt; (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 %}

398
templates/users.html Normal file
View 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 &mdash; ' + (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 %}

View File

@@ -1,5 +1,6 @@
"""Suivi des utilisateurs connectes a Amadea via parsing des logs."""
import gzip
import glob
import os
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):
"""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}_*")
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)))
files = [f for f in glob.glob(pattern) if is_valid(f)]
# Pour le jour courant uniquement : inclure aussi les fichiers sans date dans le nom
today_str = datetime.now().strftime("%y-%m-%d")
if date_str == today_str:
active_pattern = os.path.join(log_path, f"{prefix}*")
for f in glob.glob(active_pattern):
fname = os.path.basename(f)
if is_valid(f) and not _DATE_IN_NAME_RE.search(fname) and f not in files:
files.append(f)
return sorted(files, key=sort_key)
class UserMonitor:
@@ -90,27 +130,26 @@ class UserMonitor:
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
content = _read_log_file(filepath)
if content:
for line in content.splitlines():
self._parse_awevents_line(line, users, cutoff_24h, hourly)
isoft_files = _log_files_for_date(log_path, "isoft", date_str)
for filepath in isoft_files:
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
content = _read_log_file(filepath)
if content:
for line in content.splitlines():
self._parse_isoft_line(line, users)
self._compute_statuses(users, thresholds, now)
status_order = {"actif": 0, "inactif": 1, "deconnecte": 2}
sorted_users = dict(
sorted(users.items(), key=lambda x: status_order.get(x[1]["status"], 3))
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())]
@@ -193,6 +232,49 @@ class UserMonitor:
else:
user["status"] = "actif"
def get_users_for_date(self, date):
"""Retourne la liste des utilisateurs ayant agi a une date donnee, tries par nb d'actions."""
log_path = self.config.get(
"amadea_log_path",
r"C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs"
)
if not os.path.isdir(log_path):
return []
date_str = date.strftime("%y-%m-%d")
cutoff = datetime(date.year, date.month, date.day)
files = _log_files_for_date(log_path, "awevents", date_str)
if not files:
return []
users = {}
hourly = {h: set() for h in range(24)}
for filepath in files:
content = _read_log_file(filepath)
if content:
for line in content.splitlines():
self._parse_awevents_line(line, users, cutoff, hourly)
result = sorted(users.values(), key=lambda u: -u.get("action_count_24h", 0))
output = []
for u in result:
duration = None
if u.get("connected_since") and u.get("last_action_time"):
mins = int((u["last_action_time"] - u["connected_since"]).total_seconds() / 60)
if mins >= 60:
duration = f"{mins // 60}h{mins % 60:02d}"
else:
duration = f"{mins}min"
output.append({
"login": u["login"],
"last_action_time": u["last_action_time"].strftime("%H:%M:%S") if u.get("last_action_time") else None,
"last_action_label": u.get("last_action_label", ""),
"action_count": u.get("action_count_24h", 0),
"first_action_time": u["connected_since"].strftime("%H:%M") if u.get("connected_since") else None,
"duration": duration,
})
return output
def get_weekly_activity(self):
"""Retourne le nombre max d'utilisateurs actifs simultanes par jour (7 derniers jours)."""
log_path = self.config.get(
@@ -213,20 +295,19 @@ class UserMonitor:
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):
content = _read_log_file(filepath)
if not content:
continue
for line in content.splitlines():
m = re.match(
r'^(\d{4}-\d{2}-\d{2} (\d{2}):\d{2}:\d{2}).*login=([^,]+),',
line
)
if m:
hour = int(m.group(2))
login = m.group(3).strip()
if login:
hourly[hour].add(login)
max_concurrent = max((len(v) for v in hourly.values()), default=0)
result.append({"date": day.isoformat(), "count": max_concurrent})
return result