khalid #1

Open
Khalid wants to merge 18 commits from khalid into main
12 changed files with 1109 additions and 20 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"]

102
app.py
View File

@@ -20,6 +20,7 @@ from werkzeug.security import check_password_hash, generate_password_hash
from config_manager import ConfigManager
from monitor import SystemMonitor
from alerter import EmailAlerter
from user_monitor import UserMonitor
# --- Init ---
config = ConfigManager()
@@ -53,6 +54,7 @@ login_manager.login_message = "Veuillez vous connecter."
# Services
alerter = EmailAlerter(config)
monitor = SystemMonitor(config, alerter)
user_monitor = UserMonitor(config)
class AdminUser(UserMixin):
@@ -124,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,
@@ -181,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"]),
@@ -191,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")
@@ -312,6 +311,84 @@ def toggle_monitoring():
return redirect(url_for("dashboard"))
@app.route("/users")
@login_required
def users():
return render_template("users.html")
@app.route("/api/users")
@login_required
def api_users():
cache = user_monitor.data
if cache.get("error"):
return jsonify({"error": cache["error"]})
if cache.get("no_files"):
return jsonify({"no_files": True})
users_list = [
{
"login": u["login"],
"status": u["status"],
"last_action_time": u["last_action_time"].strftime("%H:%M:%S") if u.get("last_action_time") else None,
"last_action_label": u.get("last_action_label", ""),
"action_count_24h": u.get("action_count_24h", 0),
"connected_since": u["connected_since"].strftime("%H:%M") if u.get("connected_since") else None,
"explicit_logout": u.get("explicit_logout", False),
}
for u in cache.get("users", {}).values()
]
return jsonify({"users": users_list, "hourly": cache.get("hourly", [])})
@app.route("/api/users/activity/weekly")
@login_required
def api_users_weekly():
return jsonify({"weekly": user_monitor.get_weekly_activity()})
@app.route("/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():
path = request.form.get("amadea_log_path", "").strip()
if not path:
flash("Le chemin ne peut pas etre vide.", "danger")
return redirect(url_for("settings"))
config.set("amadea_log_path", path)
flash("Chemin des logs Amadea mis a jour.", "success")
return redirect(url_for("settings"))
@app.route("/settings/user-thresholds", methods=["POST"])
@login_required
def update_user_thresholds():
try:
active = int(request.form["active_minutes"])
inactive = int(request.form["inactive_minutes"])
if active < 1 or inactive < 1:
flash("Les seuils doivent etre d'au moins 1 minute.", "danger")
return redirect(url_for("settings"))
if active >= inactive:
flash("Le seuil 'actif' doit etre inferieur au seuil 'inactif'.", "danger")
return redirect(url_for("settings"))
config.set("user_status_thresholds", {"active_minutes": active, "inactive_minutes": inactive})
flash("Seuils utilisateurs mis a jour.", "success")
except (ValueError, KeyError) as e:
flash(f"Erreur: {e}", "danger")
return redirect(url_for("settings"))
def check_port_available(port):
"""Verifie si un port est disponible."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
@@ -341,6 +418,9 @@ def main():
# Collecte initiale
monitor.collect_metrics()
user_monitor.start()
user_monitor.parse_logs()
print("[Supervision] Monitoring actif")
app.run(host="0.0.0.0", port=port, debug=False)

View File

@@ -54,6 +54,12 @@ 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": {
"active_minutes": 5,
"inactive_minutes": 30,
},
"admin": {
"username": "admin",

View File

@@ -4,3 +4,4 @@ flask-limiter==3.9.*
psutil==6.1.*
werkzeug==3.1.*
pyinstaller==6.12.*
pytest==8.3.*

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 %}

0
tests/__init__.py Normal file
View File

110
tests/test_user_monitor.py Normal file
View 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"

313
user_monitor.py Normal file
View File

@@ -0,0 +1,313 @@
"""Suivi des utilisateurs connectes a Amadea via parsing des logs."""
import gzip
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 _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.
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 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:
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:
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:
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),
-(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())]
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_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(
"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:
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