From 62a478a92a5b2469179532b93b66c540558980a6 Mon Sep 17 00:00:00 2001 From: oussi Date: Mon, 20 Apr 2026 16:54:55 +0200 Subject: [PATCH] V1.0.1 --- README.md | 20 ++++-- alerter.py | 55 +++++++++++--- app.py | 31 +++++--- config_manager.py | 1 + templates/settings.html | 11 +++ templates/users.html | 156 ++++++++++++++++++++++++++++++++-------- user_monitor.py | 85 ++++++++++++++++------ 7 files changed, 284 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index b2355b3..a598c3f 100644 --- a/README.md +++ b/README.md @@ -197,10 +197,22 @@ L'onglet **Utilisateurs** affiche en temps reel les utilisateurs connectes a Ama | Fichier | Role | |---------|------| -| `awevents_JJ-MM-AA_N.txt` | Source principale : actions utilisateurs, deconnexions explicites | -| `isoft_JJ-MM-AA_N.txt` | Complement : evenements de session | +| `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 | -Seuls les fichiers du jour courant sont lus (les fichiers des jours precedents zipes sont ignores pour le tableau temps reel). Le graphique "7 derniers jours" utilise les fichiers `.txt` non zipes s'ils sont disponibles. +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 @@ -212,7 +224,7 @@ Seuls les fichiers du jour courant sont lus (les fichiers des jours precedents z ### Configuration du chemin des logs -Dans **Configuration > Chemin des logs Amadea**, renseignez le chemin complet du dossier contenant les fichiers `awevents_*.txt` et `isoft_*.txt`. +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` diff --git a/alerter.py b/alerter.py index d18a5dd..ea58347 100644 --- a/alerter.py +++ b/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 -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"] diff --git a/app.py b/app.py index c8fd017..f567c63 100644 --- a/app.py +++ b/app.py @@ -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/") +@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(): diff --git a/config_manager.py b/config_manager.py index 1f8fb8d..9eee691 100644 --- a/config_manager.py +++ b/config_manager.py @@ -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": { diff --git a/templates/settings.html b/templates/settings.html index 1154ac8..5db4fa3 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -132,6 +132,17 @@ placeholder="admin@example.com, tech@example.com"> +
+
+ + +
Si renseignee, la cle API Brevo est utilisee a la place du SMTP. Laissez vide pour conserver la cle actuelle.
+
+
+
+ Cliquez sur une barre pour voir les utilisateurs de ce jour. +
@@ -43,12 +46,12 @@
-
Utilisateurs connectes aujourd'hui
+
Utilisateurs connectes aujourd'hui
- + @@ -70,8 +73,10 @@ var STATUS_CONFIG = { deconnecte: { badge: 'bg-secondary', label: 'DECONNECTE' }, }; -var currentHourly = []; -var currentPeriod = 'today'; +var currentHourly = []; +var currentPeriod = 'today'; +var selectedBarEl = null; +var lastTodayUsers = []; /* --- Graphique CSS pur --- */ @@ -81,15 +86,15 @@ function renderYAxis(max) { 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.fontSize = '0.6rem'; + lbl.style.color = '#6c757d'; lbl.style.lineHeight = '1'; lbl.textContent = v; yaxis.appendChild(lbl); }); } -function renderChart(data) { +function renderChart(data, options) { var container = document.getElementById('chart-container'); var labelsEl = document.getElementById('chart-labels'); var unavail = document.getElementById('chart-unavailable'); @@ -99,11 +104,12 @@ function renderChart(data) { 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.className = 'text-muted small'; msg.textContent = 'Aucune donnee disponible.'; container.appendChild(msg); return; @@ -114,7 +120,7 @@ function renderChart(data) { renderYAxis(max); data.forEach(function(item) { - var count = item.count || 0; + var count = item.count || 0; var heightPct = count > 0 ? Math.max((count / max) * 100, 6) : 0; var bar = document.createElement('div'); @@ -128,6 +134,23 @@ function renderChart(data) { 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'); @@ -151,25 +174,45 @@ 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; } - container.style.display = 'flex'; - labelsEl.style.display = 'flex'; - unavail.classList.add('d-none'); - renderChart(weekly.map(function(d) { - return { date: d.date, count: d.count === null ? 0 : d.count }; - })); + + 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 = ' Utilisateurs connectes aujourd\'hui'; + thead.innerHTML = ''; + } else { + title.innerHTML = ' Utilisateurs — ' + (dateLabel || ''); + thead.innerHTML = ''; + } +} + function renderTable(users) { var tbody = document.getElementById('users-tbody'); tbody.textContent = ''; @@ -189,14 +232,12 @@ function renderTable(users) { var sc = STATUS_CONFIG[u.status] || { badge: 'bg-secondary', label: (u.status || '').toUpperCase() }; var tr = document.createElement('tr'); - /* Utilisateur */ var tdUser = document.createElement('td'); var strong = document.createElement('strong'); strong.textContent = u.login || ''; tdUser.appendChild(strong); tr.appendChild(tdUser); - /* Statut */ var tdStatus = document.createElement('td'); var badge = document.createElement('span'); badge.className = 'badge ' + sc.badge; @@ -204,25 +245,20 @@ function renderTable(users) { tdStatus.appendChild(badge); tr.appendChild(tdStatus); - /* Derniere action */ var tdAction = document.createElement('td'); tdAction.textContent = u.last_action_time || '\u2014'; if (u.last_action_label) { - var br = document.createElement('br'); var small = document.createElement('small'); - small.className = 'text-muted'; + small.className = 'text-muted d-block'; small.textContent = u.last_action_label; - tdAction.appendChild(br); tdAction.appendChild(small); } tr.appendChild(tdAction); - /* Actions 24h */ var tdCount = document.createElement('td'); tdCount.textContent = String(u.action_count_24h || 0); tr.appendChild(tdCount); - /* Depuis */ var tdSince = document.createElement('td'); tdSince.textContent = u.connected_since || '\u2014'; tr.appendChild(tdSince); @@ -231,6 +267,64 @@ function renderTable(users) { }); } +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) { @@ -257,20 +351,20 @@ function refreshUsers() { .then(function(data) { if (data.error) { showAlert('warning', data.error); - renderTable([]); - renderChart([]); + if (currentPeriod === 'today') { renderTable([]); renderChart([]); } return; } if (data.no_files) { showAlert('info', "Aucun log disponible pour aujourd'hui."); - renderTable([]); - renderChart([]); + if (currentPeriod === 'today') { renderTable([]); renderChart([]); } return; } clearAlert(); - renderTable(data.users || []); - currentHourly = data.hourly || []; + lastTodayUsers = data.users || []; + currentHourly = data.hourly || []; if (currentPeriod === 'today') { + setTableMode('today'); + renderTable(lastTodayUsers); renderChart(currentHourly); } document.getElementById('last-update').textContent = @@ -281,10 +375,14 @@ function refreshUsers() { 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') diff --git a/user_monitor.py b/user_monitor.py index 8f703e3..c67a066 100644 --- a/user_monitor.py +++ b/user_monitor.py @@ -30,12 +30,15 @@ def _read_log_file(filepath): 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. - Essai 1 : fichiers avec la date dans le nom (ex: awevents_26-04-13_1.log). - Essai 2 : si aucun fichier trouve, cherche sans date dans le nom et filtre - par date de modification du fichier (ex: serveur HDS). + 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') @@ -44,29 +47,19 @@ def _log_files_for_date(log_path, prefix, date_str): m = re.search(r'_(\d+)\.log(\.gz)?$', f) return int(m.group(1)) if m else 0 - # Essai 1 : date dans le nom + # 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)] - if files: - return sorted(files, key=sort_key) - # Essai 2 : sans date dans le nom, filtrer par date de modification - try: - target_date = datetime.strptime(date_str, "%y-%m-%d").date() - except ValueError: - return [] - - fallback_pattern = os.path.join(log_path, f"{prefix}_*") - files = [] - for f in glob.glob(fallback_pattern): - if not is_valid(f): - continue - try: - mtime = os.path.getmtime(f) - if datetime.fromtimestamp(mtime).date() == target_date: + # 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) - except OSError: - continue + return sorted(files, key=sort_key) @@ -153,7 +146,10 @@ class UserMonitor: 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())] @@ -236,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(
Utilisateur Statut Derniere actionUtilisateurStatutDerniere actionActions (24h)DepuisUtilisateurDerniere utilisationActions (jour)Duree de presence