3 Commits

Author SHA1 Message Date
oussi
62a478a92a V1.0.1 2026-04-20 16:54:55 +02:00
oussi
8c59a1ab31 Résolution de bugs 2026-04-13 16:12:42 +02:00
oussi
da51482cbd fix: remove docs/ from tracking and add to .gitignore 2026-04-07 15:33:48 +02:00
12 changed files with 333 additions and 4698 deletions

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ docs/
*.spec
build/
dist/
docs/

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,188 +0,0 @@
# Design — Onglet Utilisateurs Amadea
**Date :** 2026-04-02
**Statut :** Approuvé
---
## Contexte
Ajouter un onglet "Utilisateurs" à l'application de supervision, affichant en temps réel les utilisateurs connectés à Amadea Web 8 x64, leur statut d'activité, et un graphique d'utilisation horaire. Le chemin des logs Amadea et les seuils de statut sont rendus configurables dans l'onglet Configuration existant.
---
## Architecture générale
### Nouveaux fichiers
- **`user_monitor.py`** — classe `UserMonitor` : thread de fond, parsing des logs, cache thread-safe. Miroir exact de `SystemMonitor`.
### Fichiers modifiés
| Fichier | Modification |
|---|---|
| `config_manager.py` | Ajout des clés `amadea_log_path` et `user_status_thresholds` dans la config par défaut |
| `app.py` | Instanciation de `UserMonitor`, routes `/users` et `/api/users` |
| `templates/base.html` | Lien "Utilisateurs" dans la navbar |
| `templates/settings.html` | 2 nouveaux blocs de configuration |
| `templates/users.html` | Nouvelle page (tableau + graphique CSS) |
### Flux de données
```
UserMonitor (background thread)
├─ parse awevents_YY-MM-DD_*.log → activité utilisateur + déconnexions explicites
└─ parse isoft_YY-MM-DD_*.log → événements de session (login/timeout)
↓ cache thread-safe (dict par login)
/api/users → JSON
/users → rendu Jinja2 initial + auto-refresh JS (30s, même pattern que dashboard)
```
---
## Parsing et modèle de données
### Sélection des fichiers du jour
Les fichiers de logs suivent le pattern `PREFIX_YY-MM-DD_N.ext` (ex: `awevents_26-04-02_1.log`).
- Date du jour formatée en `%y-%m-%d` (ex: `26-04-02` pour 2026-04-02)
- Tous les fichiers du jour sont lus, triés par index `N` croissant
- Si plusieurs fichiers du même jour existent (index incrémental), ils sont tous parsés dans l'ordre
### Format `awevents_YY-MM-DD_N.log` (source principale)
```
2026-03-30 10:34:24.034;INFO ;;;;"login=JENKINS,action=SelectionChange,Label=BAO_Main/..."
```
Regex d'extraction :
```python
r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*login=([^,]+),action=([^,]+),Label=(.+)"?$'
```
- `timestamp` → groupe 1
- `login` → groupe 2 (identifiant utilisateur)
- `action` → groupe 3 (SelectionChange, Action, Click, ValueChange…)
- `label` → groupe 4 (contexte UI)
**Déconnexion explicite :** ligne dont le label contient `se deconnecter`
### Format `isoft_YY-MM-DD_N.log` (complément sessions)
```
2026-03-30 10:33:05.830;INFO ;"ISExecutingThread...";...;"method=OpenUserSession,...,login=JENKINS"
```
Regex pour login réussi :
```python
r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*method=OpenUserSession.*login=([A-Za-z0-9_]+)'
```
Regex pour timeout de session :
```python
r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*method=closeSession'
```
### Modèle par utilisateur (cache)
```python
{
"login": str, # identifiant (ex: "JENKINS")
"last_action_time": datetime, # horodatage de la dernière action
"last_action_label": str, # label de la dernière action (tronqué à 60 chars)
"action_count_24h": int, # nombre d'actions dans les dernières 24h
"status": str, # "actif" | "inactif" | "deconnecte"
"explicit_logout": bool, # True si déconnexion explicite détectée
"connected_since": datetime | None, # heure de la première action du jour (premier awevents du jour pour cet utilisateur)
}
```
### Règles de statut
Seuils configurables (clé `user_status_thresholds`, défauts : `active_minutes=5`, `inactive_minutes=30`) :
| Condition | Statut |
|---|---|
| Déconnexion explicite détectée | DÉCONNECTÉ |
| Dernière action > `inactive_minutes` | DÉCONNECTÉ |
| Dernière action entre `active_minutes` et `inactive_minutes` | INACTIF |
| Dernière action < `active_minutes` | ACTIF |
### Graphique d'activité horaire
Comptage du nombre d'utilisateurs distincts ayant au moins une action par tranche horaire (H:00 → H:59). Données issues uniquement des fichiers du jour (`awevents_*.log`).
Pour le sélecteur "7 derniers jours" : si les fichiers zippés ne sont pas disponibles (cas nominal en prod), afficher un `alert-info` : *"Les données historiques des jours précédents ne sont pas disponibles (fichiers archivés)."*
---
## Configuration
### Nouvelles clés dans `config.json`
```json
{
"amadea_log_path": "C:\\ProgramData\\ISoft\\Amadea Web 8 x64\\data\\logs",
"user_status_thresholds": {
"active_minutes": 5,
"inactive_minutes": 30
}
}
```
### Nouveaux blocs dans `settings.html`
**Bloc 1 — Chemin des logs Amadea** (même style `card` + `form-control` + `btn btn-primary`) :
- Champ texte pré-rempli avec la valeur actuelle
- Bouton "Enregistrer"
- Route POST : `/settings/amadea-log-path`
**Bloc 2 — Seuils statut utilisateurs** (même style que le bloc "Seuils d'alerte") :
- Champ numérique "Actif si dernière action < N min" (défaut: 5)
- Champ numérique "Inactif si dernière action < N min" (défaut: 30)
- Bouton "Enregistrer"
- Route POST : `/settings/user-thresholds`
---
## Interface utilisateurs (`users.html`)
### Tableau
Colonnes : **Utilisateur | Statut | Dernière action | Actions (24h) | Depuis**
Tri par défaut : Actifs → Inactifs → Déconnectés (ordre de priorité statut).
Badges Bootstrap cohérents avec l'existant :
- ACTIF → `<span class="badge bg-success">ACTIF</span>`
- INACTIF → `<span class="badge bg-warning text-dark">INACTIF</span>`
- DÉCONNECTÉ → `<span class="badge bg-secondary">DÉCONNECTÉ</span>`
### Graphique d'activité
Barres CSS Bootstrap (divs avec hauteur proportionnelle), aucune librairie externe.
Une barre par heure de la journée (00h23h), largeur fixe, hauteur = `(valeur / max) * 100%`.
Couleur : `bg-primary`. Tooltip au survol (attribut `title`).
### Auto-refresh
`setInterval` toutes les 30 secondes, appel `fetch('/api/users')`, même pattern que `refreshMetrics()` dans `dashboard.html`.
### Gestion d'erreurs
| Cas | Affichage |
|---|---|
| Dossier de logs introuvable | `alert alert-warning` avec le chemin configuré |
| Aucun fichier du jour trouvé | `alert alert-info "Aucun log disponible pour aujourd'hui"` |
| Fichier verrouillé/illisible | Ignoré silencieusement, parsing continue |
| Aucun utilisateur détecté | Message `text-muted` dans le tableau |
---
## Choix techniques
- **Librairie graphique** : barres CSS Bootstrap pures (aucune dépendance externe)
- **Thread** : daemon thread (comme `SystemMonitor`), s'arrête avec l'application
- **Encodage fichiers** : `utf-8` avec `errors='ignore'` pour tolérer les caractères invalides
- **Performance** : les fichiers `isoft_*.log` peuvent être très volumineux (index > 80). Le parsing lit ligne par ligne sans charger le fichier en mémoire (`for line in f`)

View File

@@ -1,151 +0,0 @@
# Design — supervision-rs
**Date :** 2026-04-07
**Objectif :** Réécrire l'application de supervision système en Rust pour produire un exécutable Windows standalone, sans dépendances, déployable par simple copie.
---
## Contexte
L'application actuelle est en Python (Flask + psutil + PyInstaller). Le tuteur a recommandé une réécriture en Rust pour obtenir un exécutable plus stable sur Windows. Le binaire doit être autonome : on le copie sur n'importe quel serveur Windows, on l'installe comme service, et c'est tout.
---
## Périmètre fonctionnel
Parité complète avec l'app Python actuelle :
- Interface web sécurisée (login admin, cookie de session)
- Dashboard temps réel : CPU, RAM, disques, processus surveillés
- Alertes email (SMTP plain ou STARTTLS) avec cooldown configurable
- Configuration via UI (seuils, SMTP, intervalle, processus)
- Historique des alertes (max 500, rotation automatique)
- Fonctionne comme service Windows (démarre avec Windows, tourne en arrière-plan)
---
## Architecture
### Structure du projet
```
supervision-rs/
├── Cargo.toml
├── src/
│ ├── main.rs # Point d'entrée + intégration service Windows
│ ├── config.rs # Lecture/écriture config.json et alerts.json
│ ├── monitor.rs # Thread de collecte métriques + évaluation seuils
│ ├── alerter.rs # Envoi email SMTP
│ ├── auth.rs # Session admin, rate limiting login
│ └── routes.rs # Endpoints Axum (dashboard, API, config, login)
├── templates/ # Templates Tera (portés depuis Jinja2)
│ ├── base.html
│ ├── dashboard.html
│ ├── login.html
│ ├── config.html
│ └── alerts.html
└── data/ # Créé au premier lancement (gitignored)
├── config.json
└── alerts.json
```
### Crates
| Crate | Rôle | Équivalent Python |
|---|---|---|
| `axum` | Serveur HTTP async | Flask |
| `tokio` | Runtime async | — |
| `tera` | Templates HTML | Jinja2 |
| `sysinfo` | Métriques CPU/RAM/disque/processus | psutil |
| `lettre` | Email SMTP | smtplib |
| `serde` + `serde_json` | Sérialisation config/alertes | json |
| `windows-service` | Intégration service Windows | — |
| `tower-sessions` | Sessions auth (cookie signé) | Flask-Login |
| `tower_governor` | Rate limiting | Flask-Limiter |
| `tracing` | Logs | logging |
---
## Modes de démarrage
```
supervision.exe → mode console (test, développement)
supervision.exe install → installe le service Windows
supervision.exe uninstall → supprime le service Windows
(lancé par Windows SCM) → mode service (background automatique)
```
Détection automatique dans `main.rs` : si lancé par le Service Control Manager de Windows, entre en mode service. Sinon, mode console.
**Installation sur un serveur :**
```cmd
supervision.exe install
sc start Supervision
```
---
## State partagé (concurrence)
```
Arc<AppState>
├── config: RwLock<Config> # Config lue/écrite par routes + monitor
├── metrics: RwLock<Metrics> # Écrit par monitor, lu par /api/metrics
└── alerter: Alerter # Utilisé par monitor
```
Le thread de monitoring est une `tokio::task` qui tourne en arrière-plan. Il met à jour `metrics` via `RwLock`. Les routes Axum lisent ce state sans bloquer.
---
## Authentification
- Un seul admin, identifiants stockés dans `config.json` (mot de passe hashé bcrypt)
- Session via cookie signé (`tower-sessions`)
- Rate limiting sur `POST /login` : 10 tentatives/minute
- Toutes les routes (sauf `/login`, `/static`) redirigent vers login si non authentifié
---
## Métriques & seuils
Collecte via `sysinfo` :
- CPU : pourcentage global
- RAM : pourcentage utilisé, total/utilisé/disponible en Go
- Disques : partitions physiques ≥1 Go, pourcentage, espace total/utilisé/libre
- Processus : recherche par pattern dans nom ou ligne de commande, mémoire RSS, CPU
Niveaux de statut (identique au Python) :
- `ok` : < 80% du seuil
- `warning` : ≥ 80% du seuil
- `critical` : ≥ 100% du seuil
---
## Alertes email
- SMTP plain ou STARTTLS (configurable)
- Cooldown par clé (en mémoire, réinitialisé au redémarrage)
- Alertes déclenchées sur : CPU critique, RAM critique, disque critique, processus arrêté, mémoire processus critique
- Stockage dans `alerts.json` (max 500 entrées, rotation FIFO)
---
## Persistance
- `data/config.json` : configuration complète (seuils, SMTP, admin, processus surveillés, intervalle)
- `data/alerts.json` : historique des alertes
- Dossier `data/` créé automatiquement au premier lancement, dans le même répertoire que l'exe
- Pas de base de données
---
## Déploiement
1. Compiler sur Windows : `cargo build --release`
2. Récupérer `target/release/supervision.exe`
3. Copier l'exe seul sur le serveur cible
4. Lancer `supervision.exe install` puis `sc start Supervision`
5. Accéder à `http://localhost:5000` dans le navigateur
Aucune autre dépendance requise sur le serveur cible.

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>
@@ -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 = '<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 || []);
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')

View File

@@ -1,5 +1,6 @@
"""Suivi des utilisateurs connectes a Amadea via parsing des logs."""
import gzip
import glob
import os
import re
@@ -16,11 +17,50 @@ _ISOFT_LOGIN_RE = re.compile(
)
def _read_log_file(filepath):
"""Lit un fichier log, supporte .log et .log.gz."""
try:
if filepath.endswith('.gz'):
with gzip.open(filepath, 'rt', encoding='utf-8', errors='ignore') as f:
return f.read()
else:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
return f.read()
except (PermissionError, OSError):
return None
_DATE_IN_NAME_RE = re.compile(r'_\d{2}-\d{2}-\d{2}[_.]')
def _log_files_for_date(log_path, prefix, date_str):
"""Retourne les fichiers de logs pour un prefixe et une date donnes, tries par index."""
"""Retourne les fichiers de logs pour un prefixe et une date donnes, tries par index.
Cherche les fichiers avec la date dans le nom (ex: awevents_26-04-13_1.log.gz).
Pour le jour courant uniquement, inclut aussi les fichiers sans date dans le nom
(ex: awevents.log, isoft.log) qui sont les logs actifs du jour.
"""
def is_valid(f):
return f.endswith('.log') or f.endswith('.log.gz')
def sort_key(f):
m = re.search(r'_(\d+)\.log(\.gz)?$', f)
return int(m.group(1)) if m else 0
# Fichiers avec la date dans le nom
pattern = os.path.join(log_path, f"{prefix}_{date_str}_*")
files = [f for f in glob.glob(pattern) if not f.endswith('.zip')]
return sorted(files, key=lambda f: int(re.search(r'_(\d+)\.[^.]+$', f).group(1)))
files = [f for f in glob.glob(pattern) if is_valid(f)]
# Pour le jour courant uniquement : inclure aussi les fichiers sans date dans le nom
today_str = datetime.now().strftime("%y-%m-%d")
if date_str == today_str:
active_pattern = os.path.join(log_path, f"{prefix}*")
for f in glob.glob(active_pattern):
fname = os.path.basename(f)
if is_valid(f) and not _DATE_IN_NAME_RE.search(fname) and f not in files:
files.append(f)
return sorted(files, key=sort_key)
class UserMonitor:
@@ -90,27 +130,26 @@ class UserMonitor:
hourly = {h: set() for h in range(24)}
for filepath in awevents_files:
try:
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
for line in f:
self._parse_awevents_line(line, users, cutoff_24h, hourly)
except (PermissionError, OSError):
continue
content = _read_log_file(filepath)
if content:
for line in content.splitlines():
self._parse_awevents_line(line, users, cutoff_24h, hourly)
isoft_files = _log_files_for_date(log_path, "isoft", date_str)
for filepath in isoft_files:
try:
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
for line in f:
self._parse_isoft_line(line, users)
except (PermissionError, OSError):
continue
content = _read_log_file(filepath)
if content:
for line in content.splitlines():
self._parse_isoft_line(line, users)
self._compute_statuses(users, thresholds, now)
status_order = {"actif": 0, "inactif": 1, "deconnecte": 2}
sorted_users = dict(
sorted(users.items(), key=lambda x: status_order.get(x[1]["status"], 3))
sorted(users.items(), key=lambda x: (
status_order.get(x[1]["status"], 3),
-(x[1]["last_action_time"].timestamp() if x[1].get("last_action_time") else 0),
))
)
hourly_data = [{"hour": h, "count": len(logins)} for h, logins in sorted(hourly.items())]
@@ -193,6 +232,49 @@ class UserMonitor:
else:
user["status"] = "actif"
def get_users_for_date(self, date):
"""Retourne la liste des utilisateurs ayant agi a une date donnee, tries par nb d'actions."""
log_path = self.config.get(
"amadea_log_path",
r"C:\ProgramData\ISoft\Amadea Web 8 x64\data\logs"
)
if not os.path.isdir(log_path):
return []
date_str = date.strftime("%y-%m-%d")
cutoff = datetime(date.year, date.month, date.day)
files = _log_files_for_date(log_path, "awevents", date_str)
if not files:
return []
users = {}
hourly = {h: set() for h in range(24)}
for filepath in files:
content = _read_log_file(filepath)
if content:
for line in content.splitlines():
self._parse_awevents_line(line, users, cutoff, hourly)
result = sorted(users.values(), key=lambda u: -u.get("action_count_24h", 0))
output = []
for u in result:
duration = None
if u.get("connected_since") and u.get("last_action_time"):
mins = int((u["last_action_time"] - u["connected_since"]).total_seconds() / 60)
if mins >= 60:
duration = f"{mins // 60}h{mins % 60:02d}"
else:
duration = f"{mins}min"
output.append({
"login": u["login"],
"last_action_time": u["last_action_time"].strftime("%H:%M:%S") if u.get("last_action_time") else None,
"last_action_label": u.get("last_action_label", ""),
"action_count": u.get("action_count_24h", 0),
"first_action_time": u["connected_since"].strftime("%H:%M") if u.get("connected_since") else None,
"duration": duration,
})
return output
def get_weekly_activity(self):
"""Retourne le nombre max d'utilisateurs actifs simultanes par jour (7 derniers jours)."""
log_path = self.config.get(
@@ -213,20 +295,19 @@ class UserMonitor:
continue
hourly = {h: set() for h in range(24)}
for filepath in files:
try:
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
for line in f:
m = re.match(
r'^(\d{4}-\d{2}-\d{2} (\d{2}):\d{2}:\d{2}).*login=([^,]+),',
line
)
if m:
hour = int(m.group(2))
login = m.group(3).strip()
if login:
hourly[hour].add(login)
except (PermissionError, OSError):
content = _read_log_file(filepath)
if not content:
continue
for line in content.splitlines():
m = re.match(
r'^(\d{4}-\d{2}-\d{2} (\d{2}):\d{2}:\d{2}).*login=([^,]+),',
line
)
if m:
hour = int(m.group(2))
login = m.group(3).strip()
if login:
hourly[hour].add(login)
max_concurrent = max((len(v) for v in hourly.values()), default=0)
result.append({"date": day.isoformat(), "count": max_concurrent})
return result