feat(dashboard): session cleaner intégré + auth + nettoyage UI

- Onglet "🧹 Nettoyage" dans le dashboard (iframe vers port 5006)
- Indicateur d'état + bouton de démarrage si cleaner down
- Service systemd rpa-session-cleaner intégré au target rpa-vision
- svc.sh et services.conf incluent session-cleaner (port 5006)

P0-A — Auth dashboard Flask :
- HTTP Basic obligatoire sur tous les endpoints (sauf /health, /healthz)
- Credentials via DASHBOARD_USER + DASHBOARD_PASSWORD
- 13 tests

Nettoyage UI :
- Section "Détection Visuelle" OWL retirée (modèle remplacé par pipeline VLM)
- Dashboard préfère auto shot_*_blurred.png (avec ?raw=1 pour brut)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-14 16:48:36 +02:00
parent f7b8cddd2b
commit bb4ed2a75d
7 changed files with 507 additions and 67 deletions

View File

@@ -7,8 +7,16 @@ Fonctionnalités:
- Gestion des workflows
- Visualisation des sessions et screenshots
- Graphiques de performance
Sécurité (Fix P0-A) :
- HTTP Basic Auth sur tous les endpoints (middleware before_request).
- Credentials via DASHBOARD_USER / DASHBOARD_PASSWORD.
- Exceptions : /health, /healthz, /api/health (monitoring externe).
- Désactivation auth en dev local : DASHBOARD_AUTH_DISABLED=true
"""
import base64
import hmac
import os
import sys
import json
@@ -41,9 +49,125 @@ app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-key-change-in-production
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
# =============================================================================
# Fix P0-A : HTTP Basic Auth sur le dashboard (port 5001)
# =============================================================================
# Avant ce fix, 71 endpoints étaient exposés sans authentification.
# On ajoute un middleware Flask (before_request) qui exige un header
# Authorization: Basic <b64>. Les credentials sont pris dans l'environnement.
#
# Chemins publics (pas de challenge) : healthcheck uniquement — ils servent au
# monitoring externe (Prometheus, systemd, k8s, NPM reverse proxy).
#
# Pour désactiver l'auth (dev local, tests) : DASHBOARD_AUTH_DISABLED=true.
# Les tests unitaires définissent cette variable via un flag Flask config.
_DASHBOARD_USER = os.getenv("DASHBOARD_USER", "lea").strip()
_DASHBOARD_PASSWORD = os.getenv("DASHBOARD_PASSWORD", "").strip()
_DASHBOARD_AUTH_DISABLED = os.getenv("DASHBOARD_AUTH_DISABLED", "").lower() in (
"1", "true", "yes",
)
# Si pas de password défini en env ET auth pas explicitement désactivée →
# on utilise un mot de passe par défaut "safe" (long, random-ish) ET on log
# un WARNING très visible au démarrage pour forcer Dom à le configurer
# avant un déploiement prod. On ne veut surtout pas générer un mot de passe
# aléatoire à chaque boot (même problème que l'API token auto-généré).
if not _DASHBOARD_PASSWORD and not _DASHBOARD_AUTH_DISABLED:
_DASHBOARD_PASSWORD = "changeme-dashboard-Medecin2026!"
api_logger.warning(
"[SÉCURITÉ] DASHBOARD_PASSWORD non défini en env — utilisation d'un "
"mot de passe par défaut temporaire. DÉFINIR DASHBOARD_PASSWORD "
"AVANT TOUT DÉPLOIEMENT (identifiant : DASHBOARD_USER)."
)
# Paths publics (pas d'auth, pour healthchecks externes)
_PUBLIC_DASHBOARD_PATHS = {
"/health",
"/healthz",
"/api/health",
}
def _dashboard_auth_ok(header_value: str) -> bool:
"""Valide le header Authorization Basic. Comparaison constant-time."""
if not header_value or not header_value.lower().startswith("basic "):
return False
try:
decoded = base64.b64decode(header_value[6:].strip()).decode("utf-8")
except (ValueError, UnicodeDecodeError):
return False
if ":" not in decoded:
return False
user, _, password = decoded.partition(":")
# Comparaison constant-time pour éviter les timing attacks.
user_ok = hmac.compare_digest(user, _DASHBOARD_USER)
pwd_ok = hmac.compare_digest(password, _DASHBOARD_PASSWORD)
return user_ok and pwd_ok
@app.before_request
def _dashboard_basic_auth_middleware():
"""Middleware d'auth HTTP Basic sur tous les endpoints HTTP du dashboard.
- Bypass complet si DASHBOARD_AUTH_DISABLED=true (dev/tests).
- Bypass complet si app.config['TESTING'] (pytest) et qu'aucun credential
n'est passé : les tests existants du dashboard doivent continuer de
passer sans retoucher chaque fixture.
- Paths dans _PUBLIC_DASHBOARD_PATHS : toujours publics (healthchecks).
- Sinon : header Authorization: Basic <b64> obligatoire.
Note WebSocket : Flask-SocketIO utilise son propre canal pour le handshake.
Le before_request ci-dessus s'applique à la requête HTTP de l'upgrade
(compatible mode threading). Les sockets post-handshake ne passent pas par
Flask, c'est acceptable pour un MVP (le client doit avoir passé l'auth HTTP).
"""
# Dev / tests : bypass total
if _DASHBOARD_AUTH_DISABLED:
return None
if app.config.get("TESTING") and not app.config.get("DASHBOARD_AUTH_ENABLED"):
return None
path = request.path or "/"
if path in _PUBLIC_DASHBOARD_PATHS:
return None
header_value = request.headers.get("Authorization", "")
if _dashboard_auth_ok(header_value):
return None
# Pas authentifié — challenge 401 avec WWW-Authenticate
return Response(
'{"error": "authentication required"}',
status=401,
mimetype="application/json",
headers={"WWW-Authenticate": 'Basic realm="RPA Vision V3 Dashboard"'},
)
@app.get('/healthz')
def healthz():
"""Healthcheck minimal (systemd/k8s)."""
"""Healthcheck minimal (systemd/k8s). Public — pas d'auth."""
return jsonify({
'status': 'ok',
'service': 'rpa-vision-v3-dashboard',
'timestamp': datetime.now().isoformat(),
})
@app.get('/api/health')
def api_health():
"""Healthcheck JSON public — pas d'auth (monitoring externe)."""
return jsonify({
'status': 'ok',
'service': 'rpa-vision-v3-dashboard',
'timestamp': datetime.now().isoformat(),
})
@app.get('/health')
def health():
"""Healthcheck public — pas d'auth (NPM reverse proxy)."""
return jsonify({
'status': 'ok',
'service': 'rpa-vision-v3-dashboard',
@@ -532,21 +656,29 @@ def get_session(session_id):
@app.route('/api/agent/sessions/<session_id>/screenshot/<filename>')
def get_screenshot(session_id, filename):
"""Récupère un screenshot."""
"""Récupère un screenshot.
Par défaut, si une version floutée (PII) `<stem>_blurred.png` existe à côté
du fichier demandé, elle est servie à la place (affichage conforme RGPD).
Pour obtenir la version brute, passer `?raw=1` (réservé aux endpoints
d'entraînement/grounding, à protéger par auth).
"""
try:
want_raw = request.args.get('raw', '0') in ('1', 'true', 'yes')
# Chercher le screenshot dans tous les répertoires
screenshot_path = None
for dir_path in SESSIONS_PATH.iterdir():
if not dir_path.is_dir():
continue
# Chercher dans différents emplacements possibles
possible_paths = [
dir_path / "screenshots" / filename,
dir_path / "shots" / filename,
]
# Chercher aussi dans les sous-répertoires
for subdir in dir_path.iterdir():
if subdir.is_dir():
@@ -554,18 +686,26 @@ def get_screenshot(session_id, filename):
subdir / "screenshots" / filename,
subdir / "shots" / filename,
])
for path in possible_paths:
if path.exists():
screenshot_path = path
break
if screenshot_path:
break
if not screenshot_path:
return jsonify({'error': 'Screenshot non trouvé'}), 404
# Préférer la version floutée si dispo et si l'appelant ne demande pas le brut
if not want_raw and "_blurred" not in screenshot_path.stem:
blurred_candidate = screenshot_path.with_name(
f"{screenshot_path.stem}_blurred{screenshot_path.suffix}"
)
if blurred_candidate.is_file():
screenshot_path = blurred_candidate
return send_file(screenshot_path, mimetype='image/png')
except Exception as e:
return jsonify({'error': str(e)}), 500
@@ -1630,6 +1770,14 @@ SERVICES_CONFIG = {
"url": "http://localhost:5005",
"icon": "📡"
},
"session_cleaner": {
"name": "Session Cleaner",
"description": "Nettoyage de sessions avant replay (dépend du Streaming Server)",
"port": 5006,
"start_cmd": "cd {base} && {base}/.venv/bin/python3 tools/session_cleaner.py",
"url": "http://localhost:5006",
"icon": "🧹"
},
"web_dashboard": {
"name": "Dashboard (ce service)",
"description": "Panneau de contrôle RPA Vision V3",

View File

@@ -69,6 +69,7 @@
<div class="tab" onclick="switchTab('corrections')">🔧 Corrections</div>
<div class="tab" onclick="switchTab('learning')">🧠 Apprentissage</div>
<div class="tab" onclick="switchTab('config')">🔧 Configuration</div>
<div class="tab" onclick="switchTab('cleaner')">🧹 Nettoyage</div>
</div>
<div class="container">
@@ -783,6 +784,14 @@
<button class="btn btn-small btn-primary" onclick="testConnection('service', 'upload_api')">Test</button>
</div>
</div>
<div class="config-item">
<label>Session Cleaner (port 5006)</label>
<div style="display:flex;gap:10px;">
<input type="text" id="cfg_session_cleaner_host" placeholder="localhost" class="config-input" style="flex:2;">
<input type="number" id="cfg_session_cleaner_port" placeholder="5006" class="config-input" style="flex:1;">
<button class="btn btn-small btn-primary" onclick="testConnection('service', 'session_cleaner')">Test</button>
</div>
</div>
</div>
</div>
@@ -834,37 +843,6 @@
</div>
<div class="grid grid-2">
<!-- Section Detection -->
<div class="card">
<h2><span class="icon">👁️</span> Detection Visuelle</h2>
<div id="configDetection" class="config-section">
<div class="config-item">
<label>Modele OWL</label>
<select id="cfg_detection_owl_model" class="config-input">
<option value="google/owlv2-base-patch16-ensemble">OWLv2 Base Ensemble</option>
<option value="google/owlv2-large-patch14-ensemble">OWLv2 Large Ensemble</option>
<option value="google/owlvit-base-patch32">OWLViT Base</option>
</select>
</div>
<div class="config-item">
<label>Seuil de confiance</label>
<input type="range" id="cfg_detection_confidence" min="0.1" max="0.9" step="0.05" value="0.3" class="config-input" oninput="document.getElementById('confValue').textContent = this.value">
<span id="confValue" style="color:#3b82f6;margin-left:10px;">0.3</span>
</div>
<div class="config-item">
<label>Seuil NMS</label>
<input type="range" id="cfg_detection_nms" min="0.1" max="0.9" step="0.05" value="0.3" class="config-input" oninput="document.getElementById('nmsValue').textContent = this.value">
<span id="nmsValue" style="color:#3b82f6;margin-left:10px;">0.3</span>
</div>
<div class="config-item">
<label>
<input type="checkbox" id="cfg_detection_use_gpu" checked>
Utiliser GPU (CUDA)
</label>
</div>
</div>
</div>
<!-- Section Base de donnees -->
<div class="card">
<h2><span class="icon">💾</span> Base de Donnees</h2>
@@ -896,9 +874,7 @@
</div>
</div>
</div>
</div>
<div class="grid grid-2">
<!-- Section Securite -->
<div class="card">
<h2><span class="icon">🔒</span> Securite</h2>
@@ -963,6 +939,46 @@
</div>
</div>
</div>
<!-- Tab: Nettoyage de sessions (iframe vers session_cleaner port 5006) -->
<div id="tab-cleaner" class="tab-content">
<div class="card" style="margin-bottom:20px;background:linear-gradient(135deg, #1e293b 0%, #0f172a 100%);">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:15px;">
<div>
<h2 style="margin-bottom:5px;color:#e2e8f0;"><span class="icon">🧹</span> Nettoyage de sessions avant replay</h2>
<p style="color:#64748b;font-size:13px;">Visualisez les sessions, supprimez les clics parasites et regénérez un replay propre (Session Cleaner, port 5006)</p>
</div>
<div style="display:flex;gap:10px;">
<button class="btn btn-primary" onclick="refreshCleanerFrame()">🔄 Recharger</button>
<a class="btn btn-secondary" href="http://localhost:5006" target="_blank" rel="noopener">↗ Ouvrir dans un onglet</a>
</div>
</div>
</div>
<!-- Message d'état (affiché si le service n'est pas démarré) -->
<div id="cleanerOfflineNotice" class="card" style="display:none;margin-bottom:20px;border:1px solid #ef4444;">
<div style="display:flex;align-items:center;gap:20px;flex-wrap:wrap;">
<div style="font-size:48px;">⚠️</div>
<div style="flex:1;min-width:250px;">
<h3 style="color:#ef4444;margin-bottom:8px;">Session Cleaner non démarré</h3>
<p style="color:#94a3b8;font-size:13px;">Le service sur le port 5006 ne répond pas. Démarrez-le pour accéder à l'interface de nettoyage.</p>
</div>
<div style="display:flex;gap:10px;">
<button class="btn btn-success" onclick="startCleanerService()" id="btnStartCleaner">▶️ Démarrer le cleaner</button>
<button class="btn btn-secondary" onclick="switchTab('services')">🎛️ Gérer les services</button>
</div>
</div>
</div>
<!-- iframe vers le cleaner -->
<div id="cleanerFrameContainer" class="card" style="padding:0;overflow:hidden;">
<iframe
id="cleanerFrame"
src="about:blank"
style="width:100%;height:85vh;min-height:800px;border:0;border-radius:12px;background:#0f172a;"
title="Session Cleaner"></iframe>
</div>
</div>
</div>
<style>
@@ -1199,6 +1215,73 @@
if (tabName === 'corrections') refreshCorrectionPacks();
if (tabName === 'learning') refreshLearningStats();
if (tabName === 'config') refreshConfig();
if (tabName === 'cleaner') checkCleanerStatus();
}
// === Session Cleaner (iframe vers port 5006) ===
const CLEANER_URL = 'http://localhost:5006';
async function checkCleanerStatus() {
const notice = document.getElementById('cleanerOfflineNotice');
const frameContainer = document.getElementById('cleanerFrameContainer');
const frame = document.getElementById('cleanerFrame');
if (!notice || !frameContainer || !frame) return;
try {
const res = await fetch('/api/services/session_cleaner/status');
const data = await res.json();
const running = data && data.status === 'running';
if (running) {
notice.style.display = 'none';
frameContainer.style.display = 'block';
// Charger l'iframe seulement si ce n'est pas déjà fait
if (frame.src === 'about:blank' || !frame.src.startsWith(CLEANER_URL)) {
frame.src = CLEANER_URL;
}
} else {
notice.style.display = 'block';
frameContainer.style.display = 'none';
frame.src = 'about:blank';
}
} catch (err) {
console.error('checkCleanerStatus error:', err);
notice.style.display = 'block';
frameContainer.style.display = 'none';
}
}
function refreshCleanerFrame() {
const frame = document.getElementById('cleanerFrame');
if (!frame) return;
// Forcer un rechargement (cache busting)
frame.src = CLEANER_URL + '?t=' + Date.now();
}
async function startCleanerService() {
const btn = document.getElementById('btnStartCleaner');
if (btn) {
btn.disabled = true;
btn.textContent = '⏳ Démarrage...';
}
try {
const res = await fetch('/api/services/session_cleaner/start', { method: 'POST' });
const data = await res.json();
if (!res.ok) {
alert('Erreur : ' + (data.error || 'démarrage impossible'));
} else {
// Laisser le temps au service de démarrer
await new Promise(r => setTimeout(r, 1500));
}
} catch (err) {
alert('Erreur réseau : ' + err.message);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = '▶️ Démarrer le cleaner';
}
await checkCleanerStatus();
}
}
// Update execution UI
@@ -2852,15 +2935,7 @@
refreshOllamaModels();
}
// Detection
if (config.detection) {
document.getElementById('cfg_detection_owl_model').value = config.detection.owl_model || 'google/owlv2-base-patch16-ensemble';
document.getElementById('cfg_detection_confidence').value = config.detection.confidence_threshold || 0.3;
document.getElementById('confValue').textContent = config.detection.confidence_threshold || 0.3;
document.getElementById('cfg_detection_nms').value = config.detection.nms_threshold || 0.3;
document.getElementById('nmsValue').textContent = config.detection.nms_threshold || 0.3;
document.getElementById('cfg_detection_use_gpu').checked = config.detection.use_gpu !== false;
}
// Detection (OWL-v2 legacy) — section UI retiree, config preservee telle quelle
// Database
if (config.database) {
@@ -2936,12 +3011,14 @@
model: document.getElementById('cfg_vlm_model').value,
description: 'Modele VLM pour l\'analyse visuelle'
},
detection: {
owl_model: document.getElementById('cfg_detection_owl_model').value,
confidence_threshold: parseFloat(document.getElementById('cfg_detection_confidence').value),
nms_threshold: parseFloat(document.getElementById('cfg_detection_nms').value),
use_gpu: document.getElementById('cfg_detection_use_gpu').checked,
description: 'Configuration du detecteur visuel OWL-v2'
// Detection: section UI retiree (OWL-v2 remplace par pipeline VLM).
// On preserve la config existante pour le fallback eventuel.
detection: currentConfig.detection || {
owl_model: 'google/owlv2-base-patch16-ensemble',
confidence_threshold: 0.3,
nms_threshold: 0.3,
use_gpu: true,
description: 'Configuration legacy du detecteur visuel OWL-v2 (fallback)'
},
embedding: currentConfig.embedding || {
model: 'clip',