This commit is contained in:
oussi
2026-04-20 16:54:55 +02:00
parent 8c59a1ab31
commit 62a478a92a
7 changed files with 284 additions and 75 deletions

View File

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

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": {

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

View File

@@ -31,6 +31,9 @@
<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>
@@ -43,12 +46,12 @@
<!-- Tableau utilisateurs -->
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-person-lines-fill"></i> Utilisateurs connectes aujourd'hui</h6>
<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>
<tr id="table-headers">
<th>Utilisateur</th>
<th>Statut</th>
<th>Derniere action</th>
@@ -72,6 +75,8 @@ var STATUS_CONFIG = {
var currentHourly = [];
var currentPeriod = 'today';
var selectedBarEl = null;
var lastTodayUsers = [];
/* --- Graphique CSS pur --- */
@@ -89,7 +94,7 @@ function renderYAxis(max) {
});
}
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,6 +104,7 @@ function renderChart(data) {
unavail.classList.add('d-none');
container.textContent = '';
labelsEl.textContent = '';
selectedBarEl = null;
if (!data || data.length === 0) {
renderYAxis(0);
@@ -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) {
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 = '';
@@ -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 || []);
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')

View File

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