1003 lines
35 KiB
Markdown
1003 lines
35 KiB
Markdown
# Onglet Utilisateurs Amadea — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Ajouter un onglet "Utilisateurs" affichant en temps réel les utilisateurs connectés à Amadea Web 8 x64 (statut, dernière action, graphique d'activité horaire), avec configuration du chemin logs et des seuils de statut.
|
|
|
|
**Architecture:** Un module `user_monitor.py` tourne en background thread (miroir de `SystemMonitor`) — il parse les fichiers `awevents_YY-MM-DD_*.{txt,log}` et `isoft_YY-MM-DD_*.{txt,log}` du jour, calcule les statuts utilisateurs et expose un cache thread-safe. L'API `/api/users` retourne ce cache directement. Un second endpoint `/api/users/activity/weekly` lit les fichiers des 7 derniers jours à la demande.
|
|
|
|
**Tech Stack:** Python 3.10+, Flask 3.1, Bootstrap 5.3, Bootstrap Icons, barres CSS pures (aucune librairie graphique), pytest
|
|
|
|
---
|
|
|
|
## Fichiers créés / modifiés
|
|
|
|
| Fichier | Action | Rôle |
|
|
|---|---|---|
|
|
| `user_monitor.py` | Créer | Classe `UserMonitor` : thread, parsing, cache |
|
|
| `tests/__init__.py` | Créer | Package tests |
|
|
| `tests/test_user_monitor.py` | Créer | Tests unitaires du parsing et du calcul de statut |
|
|
| `config_manager.py` | Modifier | Ajout des clés `amadea_log_path` + `user_status_thresholds` |
|
|
| `app.py` | Modifier | Instanciation `UserMonitor`, 4 nouvelles routes |
|
|
| `templates/base.html` | Modifier | Lien "Utilisateurs" dans la navbar |
|
|
| `templates/settings.html` | Modifier | 2 nouveaux blocs de configuration |
|
|
| `templates/users.html` | Créer | Tableau utilisateurs + graphique CSS + JS auto-refresh |
|
|
| `requirements.txt` | Modifier | Ajout de `pytest` |
|
|
|
|
---
|
|
|
|
## Task 1 : Étendre ConfigManager avec les nouvelles clés
|
|
|
|
**Files:**
|
|
- Modify: `config_manager.py`
|
|
- Modify: `requirements.txt`
|
|
|
|
- [ ] **Étape 1 : Ajouter les nouvelles clés dans `get_default_config()`**
|
|
|
|
Dans `config_manager.py`, dans la fonction `get_default_config()`, ajouter après la clé `"smtp"` :
|
|
|
|
```python
|
|
"amadea_log_path": r"C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs",
|
|
"user_status_thresholds": {
|
|
"active_minutes": 5,
|
|
"inactive_minutes": 30,
|
|
},
|
|
```
|
|
|
|
La méthode `_load()` existante fusionne automatiquement les nouvelles clés avec une config déjà sauvegardée — aucune migration manuelle n'est nécessaire.
|
|
|
|
- [ ] **Étape 2 : Ajouter pytest à requirements.txt**
|
|
|
|
Ajouter à la fin de `requirements.txt` :
|
|
```
|
|
pytest==8.3.*
|
|
```
|
|
|
|
- [ ] **Étape 3 : Commit**
|
|
|
|
```bash
|
|
git add config_manager.py requirements.txt
|
|
git commit -m "feat: add amadea_log_path and user_status_thresholds config keys"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2 : Créer `user_monitor.py`
|
|
|
|
**Files:**
|
|
- Create: `user_monitor.py`
|
|
- Create: `tests/__init__.py`
|
|
- Create: `tests/test_user_monitor.py`
|
|
|
|
- [ ] **Étape 1 : Créer `user_monitor.py`**
|
|
|
|
```python
|
|
"""Suivi des utilisateurs connectes a Amadea via parsing des logs."""
|
|
|
|
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 _log_files_for_date(log_path, prefix, date_str):
|
|
"""Retourne les fichiers de logs pour un prefixe et une date donnes, tries par index."""
|
|
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)))
|
|
|
|
|
|
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:
|
|
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
|
|
|
|
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
|
|
|
|
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))
|
|
)
|
|
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_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:
|
|
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):
|
|
continue
|
|
max_concurrent = max((len(v) for v in hourly.values()), default=0)
|
|
result.append({"date": day.isoformat(), "count": max_concurrent})
|
|
return result
|
|
```
|
|
|
|
- [ ] **Étape 2 : Créer `tests/__init__.py`** (fichier vide)
|
|
|
|
```bash
|
|
mkdir -p tests && touch tests/__init__.py
|
|
```
|
|
|
|
- [ ] **Étape 3 : Créer `tests/test_user_monitor.py`**
|
|
|
|
```python
|
|
"""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"
|
|
```
|
|
|
|
- [ ] **Étape 4 : Lancer les tests**
|
|
|
|
```bash
|
|
.venv/bin/pip install pytest -q
|
|
.venv/bin/pytest tests/test_user_monitor.py -v
|
|
```
|
|
|
|
Résultat attendu : 9 tests PASSED.
|
|
|
|
- [ ] **Étape 5 : Commit**
|
|
|
|
```bash
|
|
git add user_monitor.py tests/__init__.py tests/test_user_monitor.py requirements.txt
|
|
git commit -m "feat: add UserMonitor with Amadea log parsing"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3 : Étendre `app.py` — routes et instanciation
|
|
|
|
**Files:**
|
|
- Modify: `app.py`
|
|
|
|
- [ ] **Étape 1 : Importer et instancier UserMonitor**
|
|
|
|
Dans `app.py`, après `from alerter import EmailAlerter`, ajouter :
|
|
|
|
```python
|
|
from user_monitor import UserMonitor
|
|
```
|
|
|
|
Après `monitor = SystemMonitor(config, alerter)`, ajouter :
|
|
|
|
```python
|
|
user_monitor = UserMonitor(config)
|
|
```
|
|
|
|
- [ ] **Étape 2 : Démarrer UserMonitor dans `main()`**
|
|
|
|
Dans `main()`, après `monitor.start()`, ajouter :
|
|
|
|
```python
|
|
user_monitor.start()
|
|
user_monitor.parse_logs()
|
|
```
|
|
|
|
- [ ] **Étape 3 : Ajouter les 4 nouvelles routes**
|
|
|
|
Ajouter avant la fonction `check_port_available` :
|
|
|
|
```python
|
|
@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("/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"))
|
|
```
|
|
|
|
- [ ] **Étape 4 : Vérifier le démarrage**
|
|
|
|
```bash
|
|
.venv/bin/python app.py
|
|
```
|
|
|
|
Résultat attendu : `[Supervision] Demarrage sur le port 5000` sans traceback. Ctrl+C pour arrêter.
|
|
|
|
- [ ] **Étape 5 : Commit**
|
|
|
|
```bash
|
|
git add app.py
|
|
git commit -m "feat: wire UserMonitor into app, add /users and /api/users routes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4 : Mettre à jour la navigation dans `base.html`
|
|
|
|
**Files:**
|
|
- Modify: `templates/base.html`
|
|
|
|
- [ ] **Étape 1 : Ajouter le lien "Utilisateurs"**
|
|
|
|
Dans `templates/base.html`, après le `<li class="nav-item">` pour "Alertes" (après la balise `</li>` qui ferme le lien Alertes), ajouter :
|
|
|
|
```html
|
|
<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>
|
|
```
|
|
|
|
- [ ] **Étape 2 : Vérifier visuellement**
|
|
|
|
Ouvrir http://localhost:5000 — la navbar doit afficher : Tableau de bord | Configuration | Alertes | Utilisateurs.
|
|
|
|
- [ ] **Étape 3 : Commit**
|
|
|
|
```bash
|
|
git add templates/base.html
|
|
git commit -m "feat: add Utilisateurs link to navbar"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5 : Mettre à jour `settings.html` — 2 nouveaux blocs
|
|
|
|
**Files:**
|
|
- Modify: `templates/settings.html`
|
|
|
|
- [ ] **Étape 1 : Ajouter les 2 blocs avant `{% endblock %}`**
|
|
|
|
Dans `templates/settings.html`, juste avant la balise `{% endblock %}` finale, ajouter :
|
|
|
|
```html
|
|
<!-- Chemin des logs Amadea + Seuils statut utilisateurs -->
|
|
<div class="row">
|
|
<div class="col-lg-6 mb-4">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h6 class="mb-0"><i class="bi bi-folder2-open"></i> Chemin des logs Amadea</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<form method="POST" action="{{ url_for('update_amadea_log_path') }}">
|
|
<div class="mb-3">
|
|
<label for="amadea_log_path" class="form-label">Dossier des logs</label>
|
|
<input type="text" class="form-control" id="amadea_log_path"
|
|
name="amadea_log_path"
|
|
value="{{ config.amadea_log_path }}"
|
|
placeholder="C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs">
|
|
<div class="form-text">Dossier contenant les fichiers isoft_*.txt et awevents_*.txt.</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="bi bi-check-lg"></i> Enregistrer
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-6 mb-4">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h6 class="mb-0"><i class="bi bi-people"></i> Seuils statut utilisateurs</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<form method="POST" action="{{ url_for('update_user_thresholds') }}">
|
|
<div class="mb-3">
|
|
<label for="active_minutes" class="form-label">Actif si derniere action < (minutes)</label>
|
|
<input type="number" class="form-control" id="active_minutes"
|
|
name="active_minutes"
|
|
value="{{ config.user_status_thresholds.active_minutes }}"
|
|
min="1" required>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="inactive_minutes" class="form-label">Inactif si derniere action < (minutes)</label>
|
|
<input type="number" class="form-control" id="inactive_minutes"
|
|
name="inactive_minutes"
|
|
value="{{ config.user_status_thresholds.inactive_minutes }}"
|
|
min="1" required>
|
|
<div class="form-text">Au-dela du seuil inactif, le statut passe a Deconnecte.</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="bi bi-check-lg"></i> Enregistrer
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Étape 2 : Vérifier visuellement**
|
|
|
|
Ouvrir http://localhost:5000/settings. Les deux nouveaux blocs doivent apparaître en bas avec les valeurs par défaut pré-remplies. Tester la sauvegarde de chaque formulaire.
|
|
|
|
- [ ] **Étape 3 : Commit**
|
|
|
|
```bash
|
|
git add templates/settings.html
|
|
git commit -m "feat: add Amadea log path and user status thresholds to settings"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6 : Créer `templates/users.html`
|
|
|
|
**Files:**
|
|
- Create: `templates/users.html`
|
|
|
|
- [ ] **Étape 1 : Créer le template**
|
|
|
|
Le JavaScript utilise exclusivement `textContent` et `createElement` pour insérer des données dans le DOM — aucune interpolation de données dans des chaînes HTML.
|
|
|
|
```html
|
|
{% 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 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 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"><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>
|
|
<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>
|
|
const STATUS_CONFIG = {
|
|
actif: { badge: 'bg-success', label: 'ACTIF' },
|
|
inactif: { badge: 'bg-warning text-dark', label: 'INACTIF' },
|
|
deconnecte: { badge: 'bg-secondary', label: 'DECONNECTE' },
|
|
};
|
|
|
|
let currentHourly = [];
|
|
let currentPeriod = 'today';
|
|
|
|
// --- Graphique CSS pur ---
|
|
|
|
function renderChart(data) {
|
|
const container = document.getElementById('chart-container');
|
|
const labelsEl = document.getElementById('chart-labels');
|
|
const unavail = document.getElementById('chart-unavailable');
|
|
|
|
container.style.display = 'flex';
|
|
labelsEl.style.display = 'flex';
|
|
unavail.classList.add('d-none');
|
|
container.textContent = '';
|
|
labelsEl.textContent = '';
|
|
|
|
if (!data || data.length === 0) {
|
|
const msg = document.createElement('span');
|
|
msg.className = 'text-muted small';
|
|
msg.textContent = 'Aucune donnee disponible.';
|
|
container.appendChild(msg);
|
|
return;
|
|
}
|
|
|
|
const max = Math.max(...data.map(d => d.count || 0), 1);
|
|
|
|
data.forEach(function(item) {
|
|
const count = item.count || 0;
|
|
const heightPct = count > 0 ? Math.max((count / max) * 100, 6) : 0;
|
|
|
|
const 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';
|
|
var labelText = item.hour !== undefined
|
|
? item.hour + 'h : ' + count + ' utilisateur(s)'
|
|
: item.date + ' : ' + count + ' utilisateur(s)';
|
|
bar.title = labelText;
|
|
container.appendChild(bar);
|
|
|
|
const 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 allNull = weekly.every(function(d) { return d.count === null; });
|
|
if (allNull) {
|
|
container.style.display = 'none';
|
|
labelsEl.style.display = 'none';
|
|
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 };
|
|
}));
|
|
}
|
|
|
|
// --- Tableau ---
|
|
|
|
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');
|
|
|
|
// 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;
|
|
badge.textContent = sc.label;
|
|
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.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);
|
|
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
// --- 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);
|
|
renderTable([]);
|
|
renderChart([]);
|
|
return;
|
|
}
|
|
if (data.no_files) {
|
|
showAlert('info', "Aucun log disponible pour aujourd'hui.");
|
|
renderTable([]);
|
|
renderChart([]);
|
|
return;
|
|
}
|
|
clearAlert();
|
|
renderTable(data.users || []);
|
|
currentHourly = data.hourly || [];
|
|
if (currentPeriod === 'today') {
|
|
renderChart(currentHourly);
|
|
}
|
|
var now = new Date();
|
|
document.getElementById('last-update').textContent =
|
|
'Mis a jour : ' + now.toLocaleTimeString('fr-FR');
|
|
})
|
|
.catch(function() {});
|
|
}
|
|
|
|
document.getElementById('period-select').addEventListener('change', function() {
|
|
currentPeriod = this.value;
|
|
if (currentPeriod === 'today') {
|
|
document.getElementById('chart-container').style.display = 'flex';
|
|
document.getElementById('chart-labels').style.display = 'flex';
|
|
document.getElementById('chart-unavailable').classList.add('d-none');
|
|
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 %}
|
|
```
|
|
|
|
- [ ] **Étape 2 : Vérifier visuellement**
|
|
|
|
Lancer l'app et ouvrir http://localhost:5000/users.
|
|
|
|
- Sur macOS (dossier logs Windows inexistant) : alerte orange "Dossier de logs introuvable"
|
|
- Sur Windows avec logs présents : tableau + graphique barres bleues
|
|
- Tester le sélecteur "7 derniers jours"
|
|
- Vérifier auto-refresh via la console réseau (requête toutes les 30s)
|
|
|
|
- [ ] **Étape 3 : Commit final**
|
|
|
|
```bash
|
|
git add templates/users.html
|
|
git commit -m "feat: add users.html template with table and activity chart"
|
|
```
|
|
|
|
---
|
|
|
|
## Vérification finale
|
|
|
|
- [ ] `pytest tests/test_user_monitor.py -v` → tous verts
|
|
- [ ] Routes fonctionnelles : `/users`, `/api/users`, `/api/users/activity/weekly`
|
|
- [ ] Configuration → sauvegarder un chemin de logs → vérifier `data/config.json`
|
|
- [ ] Configuration → seuils invalides (actif >= inactif) → message d'erreur affiché
|
|
- [ ] Onglets existants (Tableau de bord, Configuration, Alertes) → comportement inchangé
|
|
|
|
```bash
|
|
git log --oneline -6
|
|
```
|
|
|
|
Résultat attendu :
|
|
```
|
|
feat: add users.html template with table and activity chart
|
|
feat: add Amadea log path and user status thresholds to settings
|
|
feat: add Utilisateurs link to navbar
|
|
feat: wire UserMonitor into app, add /users and /api/users routes
|
|
feat: add UserMonitor with Amadea log parsing
|
|
feat: add amadea_log_path and user_status_thresholds config keys
|
|
```
|