Files
Dom 82d7b38cff
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
feat(dashboard): page Base de connaissances — métriques FAISS, sessions, patterns
Nouvelle page /knowledge-base avec :
- Mémoire visuelle : 331 vecteurs FAISS / 13666 embeddings (alerte consolidation)
- Sessions observées : 56 sessions, 6.66 Go, 3 machines
- Réflexes natifs : 16 patterns UI en 6 catégories
- Workflows appris : 29

Onglet 📚 Connaissances ajouté dans toute la navigation.
Tout en français, dark theme, zéro jargon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:41:23 +02:00

2996 lines
154 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RPA Vision V3 - Dashboard</title>
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; }
.header { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); color: white; padding: 20px 30px; display: flex; justify-content: space-between; align-items: center; }
.header h1 { font-size: 24px; display: flex; align-items: center; gap: 10px; }
.header .status { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: #22c55e; animation: pulse 2s infinite; }
.status-dot.offline { background: #ef4444; animation: none; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.tabs { background: #1e293b; border-bottom: 1px solid #334155; display: flex; padding: 0 20px; }
.tab { padding: 15px 25px; cursor: pointer; border-bottom: 3px solid transparent; transition: all 0.2s; color: #94a3b8; font-weight: 500; }
.tab:hover { color: #e2e8f0; background: #334155; }
.tab.active { border-bottom-color: #3b82f6; color: #3b82f6; }
.container { max-width: 1600px; margin: 0 auto; padding: 20px; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-bottom: 20px; }
.grid-2 { grid-template-columns: 1fr 1fr; }
.grid-3 { grid-template-columns: 1fr 1fr 1fr; }
.grid-4 { grid-template-columns: repeat(4, 1fr); }
.card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; }
.card h2 { font-size: 16px; margin-bottom: 15px; color: #94a3b8; display: flex; align-items: center; gap: 8px; }
.card h2 .icon { font-size: 20px; }
.stat-card { text-align: center; }
.stat-value { font-size: 36px; font-weight: bold; color: #3b82f6; }
.stat-label { font-size: 12px; color: #64748b; margin-top: 5px; text-transform: uppercase; }
</style>
</head>
<body>
<div class="header">
<h1>🚀 RPA Vision V3 Dashboard</h1>
<div style="display:flex;align-items:center;gap:20px;">
<div id="fleetBadge" style="background:rgba(255,255,255,0.15);padding:6px 14px;border-radius:20px;font-size:13px;color:rgba(255,255,255,0.9);cursor:pointer;" onclick="switchTab('fleet')" title="Agents connectés">
🖥 <span id="fleetBadgeCount">-- agents</span>
</div>
<div class="status">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Connecté</span>
</div>
</div>
</div>
<div class="tabs">
<div class="tab active" onclick="switchTab('services')">🎛️ Services</div>
<div class="tab" onclick="switchTab('fleet')">🖥 Fleet</div>
<div class="tab" onclick="switchTab('workflows')">🔄 Workflows</div>
<div class="tab" onclick="switchTab('sessions')">📦 Sessions</div>
<div class="tab" onclick="switchTab('performance')">📈 Performance</div>
<div class="tab" onclick="switchTab('streaming')">📡 Streaming</div>
<div class="tab" onclick="switchTab('logs')">📄 Logs</div>
<div class="tab" onclick="switchTab('backups')">💾 Sauvegardes</div>
<div class="tab" onclick="switchTab('config')">🔧 Configuration</div>
<div class="tab" onclick="switchTab('cleaner')">🧹 Nettoyage</div>
<a class="tab" href="/process-mining" style="text-decoration:none;color:#94a3b8;">🗺️ Cartographie</a>
<a class="tab" href="/knowledge-base" style="text-decoration:none;color:#94a3b8;">📚 Connaissances</a>
</div>
<div class="container">
<!-- Tab: Services (Panneau de contrôle pour démos) -->
<div id="tab-services" class="tab-content active">
<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> Panneau de Contrôle des Services</h2>
<p style="color:#64748b;font-size:13px;">Gérez tous les services RPA Vision V3 depuis cette interface</p>
</div>
<div style="display:flex;gap:10px;">
<button class="btn btn-success" onclick="startAllServices()">▶️ Tout Démarrer</button>
<button class="btn btn-danger" onclick="stopAllServices()">⏹️ Tout Arrêter</button>
<button class="btn btn-primary" onclick="refreshServices()">🔄 Actualiser</button>
</div>
</div>
</div>
<div class="grid grid-2" id="servicesGrid">
<div class="loading"><div class="spinner"></div>Chargement des services...</div>
</div>
<!-- Liens rapides -->
<div class="card" style="margin-top:20px;">
<h2><span class="icon">🔗</span> Ouvrir dans le Navigateur</h2>
<div style="display:flex;flex-wrap:wrap;gap:15px;margin-top:15px;" id="quickLinks">
<!-- Généré dynamiquement -->
</div>
</div>
<!-- Système & Version (fusionné depuis l'ancien onglet Système) -->
<div class="card" style="margin-top:20px;">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:15px;">
<h2 style="margin-bottom:0;"><span class="icon">⚙️</span> Système & Version</h2>
<button class="btn btn-primary btn-small" onclick="refreshSystemInfo()">🔄 Actualiser</button>
</div>
</div>
<div class="grid grid-3" style="margin-top:15px;">
<div class="card stat-card">
<div class="stat-value" id="sysVersion">-</div>
<div class="stat-label">Version</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="sysCommit">-</div>
<div class="stat-label">Commit</div>
</div>
<div class="card stat-card">
<div id="sysUpdateStatus" style="font-size:24px;"></div>
<div class="stat-label">Mise à jour</div>
</div>
</div>
<div class="grid grid-2" style="margin-top:15px;">
<div class="card">
<h2><span class="icon">🖥️</span> Informations système</h2>
<div class="system-info" id="systemInfoDetails">
<div class="loading"><div class="spinner"></div>Chargement...</div>
</div>
</div>
<div class="card">
<h2><span class="icon">⏮️</span> Points de restauration</h2>
<div class="actions-bar">
<button class="btn btn-success btn-small" onclick="createVersionBackup()"> Créer un point</button>
</div>
<div class="backup-list" id="versionBackupsList">
<div class="loading"><div class="spinner"></div>Chargement...</div>
</div>
</div>
</div>
<div class="card" style="margin-top:15px;">
<h2><span class="icon">📤</span> Mise à jour manuelle</h2>
<div style="display:flex;gap:20px;align-items:center;flex-wrap:wrap;">
<div style="flex:1;min-width:300px;">
<p style="color:#94a3b8;margin-bottom:15px;">Uploadez un package de mise à jour (.zip) contenant un manifest valide</p>
<input type="file" id="updateFileInput" accept=".zip" style="display:none;" onchange="uploadUpdatePackage()">
<button class="btn btn-warning" onclick="document.getElementById('updateFileInput').click()">
📤 Uploader un package
</button>
</div>
<div id="updatePackageInfo" style="flex:1;min-width:300px;padding:15px;background:#0f172a;border-radius:8px;display:none;">
</div>
</div>
</div>
</div>
<!-- Onglets Vue d'ensemble et Exécution retirés (doublons / legacy) -->
<!-- Tab: Workflows -->
<div id="tab-workflows" class="tab-content">
<div class="card">
<h2><span class="icon">🔄</span> Workflows disponibles</h2>
<div class="actions-bar" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
<div style="display:flex;gap:10px;align-items:center;">
<button class="btn btn-primary btn-small" onclick="refreshWorkflows()">🔄 Actualiser</button>
<label style="display:flex;align-items:center;gap:5px;font-size:13px;color:#94a3b8;cursor:pointer;">
<input type="checkbox" id="hideUnnamedWorkflows" checked onchange="refreshWorkflows()">
Masquer "Unnamed Workflow"
</label>
</div>
<div style="display:flex;gap:10px;align-items:center;">
<span id="unnamedWorkflowsInfo" style="font-size:12px;color:#f59e0b;"></span>
<button class="btn btn-warning btn-small" onclick="cleanupUnnamedWorkflows()" id="btnCleanupWorkflows" style="display:none;">
🗑️ Supprimer invalides
</button>
</div>
</div>
<div class="workflow-grid" id="workflowList">
<div class="loading"><div class="spinner"></div>Chargement...</div>
</div>
</div>
<!-- Sous-section : Chaînes de Workflows -->
<div class="card" style="margin-top:20px;">
<h2><span class="icon">🔗</span> Chaînes de Workflows</h2>
<div class="actions-bar">
<button class="btn btn-primary btn-small" onclick="refreshChains()">🔄 Actualiser</button>
<button class="btn btn-success btn-small" onclick="showCreateChainModal()"> Nouvelle Chaîne</button>
</div>
<div class="workflow-grid" id="chainList">
<div class="loading"><div class="spinner"></div>Chargement...</div>
</div>
</div>
<!-- Sous-section : Déclencheurs -->
<div class="card" style="margin-top:20px;">
<h2><span class="icon"></span> Déclencheurs</h2>
<div class="actions-bar">
<button class="btn btn-primary btn-small" onclick="refreshTriggers()">🔄 Actualiser</button>
<button class="btn btn-success btn-small" onclick="showCreateTriggerModal()"> Nouveau Déclencheur</button>
</div>
<div class="workflow-grid" id="triggerList">
<div class="loading"><div class="spinner"></div>Chargement...</div>
</div>
</div>
</div>
<!-- Tab: Sessions -->
<div id="tab-sessions" class="tab-content">
<div class="card">
<h2><span class="icon">📦</span> Sessions Agent</h2>
<div class="actions-bar" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
<div style="display:flex;gap:10px;align-items:center;">
<button class="btn btn-primary btn-small" onclick="refreshSessions()">🔄 Actualiser</button>
<label style="display:flex;align-items:center;gap:5px;font-size:13px;color:#94a3b8;cursor:pointer;">
<input type="checkbox" id="hideEmptySessions" checked onchange="refreshSessions()">
Masquer sessions vides
</label>
</div>
<div style="display:flex;gap:10px;align-items:center;">
<span id="emptySessionsInfo" style="font-size:12px;color:#f59e0b;"></span>
<button class="btn btn-warning btn-small" onclick="cleanupEmptySessions()" id="btnCleanup" style="display:none;">
🗑️ Supprimer vides
</button>
</div>
</div>
<div class="session-list" id="sessionList">
<div class="loading"><div class="spinner"></div>Chargement...</div>
</div>
</div>
<!-- Modal pour les screenshots -->
<div id="screenshotModal" class="modal" style="display:none;">
<div class="modal-content">
<span class="modal-close" onclick="closeModal()">&times;</span>
<h3 id="modalTitle">Screenshots de la session</h3>
<div class="screenshot-gallery" id="screenshotGallery"></div>
</div>
</div>
</div>
<!-- Tab: Performance -->
<div id="tab-performance" class="tab-content">
<!-- FAISS Status Panel -->
<div class="card" style="margin-bottom:20px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<h2><span class="icon">🗄️</span> État de l'Index FAISS</h2>
<button class="btn btn-primary btn-small" onclick="testFaissIndex()" id="btnTestFaiss">
🧪 Tester l'index
</button>
</div>
<div class="grid grid-4" style="margin-top:15px;">
<div style="text-align:center;cursor:help;" title="État de l'index FAISS&#10;✅ Actif = Index chargé et fonctionnel&#10;⚠️ Non trouvé = Aucun index créé&#10;❌ Erreur = Problème de chargement">
<div id="faissStatus" style="font-size:24px;"></div>
<div style="font-size:12px;color:#64748b;margin-top:5px;">Status <span style="color:#3b82f6;"></span></div>
</div>
<div style="text-align:center;cursor:help;" title="Nombre de vecteurs dans l'index&#10;Chaque cible cliquée génère un vecteur&#10;Plus il y a de vecteurs, plus la reconnaissance est précise">
<div id="faissVectors" style="font-size:24px;color:#3b82f6;font-weight:bold;">0</div>
<div style="font-size:12px;color:#64748b;margin-top:5px;">Vecteurs <span style="color:#3b82f6;"></span></div>
</div>
<div style="text-align:center;cursor:help;" title="Taille des vecteurs d'embedding&#10;512 = CLIP ViT-B/32 (standard)&#10;768 = CLIP ViT-L/14 (plus précis)">
<div id="faissDimensions" style="font-size:24px;color:#8b5cf6;font-weight:bold;">-</div>
<div style="font-size:12px;color:#64748b;margin-top:5px;">Dimensions <span style="color:#3b82f6;"></span></div>
</div>
<div style="text-align:center;cursor:help;" title="Type d'index FAISS&#10;Flat = Recherche exhaustive (précis, lent sur gros volumes)&#10;IVF = Index inversé (rapide, recommandé > 10k vecteurs)">
<div id="faissIndexType" style="font-size:24px;color:#f59e0b;font-weight:bold;">-</div>
<div style="font-size:12px;color:#64748b;margin-top:5px;">Type <span style="color:#3b82f6;"></span></div>
</div>
</div>
<div style="margin-top:15px;padding:10px;background:#0f172a;border-radius:8px;font-size:13px;color:#94a3b8;">
<span id="faissDetails">Chargement des détails FAISS...</span>
</div>
<!-- Zone de recommandations -->
<div id="faissRecommendations" style="display:none;margin-top:10px;padding:10px;background:#422006;border:1px solid #f59e0b;border-radius:8px;font-size:13px;color:#fbbf24;">
</div>
<!-- Résultats du test -->
<div id="faissTestResults" style="display:none;margin-top:15px;padding:15px;background:#0f172a;border-radius:8px;">
</div>
</div>
<div class="grid grid-3">
<div class="card stat-card">
<div class="stat-value" id="perfEmbeddings">0</div>
<div class="stat-label">Embeddings</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="perfCacheHits">0%</div>
<div class="stat-label">Cache Hit Rate</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="perfAvgTime">0ms</div>
<div class="stat-label">Temps moyen</div>
</div>
</div>
<div class="grid grid-2">
<div class="card">
<h2><span class="icon">🔍</span> FAISS Performance</h2>
<canvas id="faissChart" height="250"></canvas>
</div>
<div class="card">
<h2><span class="icon">💾</span> Cache Performance</h2>
<canvas id="cacheChart" height="250"></canvas>
</div>
</div>
<!-- Métriques Prometheus (fusionné depuis l'ancien onglet Métriques) -->
<div class="grid grid-4" style="margin-top:20px;">
<div class="card stat-card">
<div class="stat-value" id="metricWorkflows">0</div>
<div class="stat-label">Workflows Exécutés</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="metricChains">0</div>
<div class="stat-label">Chaînes Exécutées</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="metricTriggers">0</div>
<div class="stat-label">Triggers Activés</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="metricErrors">0%</div>
<div class="stat-label">Taux d'Erreur</div>
</div>
</div>
<!-- Carte Automatisation retirée (routes /api/automation supprimées) -->
<div class="card" style="margin-top:20px;">
<h2><span class="icon">📊</span> Métriques Prometheus</h2>
<div class="actions-bar">
<button class="btn btn-primary btn-small" onclick="refreshMetrics()">🔄 Actualiser</button>
<button class="btn btn-secondary btn-small" onclick="window.open('/metrics', '_blank')">🔗 Endpoint /metrics</button>
</div>
<div class="test-output" id="metricsOutput">Chargement des métriques...</div>
</div>
</div>
<!-- Tab: Streaming -->
<div id="tab-streaming" 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> Streaming & Capture Temps Réel</h2>
<p style="color:#64748b;font-size:13px;">Suivi des sessions de streaming, workflows générés et statistiques du serveur de capture</p>
</div>
<div style="display:flex;gap:10px;">
<button class="btn btn-primary" onclick="refreshStreaming()">🔄 Actualiser</button>
</div>
</div>
</div>
<!-- Stats du serveur streaming -->
<div class="grid grid-4">
<div class="card stat-card">
<div class="stat-value" id="streamActiveSessions">-</div>
<div class="stat-label">Sessions actives</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="streamTotalEvents">-</div>
<div class="stat-label">Événements totaux</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="streamWorkflowsBuilt">-</div>
<div class="stat-label">Workflows construits</div>
</div>
<div class="card stat-card">
<div id="streamServerStatus" style="font-size:24px;"></div>
<div class="stat-label">Serveur streaming</div>
</div>
</div>
<div class="grid grid-2" style="margin-top:20px;">
<!-- Sessions de streaming actives -->
<div class="card">
<h2><span class="icon">🎬</span> Sessions de streaming actives</h2>
<div id="streamSessionsList" class="session-list" style="max-height:400px;">
<div class="loading"><div class="spinner"></div>Chargement...</div>
</div>
</div>
<!-- Workflows construits par le streaming -->
<div class="card">
<h2><span class="icon">🔄</span> Workflows construits par le streaming</h2>
<div id="streamWorkflowsList" class="session-list" style="max-height:400px;">
<div class="loading"><div class="spinner"></div>Chargement...</div>
</div>
</div>
</div>
<!-- Détails du serveur -->
<div class="card" style="margin-top:20px;">
<h2><span class="icon">📊</span> Statistiques détaillées du serveur</h2>
<div class="live-stats" id="streamServerDetails">
<div class="loading"><div class="spinner"></div>Chargement...</div>
</div>
</div>
</div>
<!-- Tab: Logs -->
<div id="tab-logs" class="tab-content">
<div class="card">
<h2><span class="icon">📄</span> Logs Système</h2>
<div class="actions-bar">
<button class="btn btn-primary btn-small" onclick="refreshLogs()">🔄 Actualiser</button>
<button class="btn btn-success btn-small" onclick="downloadLogs()">📥 Télécharger ZIP</button>
</div>
<div class="test-output" id="logsOutput" style="max-height: 600px;">Chargement des logs...</div>
</div>
</div>
<!-- Tab: Tests -->
<!-- Onglet Tests retiré (RCE potentielle via subprocess pytest) -->
<!-- Tab: Sauvegardes -->
<div id="tab-backups" 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> Sauvegardes & Export</h2>
<p style="color:#64748b;font-size:13px;">Exportez et téléchargez vos données pour la sauvegarde ou le transfert</p>
</div>
<button class="btn btn-primary" onclick="refreshBackupStats()">🔄 Actualiser</button>
</div>
</div>
<div class="grid grid-4" id="backupStats">
<div class="card stat-card">
<div class="stat-value" id="statWorkflowsBackup">-</div>
<div class="stat-label">Workflows</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="statCorrectionsBackup">-</div>
<div class="stat-label">Corrections</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="statModelsBackup">-</div>
<div class="stat-label">Modèles</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="statSessionsBackup">-</div>
<div class="stat-label">Sessions</div>
</div>
</div>
<div class="grid grid-2">
<div class="card">
<h2><span class="icon">📦</span> Export par catégorie</h2>
<div class="backup-list">
<div class="backup-item" onclick="downloadBackup('workflows')">
<div class="backup-icon">🔄</div>
<div class="backup-info">
<h4>Workflows</h4>
<p>Tous les workflows et templates</p>
</div>
<button class="btn btn-primary btn-small">⬇️ Télécharger</button>
</div>
<div class="backup-item" onclick="downloadBackup('correction-packs')">
<div class="backup-icon">🔧</div>
<div class="backup-info">
<h4>Correction Packs</h4>
<p>Packs de corrections cross-workflow</p>
</div>
<button class="btn btn-primary btn-small">⬇️ Télécharger</button>
</div>
<div class="backup-item" onclick="downloadBackup('trained-models')">
<div class="backup-icon">🧠</div>
<div class="backup-info">
<h4>Modèles entraînés</h4>
<p>Index FAISS et embeddings</p>
</div>
<button class="btn btn-primary btn-small">⬇️ Télécharger</button>
</div>
<div class="backup-item" onclick="downloadBackup('config')">
<div class="backup-icon">⚙️</div>
<div class="backup-info">
<h4>Configuration</h4>
<p>Paramètres système (secrets masqués)</p>
</div>
<button class="btn btn-primary btn-small">⬇️ Télécharger</button>
</div>
</div>
</div>
<div class="card">
<h2><span class="icon">📁</span> Backup complet</h2>
<div style="padding:20px;text-align:center;">
<div style="font-size:48px;margin-bottom:20px;">📥</div>
<p style="color:#94a3b8;margin-bottom:20px;">Téléchargez une archive complète de toutes vos données</p>
<button class="btn btn-success" style="font-size:16px;padding:15px 30px;" onclick="downloadFullBackup()">
⬇️ Télécharger le backup complet
</button>
<p style="color:#64748b;font-size:12px;margin-top:15px;">Inclut: workflows, corrections, modèles, configuration</p>
</div>
</div>
</div>
</div>
<!-- Onglet Corrections retiré (doublon VWB) -->
<!-- Onglet Apprentissage retiré (placeholders non alimentés) -->
<!-- Tab: Fleet Management -->
<div id="tab-fleet" 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> Fleet Management</h2>
<p style="color:#64748b;font-size:13px;">Gérez les postes collaborateurs déployés : enregistrement, révocation, suivi d'activité</p>
</div>
<div style="display:flex;gap:10px;">
<button class="btn btn-success" onclick="showEnrollModal()"> Enregistrer un agent</button>
<button class="btn btn-primary" onclick="refreshFleet()">🔄 Actualiser</button>
</div>
</div>
</div>
<div class="grid grid-4" id="fleetStats">
<div class="card stat-card">
<div class="stat-value" id="fleetActiveCount" style="color:#22c55e;">-</div>
<div class="stat-label">Agents actifs</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="fleetTotalCount">-</div>
<div class="stat-label">Total enregistrés</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="fleetUninstalledCount" style="color:#64748b;">-</div>
<div class="stat-label">Révoqués</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="fleetLastSeen">-</div>
<div class="stat-label">Dernière activité</div>
</div>
</div>
<!-- Tableau des agents -->
<div class="card">
<h2><span class="icon">📋</span> Agents enregistrés</h2>
<div style="overflow-x:auto;">
<table id="fleetTable" style="width:100%;border-collapse:collapse;font-size:13px;">
<thead>
<tr style="border-bottom:2px solid #334155;text-align:left;">
<th style="padding:12px 10px;color:#94a3b8;">Machine</th>
<th style="padding:12px 10px;color:#94a3b8;">Collaborateur</th>
<th style="padding:12px 10px;color:#94a3b8;">Hostname</th>
<th style="padding:12px 10px;color:#94a3b8;">Version</th>
<th style="padding:12px 10px;color:#94a3b8;">Statut</th>
<th style="padding:12px 10px;color:#94a3b8;">Dernière activité</th>
<th style="padding:12px 10px;color:#94a3b8;">Enregistré le</th>
<th style="padding:12px 10px;color:#94a3b8;">Installeur</th>
<th style="padding:12px 10px;color:#94a3b8;">Actions</th>
</tr>
</thead>
<tbody id="fleetTableBody">
<tr><td colspan="9" style="text-align:center;padding:40px;color:#64748b;">
<div class="loading"><div class="spinner"></div>Chargement...</div>
</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Modal enregistrement agent -->
<div id="enrollModal" class="modal" style="display:none;">
<div class="modal-content" style="max-width:500px;">
<span class="modal-close" onclick="closeEnrollModal()">&times;</span>
<h3> Enregistrer un nouveau poste</h3>
<p style="color:#94a3b8;font-size:13px;margin-top:10px;">
Saisissez le nom et l'email du collaborateur. L'identifiant technique et le hostname seront remplis automatiquement.
</p>
<div style="margin-top:20px;">
<label style="display:block;color:#94a3b8;margin-bottom:5px;">Nom du collaborateur *</label>
<input type="text" id="enrollUserName" class="select-input" placeholder="Ex: Marie Dupont">
<label style="display:block;color:#94a3b8;margin:15px 0 5px;">Email *</label>
<input type="email" id="enrollUserEmail" class="select-input" placeholder="Ex: m.dupont@ch-auch.fr">
<div style="display:flex;gap:10px;margin-top:20px;">
<button class="btn btn-success" onclick="enrollAgent()">✅ Enregistrer</button>
<button class="btn btn-secondary" onclick="closeEnrollModal()">Annuler</button>
</div>
</div>
</div>
</div>
<!-- Modal token généré -->
<div id="tokenModal" class="modal" style="display:none;">
<div class="modal-content" style="max-width:500px;">
<span class="modal-close" onclick="closeTokenModal()">&times;</span>
<h3 style="color:#22c55e;">✅ Agent enregistré avec succès</h3>
<div style="margin-top:20px;">
<p style="color:#94a3b8;margin-bottom:10px;">Token API à configurer sur le poste client :</p>
<div style="position:relative;">
<input type="text" id="generatedToken" class="select-input" readonly
style="font-family:monospace;font-size:12px;padding-right:80px;background:#0f172a;">
<button class="btn btn-primary btn-small" onclick="copyToken()"
style="position:absolute;right:5px;top:50%;transform:translateY(-50%);">
📋 Copier
</button>
</div>
<div style="margin-top:20px;padding:15px;background:#0f172a;border-radius:8px;border:1px solid #334155;">
<p style="color:#e2e8f0;margin-bottom:10px;font-weight:600;">📥 Installeur pré-configuré</p>
<p style="color:#94a3b8;font-size:12px;margin-bottom:12px;">
Téléchargez le ZIP à envoyer au collaborateur. Le token, l'URL serveur et l'identifiant machine sont déjà configurés — zéro configuration manuelle.
</p>
<a id="enrollDownloadLink" href="#" class="btn btn-success" style="display:inline-block;text-decoration:none;">
📥 Télécharger l'installeur Léa
</a>
</div>
<p style="color:#f59e0b;font-size:12px;margin-top:15px;">
⚠️ Ce token est le token API global du serveur. Conservez-le en lieu sûr.
</p>
</div>
</div>
</div>
</div>
<!-- Tab: Configuration -->
<div id="tab-config" 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> Configuration Systeme</h2>
<p style="color:#64748b;font-size:13px;">Configurez les services, modeles LLM/VLM, base de donnees et parametres de securite</p>
</div>
<div style="display:flex;gap:10px;">
<button class="btn btn-success" onclick="saveConfig()">💾 Sauvegarder</button>
<button class="btn btn-primary" onclick="refreshConfig()">🔄 Actualiser</button>
<button class="btn btn-secondary" onclick="exportConfig()">📥 Exporter</button>
<label class="btn btn-secondary" style="cursor:pointer;">
📤 Importer
<input type="file" id="importConfigFile" accept=".json" style="display:none;" onchange="importConfig(this)">
</label>
</div>
</div>
</div>
<div class="grid grid-2">
<!-- Section Services -->
<div class="card">
<h2><span class="icon">🌐</span> Services & Ports</h2>
<div id="configServices" class="config-section">
<div class="config-item">
<label>VWB Backend (port 5002)</label>
<div style="display:flex;gap:10px;">
<input type="text" id="cfg_vwb_backend_host" placeholder="localhost" class="config-input" style="flex:2;">
<input type="number" id="cfg_vwb_backend_port" placeholder="5002" class="config-input" style="flex:1;">
<button class="btn btn-small btn-primary" onclick="testConnection('service', 'vwb_backend')">Test</button>
</div>
</div>
<div class="config-item">
<label>VWB Frontend (port 3002)</label>
<div style="display:flex;gap:10px;">
<input type="text" id="cfg_vwb_frontend_host" placeholder="localhost" class="config-input" style="flex:2;">
<input type="number" id="cfg_vwb_frontend_port" placeholder="3002" class="config-input" style="flex:1;">
<button class="btn btn-small btn-primary" onclick="testConnection('service', 'vwb_frontend')">Test</button>
</div>
</div>
<div class="config-item">
<label>Agent Chat (port 5004)</label>
<div style="display:flex;gap:10px;">
<input type="text" id="cfg_agent_chat_host" placeholder="localhost" class="config-input" style="flex:2;">
<input type="number" id="cfg_agent_chat_port" placeholder="5004" class="config-input" style="flex:1;">
<button class="btn btn-small btn-primary" onclick="testConnection('service', 'agent_chat')">Test</button>
</div>
</div>
<div class="config-item">
<label>Streaming (port 5005)</label>
<div style="display:flex;gap:10px;">
<input type="text" id="cfg_streaming_host" placeholder="localhost" class="config-input" style="flex:2;">
<input type="number" id="cfg_streaming_port" placeholder="5005" class="config-input" style="flex:1;">
<button class="btn btn-small btn-primary" onclick="testConnection('service', 'streaming')">Test</button>
</div>
</div>
<div class="config-item">
<label>API Upload (port 8000)</label>
<div style="display:flex;gap:10px;">
<input type="text" id="cfg_upload_api_host" placeholder="localhost" class="config-input" style="flex:2;">
<input type="number" id="cfg_upload_api_port" placeholder="8000" class="config-input" style="flex:1;">
<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>
<!-- Section LLM/VLM -->
<div class="card">
<h2><span class="icon">🤖</span> Modeles LLM & VLM</h2>
<div id="configLLM" class="config-section">
<div class="config-item">
<label>Provider</label>
<select id="cfg_llm_provider" class="config-input">
<option value="ollama">Ollama (local)</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div class="config-item">
<label>URL Ollama</label>
<div style="display:flex;gap:10px;">
<input type="text" id="cfg_llm_base_url" placeholder="http://localhost:11434" class="config-input" style="flex:1;">
<button class="btn btn-small btn-primary" onclick="testOllamaConnection()">Test</button>
</div>
</div>
<div class="config-item">
<label>Modele LLM</label>
<div style="display:flex;gap:10px;">
<select id="cfg_llm_model" class="config-input" style="flex:1;">
<option value="">Chargement...</option>
</select>
<button class="btn btn-small btn-secondary" onclick="refreshOllamaModels()">🔄</button>
</div>
</div>
<div class="config-item">
<label>Modele VLM</label>
<select id="cfg_vlm_model" class="config-input">
<option value="">Chargement...</option>
</select>
</div>
<div class="config-item">
<label>Temperature</label>
<input type="range" id="cfg_llm_temperature" min="0" max="2" step="0.1" value="0.7" class="config-input" oninput="document.getElementById('tempValue').textContent = this.value">
<span id="tempValue" style="color:#3b82f6;margin-left:10px;">0.7</span>
</div>
<div class="config-item">
<label>Max Tokens</label>
<input type="number" id="cfg_llm_max_tokens" placeholder="2048" class="config-input" value="2048">
</div>
</div>
</div>
</div>
<div class="grid grid-2">
<!-- Section Base de donnees -->
<div class="card">
<h2><span class="icon">💾</span> Base de Donnees</h2>
<div id="configDatabase" class="config-section">
<div class="config-item">
<label>Type</label>
<select id="cfg_database_type" class="config-input">
<option value="sqlite">SQLite (local)</option>
<option value="postgresql">PostgreSQL</option>
<option value="mysql">MySQL</option>
</select>
</div>
<div class="config-item">
<label>Chemin / URL</label>
<div style="display:flex;gap:10px;">
<input type="text" id="cfg_database_path" placeholder="data/training/workflows.db" class="config-input" style="flex:1;">
<button class="btn btn-small btn-primary" onclick="testDatabaseConnection()">Test</button>
</div>
</div>
<div class="config-item">
<label>
<input type="checkbox" id="cfg_database_backup_enabled" checked>
Backup automatique
</label>
</div>
<div class="config-item">
<label>Intervalle backup (heures)</label>
<input type="number" id="cfg_database_backup_interval" value="24" class="config-input">
</div>
</div>
</div>
<!-- Section Securite -->
<div class="card">
<h2><span class="icon">🔒</span> Securite</h2>
<div id="configSecurity" class="config-section">
<div class="config-item">
<label>
<input type="checkbox" id="cfg_security_encryption" checked>
Chiffrement des donnees
</label>
</div>
<div class="config-item">
<label>
<input type="checkbox" id="cfg_security_auth">
Authentification requise
</label>
</div>
<div class="config-item">
<label>Timeout session (minutes)</label>
<input type="number" id="cfg_security_timeout" value="60" class="config-input">
</div>
<div class="config-item">
<label>Origines autorisees (CORS)</label>
<input type="text" id="cfg_security_origins" placeholder="http://localhost:3002,http://localhost:5001,http://localhost:5002" class="config-input">
</div>
</div>
</div>
<!-- Section Logs -->
<div class="card">
<h2><span class="icon">📝</span> Logs</h2>
<div id="configLogging" class="config-section">
<div class="config-item">
<label>Niveau de log</label>
<select id="cfg_logging_level" class="config-input">
<option value="DEBUG">DEBUG</option>
<option value="INFO" selected>INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
</div>
<div class="config-item">
<label>Fichier de log</label>
<input type="text" id="cfg_logging_file" placeholder="logs/rpa_vision.log" class="config-input">
</div>
<div class="config-item">
<label>Taille max (MB)</label>
<input type="number" id="cfg_logging_max_size" value="50" class="config-input">
</div>
<div class="config-item">
<label>Nombre de backups</label>
<input type="number" id="cfg_logging_backup_count" value="5" class="config-input">
</div>
</div>
</div>
</div>
<!-- Resultat des tests -->
<div class="card" style="margin-top:20px;" id="configTestResults">
<h2><span class="icon"></span> Resultats des tests</h2>
<div id="testResultsContent" style="padding:15px;background:#0f172a;border-radius:8px;">
<p style="color:#64748b;text-align:center;">Cliquez sur "Test" pour verifier les connexions</p>
</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>
.execution-panel { padding: 15px; background: #0f172a; border-radius: 8px; }
/* Configuration styles */
.config-section { display: flex; flex-direction: column; gap: 15px; padding: 10px 0; }
.config-item { display: flex; flex-direction: column; gap: 5px; }
.config-item label { color: #94a3b8; font-size: 13px; font-weight: 500; display: flex; align-items: center; gap: 8px; }
.config-item label input[type="checkbox"] { width: 18px; height: 18px; accent-color: #3b82f6; }
.config-input { width: 100%; padding: 10px 12px; background: #0f172a; border: 1px solid #334155; border-radius: 8px; color: #e2e8f0; font-size: 14px; transition: border-color 0.2s; }
.config-input:focus { outline: none; border-color: #3b82f6; }
.config-input:hover { border-color: #475569; }
select.config-input { cursor: pointer; }
input[type="range"].config-input { padding: 0; height: 6px; border: none; cursor: pointer; }
.exec-idle { color: #64748b; text-align: center; padding: 30px; }
.exec-running { color: #22c55e; }
.exec-controls { display: flex; flex-direction: column; gap: 15px; }
.select-input { width: 100%; padding: 12px; background: #0f172a; border: 1px solid #334155; border-radius: 8px; color: #e2e8f0; font-size: 14px; }
.btn-group { display: flex; gap: 10px; }
.btn { padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s; }
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:hover { background: #2563eb; }
.btn-success { background: #22c55e; color: white; }
.btn-success:hover { background: #16a34a; }
.btn-warning { background: #f59e0b; color: white; }
.btn-warning:hover { background: #d97706; }
.btn-danger { background: #ef4444; color: white; }
.btn-danger:hover { background: #dc2626; }
.btn-small { padding: 8px 16px; font-size: 12px; }
.live-stats { display: flex; flex-direction: column; gap: 12px; }
.stat-row { display: flex; justify-content: space-between; padding: 10px; background: #0f172a; border-radius: 6px; }
.stat-row span:first-child { color: #64748b; }
.stat-row span:last-child { font-weight: 600; color: #3b82f6; }
.history-list { max-height: 400px; overflow-y: auto; }
.history-item { display: flex; align-items: center; gap: 15px; padding: 12px; border-bottom: 1px solid #334155; }
.history-item .time { color: #64748b; font-size: 12px; min-width: 80px; }
.history-item .node { flex: 1; }
.history-item .confidence { color: #3b82f6; font-weight: 600; }
.history-item.success { border-left: 3px solid #22c55e; }
.history-item.failed { border-left: 3px solid #ef4444; }
.workflow-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 15px; }
.workflow-card { background: #0f172a; border-radius: 8px; padding: 20px; border: 1px solid #334155; transition: all 0.2s; }
.workflow-card:hover { border-color: #3b82f6; transform: translateY(-2px); }
.workflow-card h3 { color: #e2e8f0; margin-bottom: 10px; }
.workflow-card .meta { color: #64748b; font-size: 12px; margin-bottom: 15px; }
.workflow-card .badge { display: inline-block; padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; }
.badge-observation { background: #1e40af; color: #93c5fd; }
.badge-coaching { background: #7c3aed; color: #c4b5fd; }
.badge-supervised { background: #059669; color: #6ee7b7; }
.badge-automatic { background: #dc2626; color: #fca5a5; }
.session-list { max-height: 600px; overflow-y: auto; }
.session-item { display: flex; justify-content: space-between; align-items: center; padding: 15px; border-bottom: 1px solid #334155; transition: background 0.2s; }
.session-item:hover { background: #334155; }
.session-info h4 { color: #e2e8f0; margin-bottom: 5px; }
.session-info .meta { color: #64748b; font-size: 12px; }
.session-actions { display: flex; gap: 8px; }
.actions-bar { display: flex; gap: 10px; margin-bottom: 15px; }
.test-list { max-height: 500px; overflow-y: auto; }
.test-item { display: flex; justify-content: space-between; align-items: center; padding: 12px; border-bottom: 1px solid #334155; cursor: pointer; }
.test-item:hover { background: #334155; }
.test-type { padding: 4px 10px; border-radius: 4px; font-size: 11px; font-weight: 600; }
.test-type.unit { background: #1e40af; color: #93c5fd; }
.test-type.integration { background: #7c3aed; color: #c4b5fd; }
.test-type.performance { background: #f59e0b; color: #fef3c7; }
.test-output { background: #0f172a; padding: 15px; border-radius: 8px; font-family: monospace; font-size: 12px; max-height: 500px; overflow-y: auto; white-space: pre-wrap; color: #94a3b8; }
.loading { text-align: center; padding: 40px; color: #64748b; }
.spinner { border: 3px solid #334155; border-top: 3px solid #3b82f6; border-radius: 50%; width: 30px; height: 30px; animation: spin 1s linear infinite; margin: 0 auto 10px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal-content { background: #1e293b; border-radius: 12px; padding: 30px; max-width: 90%; max-height: 90%; overflow: auto; }
.modal-close { position: absolute; top: 20px; right: 30px; font-size: 30px; cursor: pointer; color: #94a3b8; }
.screenshot-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; margin-top: 20px; }
.screenshot-gallery img { width: 100%; border-radius: 8px; cursor: pointer; transition: transform 0.2s; }
.screenshot-gallery img:hover { transform: scale(1.05); }
/* Services Panel Styles */
.service-card { background: #1e293b; border-radius: 12px; padding: 20px; border: 2px solid #334155; transition: all 0.3s; position: relative; overflow: hidden; }
.service-card.running { border-color: #22c55e; box-shadow: 0 0 20px rgba(34, 197, 94, 0.2); }
.service-card.stopped { border-color: #ef4444; }
.service-card .status-indicator { position: absolute; top: 15px; right: 15px; width: 16px; height: 16px; border-radius: 50%; animation: pulse 2s infinite; }
.service-card .status-indicator.running { background: #22c55e; }
.service-card .status-indicator.stopped { background: #ef4444; animation: none; }
.service-card .service-icon { font-size: 36px; margin-bottom: 10px; }
.service-card h3 { color: #e2e8f0; font-size: 18px; margin-bottom: 5px; }
.service-card .description { color: #64748b; font-size: 13px; margin-bottom: 15px; }
.service-card .port-info { display: inline-block; padding: 4px 10px; background: #0f172a; border-radius: 4px; font-size: 12px; color: #94a3b8; margin-bottom: 15px; }
.service-card .actions { display: flex; gap: 8px; flex-wrap: wrap; }
.service-card .btn { flex: 1; min-width: 80px; }
.quick-link { display: flex; align-items: center; gap: 12px; padding: 15px 20px; background: #0f172a; border-radius: 10px; border: 1px solid #334155; cursor: pointer; transition: all 0.2s; text-decoration: none; color: #e2e8f0; }
.quick-link:hover { border-color: #3b82f6; transform: translateY(-2px); background: #1e293b; }
.quick-link .icon { font-size: 24px; }
.quick-link .info { flex: 1; }
.quick-link .name { font-weight: 600; margin-bottom: 2px; }
.quick-link .url { font-size: 12px; color: #64748b; }
.quick-link .status-badge { padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; }
.quick-link .status-badge.running { background: #052e16; color: #22c55e; }
.quick-link .status-badge.stopped { background: #450a0a; color: #ef4444; }
.btn-secondary { background: #475569; color: white; }
.btn-secondary:hover { background: #64748b; }
/* Backup section styles */
.backup-list { display: flex; flex-direction: column; gap: 10px; }
.backup-item { display: flex; align-items: center; gap: 15px; padding: 15px; background: #0f172a; border-radius: 10px; border: 1px solid #334155; cursor: pointer; transition: all 0.2s; }
.backup-item:hover { border-color: #3b82f6; transform: translateX(5px); }
.backup-icon { font-size: 28px; }
.backup-info { flex: 1; }
.backup-info h4 { color: #e2e8f0; margin-bottom: 3px; }
.backup-info p { color: #64748b; font-size: 12px; }
/* System info styles */
.system-info { display: flex; flex-direction: column; gap: 10px; }
.system-info-row { display: flex; justify-content: space-between; padding: 10px 15px; background: #0f172a; border-radius: 6px; }
.system-info-row span:first-child { color: #64748b; }
.system-info-row span:last-child { color: #e2e8f0; font-weight: 500; }
/* Correction packs styles */
.correction-packs-list { display: flex; flex-direction: column; gap: 12px; }
.correction-pack-item { display: flex; justify-content: space-between; align-items: center; padding: 20px; background: #0f172a; border-radius: 10px; border: 1px solid #334155; transition: all 0.2s; }
.correction-pack-item:hover { border-color: #3b82f6; }
.pack-main { display: flex; align-items: center; gap: 15px; flex: 1; }
.pack-icon { font-size: 32px; }
.pack-details h4 { color: #e2e8f0; margin-bottom: 5px; }
.pack-details p { color: #64748b; font-size: 12px; }
.pack-stats { display: flex; gap: 20px; }
.pack-stat { text-align: center; }
.pack-stat .value { font-size: 18px; font-weight: bold; color: #3b82f6; }
.pack-stat .label { font-size: 10px; color: #64748b; text-transform: uppercase; }
.pack-actions { display: flex; gap: 8px; margin-left: 20px; }
/* Learning stats styles */
.progress-bar { height: 8px; background: #334155; border-radius: 4px; overflow: hidden; margin-top: 8px; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #8b5cf6); border-radius: 4px; transition: width 0.5s ease; }
.stat-bar { display: flex; justify-content: space-between; align-items: center; padding: 12px 15px; background: #0f172a; border-radius: 8px; margin-bottom: 8px; }
.stat-bar-label { color: #94a3b8; }
.stat-bar-value { color: #3b82f6; font-weight: 600; }
/* Version backup item */
.version-backup-item { display: flex; justify-content: space-between; align-items: center; padding: 12px 15px; background: #0f172a; border-radius: 8px; margin-bottom: 8px; border: 1px solid #334155; }
.version-backup-item:hover { border-color: #3b82f6; }
.version-backup-info { flex: 1; }
.version-backup-info h5 { color: #e2e8f0; margin-bottom: 3px; font-weight: 500; }
.version-backup-info span { color: #64748b; font-size: 12px; }
</style>
<script>
// WebSocket connection
const socket = io();
let faissChart, cacheChart;
// === HELPER: Validated JSON fetch ===
async function fetchJSON(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
const text = await response.text();
// Check if it's HTML (error page)
if (text.trim().startsWith('<')) {
throw new Error(`Serveur erreur ${response.status}: ${response.statusText}`);
}
throw new Error(text || `HTTP ${response.status}`);
}
return response.json();
}
// === HELPER: Show error in UI ===
function showError(elementId, message) {
const el = document.getElementById(elementId);
if (el) {
el.innerHTML = `<p style="color:#ef4444;padding:20px;text-align:center;">❌ ${message}</p>`;
}
}
// Socket events
socket.on('connect', () => {
document.getElementById('statusDot').classList.remove('offline');
document.getElementById('statusText').textContent = 'Connecté';
});
socket.on('disconnect', () => {
document.getElementById('statusDot').classList.add('offline');
document.getElementById('statusText').textContent = 'Déconnecté';
});
// Tab switching
function switchTab(tabName) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
document.getElementById(`tab-${tabName}`).classList.add('active');
// Trouver l'onglet cliqué par son onclick (event.target peut viser un enfant)
const clickedTab = event.target.closest('.tab') || event.target;
clickedTab.classList.add('active');
if (tabName === 'services') { refreshServices(); refreshSystemInfo(); }
if (tabName === 'fleet') refreshFleet();
if (tabName === 'sessions') refreshSessions();
if (tabName === 'workflows') { refreshWorkflows(); refreshChains(); refreshTriggers(); }
if (tabName === 'performance') { refreshPerformance(); refreshMetrics(); }
if (tabName === 'streaming') refreshStreaming();
if (tabName === 'logs') refreshLogs();
if (tabName === 'backups') refreshBackupStats();
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();
}
}
// Fonctions Exécution / Vue d'ensemble retirées (onglets supprimés)
async function refreshWorkflows() {
const list = document.getElementById('workflowList');
list.innerHTML = '<div class="loading"><div class="spinner"></div>Chargement...</div>';
try {
const hideUnnamed = document.getElementById('hideUnnamedWorkflows').checked;
const data = await fetchJSON(`/api/workflows?hide_unnamed=${hideUnnamed}`);
// Afficher info sur workflows invalides
const infoEl = document.getElementById('unnamedWorkflowsInfo');
const btnCleanup = document.getElementById('btnCleanupWorkflows');
if (data.hidden_unnamed > 0) {
infoEl.textContent = `⚠️ ${data.hidden_unnamed} workflow(s) invalide(s)`;
btnCleanup.style.display = 'inline-block';
} else {
infoEl.textContent = '';
btnCleanup.style.display = 'none';
}
if (!data.workflows || data.workflows.length === 0) {
list.innerHTML = '<p style="text-align:center;color:#64748b;padding:40px;">Aucun workflow disponible</p>';
return;
}
let html = '';
data.workflows.forEach(wf => {
const badgeClass = `badge-${wf.learning_state.toLowerCase()}`;
html += `
<div class="workflow-card">
<h3>${wf.name || wf.workflow_id}</h3>
<div class="meta">${wf.nodes_count} nodes • ${wf.edges_count} edges • ${wf.execution_count} exécutions</div>
<span class="badge ${badgeClass}">${wf.learning_state}</span>
<p style="margin-top:10px;color:#94a3b8;font-size:13px;">${wf.description || 'Pas de description'}</p>
<div style="margin-top:15px;">
<button class="btn btn-primary btn-small" onclick="viewWorkflow('${wf.workflow_id}')">👁️ Voir</button>
</div>
</div>
`;
});
list.innerHTML = html;
} catch (e) {
list.innerHTML = '<p style="color:#ef4444;">Erreur: ' + e.message + '</p>';
}
}
async function viewWorkflow(id) {
try {
const data = await fetchJSON(`/api/workflows/${id}`);
alert(JSON.stringify(data.workflow, null, 2));
} catch (e) {
alert('Erreur: ' + e.message);
}
}
async function cleanupUnnamedWorkflows() {
if (!confirm('Êtes-vous sûr de vouloir supprimer tous les workflows "Unnamed Workflow" ?\n\nCette action est irréversible.')) {
return;
}
const btn = document.getElementById('btnCleanupWorkflows');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = '⏳ Suppression...';
try {
const data = await fetchJSON('/api/workflows/cleanup-unnamed', { method: 'POST' });
alert(`${data.deleted_count} workflow(s) supprimé(s)`);
refreshWorkflows();
} catch (e) {
alert('❌ Erreur: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
// Sessions
async function refreshSessions() {
const list = document.getElementById('sessionList');
list.innerHTML = '<div class="loading"><div class="spinner"></div>Chargement...</div>';
try {
const hideEmpty = document.getElementById('hideEmptySessions').checked;
const data = await fetchJSON(`/api/agent/sessions?hide_empty=${hideEmpty}`);
// Afficher info sur sessions vides
const infoEl = document.getElementById('emptySessionsInfo');
const btnCleanup = document.getElementById('btnCleanup');
if (data.hidden_empty > 0) {
infoEl.textContent = `⚠️ ${data.hidden_empty} session(s) vide(s)`;
btnCleanup.style.display = 'inline-block';
} else {
infoEl.textContent = '';
btnCleanup.style.display = 'none';
}
if (!data.sessions || data.sessions.length === 0) {
list.innerHTML = '<p style="text-align:center;color:#64748b;padding:40px;">Aucune session enregistrée</p>';
return;
}
let html = '';
data.sessions.forEach(session => {
const user = session.user?.label || session.user?.id || 'Inconnu';
const training = session.context?.training_label || 'Sans label';
const date = new Date(session.started_at).toLocaleString('fr-FR');
// Badge de statut de traitement
const state = session.processing_state || 'pending';
const stateConfig = {
'pending': { icon: '⏳', text: 'En attente', color: '#64748b', bg: '#334155' },
'processing': { icon: '⚙️', text: 'En cours...', color: '#f59e0b', bg: '#422006' },
'completed': { icon: '✅', text: 'Traité', color: '#22c55e', bg: '#052e16' },
'failed': { icon: '❌', text: 'Échec', color: '#ef4444', bg: '#450a0a' }
};
const sc = stateConfig[state] || stateConfig.pending;
const statsBadge = session.processing_stats ?
`<span style="margin-left:8px;font-size:11px;color:#94a3b8;">${session.processing_stats.targets_found || 0} cibles</span>` : '';
html += `
<div class="session-item">
<div class="session-info">
<h4>
${session.session_id}
<span style="margin-left:10px;padding:3px 8px;border-radius:4px;font-size:12px;background:${sc.bg};color:${sc.color};">
${sc.icon} ${sc.text}${statsBadge}
</span>
</h4>
<div class="meta">
👤 ${user} • 🏷️ ${training} • 📅 ${date}<br>
📸 ${session.screenshots_count} screenshots • 🎬 ${session.events_count} events • 💾 ${session.size_mb} MB
</div>
</div>
<div class="session-actions">
<button class="btn btn-primary btn-small" onclick="viewScreenshots('${session.session_id}')">📸 Screenshots</button>
<button class="btn btn-success btn-small" onclick="processSession('${session.session_id}')" ${state === 'processing' ? 'disabled' : ''}>
${state === 'processing' ? '⏳ En cours...' : '⚙️ Traiter'}
</button>
</div>
</div>
`;
});
list.innerHTML = html;
} catch (e) {
list.innerHTML = '<p style="color:#ef4444;">Erreur: ' + e.message + '</p>';
}
}
async function viewScreenshots(sessionId) {
const modal = document.getElementById('screenshotModal');
const gallery = document.getElementById('screenshotGallery');
const title = document.getElementById('modalTitle');
title.textContent = `Screenshots - ${sessionId}`;
gallery.innerHTML = '<div class="loading"><div class="spinner"></div>Chargement...</div>';
modal.style.display = 'flex';
try {
const data = await fetchJSON(`/api/agent/sessions/${sessionId}`);
if (!data.screenshots || data.screenshots.length === 0) {
gallery.innerHTML = '<p style="color:#64748b;">Aucun screenshot disponible</p>';
return;
}
let html = '';
data.screenshots.forEach(ss => {
html += `<img src="${ss.url}" alt="${ss.filename}" onclick="window.open('${ss.url}', '_blank')">`;
});
gallery.innerHTML = html;
} catch (e) {
gallery.innerHTML = '<p style="color:#ef4444;">Erreur: ' + e.message + '</p>';
}
}
function closeModal() {
document.getElementById('screenshotModal').style.display = 'none';
}
async function cleanupEmptySessions() {
const infoEl = document.getElementById('emptySessionsInfo');
const count = infoEl.textContent.match(/(\d+)/)?.[1] || '?';
if (!confirm(`Supprimer définitivement ${count} session(s) vide(s) (sans screenshots) ?`)) return;
const btn = document.getElementById('btnCleanup');
btn.disabled = true;
btn.textContent = '⏳ Suppression...';
try {
const data = await fetchJSON('/api/agent/sessions/cleanup-empty', { method: 'POST' });
if (data.success) {
alert(`${data.deleted_count} session(s) supprimée(s)`);
refreshSessions();
} else {
alert('❌ Erreur: ' + (data.error || 'Inconnue'));
}
} catch (e) {
alert('❌ Erreur: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = '🗑️ Supprimer vides';
}
}
async function processSession(sessionId) {
if (!confirm(`Lancer le traitement de ${sessionId}?`)) return;
try {
const data = await fetchJSON(`/api/agent/sessions/${sessionId}/process`, { method: 'POST' });
if (data.error) {
alert('Erreur: ' + data.error);
} else {
alert('Traitement lancé! Voir les logs pour suivre la progression.');
}
} catch (e) {
alert('Erreur: ' + e.message);
}
}
// Performance
async function refreshPerformance() {
try {
const data = await fetchJSON('/api/system/performance');
const faiss = data.faiss || {};
// Update FAISS Panel
const totalVectors = faiss.total_vectors || faiss.total_embeddings || 0;
const hasIndex = totalVectors > 0 || faiss.dimensions;
// Status indicator
const faissStatusEl = document.getElementById('faissStatus');
if (faiss.error) {
faissStatusEl.textContent = '❌';
faissStatusEl.title = faiss.error;
} else if (faiss.status === 'index_not_found') {
faissStatusEl.textContent = '⚠️';
faissStatusEl.title = 'Index non trouvé';
} else if (hasIndex) {
faissStatusEl.textContent = '✅';
faissStatusEl.title = 'Index actif';
} else {
faissStatusEl.textContent = '⏳';
faissStatusEl.title = 'En attente';
}
// FAISS metrics
document.getElementById('faissVectors').textContent = totalVectors.toLocaleString();
document.getElementById('faissDimensions').textContent = faiss.dimensions || '-';
document.getElementById('faissIndexType').textContent = faiss.index_type || 'Flat';
// FAISS details with tooltips
const details = [];
if (faiss.metric) details.push(`Métrique: ${faiss.metric}`);
if (faiss.use_gpu !== undefined) details.push(`GPU: ${faiss.use_gpu ? 'Oui' : 'Non'}`);
if (faiss.is_trained !== undefined) details.push(`Entraîné: ${faiss.is_trained ? 'Oui' : 'Non'}`);
if (faiss.nlist) details.push(`nlist: ${faiss.nlist}`);
if (faiss.nprobe) details.push(`nprobe: ${faiss.nprobe}`);
if (faiss.metadata_count) details.push(`Métadonnées: ${faiss.metadata_count}`);
document.getElementById('faissDetails').textContent = details.length > 0 ?
details.join(' • ') : (faiss.error || faiss.status || 'Aucune info disponible');
// Recommendations contextuelles
const recoEl = document.getElementById('faissRecommendations');
const recommendations = [];
if (totalVectors > 10000 && (faiss.index_type === 'Flat' || !faiss.index_type)) {
recommendations.push('💡 Passez à un index IVF pour de meilleures performances (> 10k vecteurs)');
}
if (!faiss.use_gpu && totalVectors > 50000) {
recommendations.push('🚀 Activez le GPU pour accélérer les recherches (> 50k vecteurs)');
}
if (faiss.status === 'index_not_found') {
recommendations.push('📝 Traitez des sessions pour créer l\'index FAISS');
}
if (recommendations.length > 0) {
recoEl.innerHTML = recommendations.join('<br>');
recoEl.style.display = 'block';
} else {
recoEl.style.display = 'none';
}
// Update simple stats
document.getElementById('perfEmbeddings').textContent = totalVectors;
const hits = data.embedding_cache?.hits || 0;
const misses = data.embedding_cache?.misses || 0;
const hitRate = hits + misses > 0 ? ((hits / (hits + misses)) * 100).toFixed(1) : 0;
document.getElementById('perfCacheHits').textContent = hitRate + '%';
const avgTime = faiss.avg_search_time_ms || data.metrics?.avg_search_time_ms || 0;
document.getElementById('perfAvgTime').textContent = avgTime.toFixed(2) + 'ms';
// Update charts
updatePerformanceCharts(data);
} catch (e) {
console.error('Performance error:', e);
document.getElementById('faissStatus').textContent = '❌';
document.getElementById('faissDetails').textContent = 'Erreur: ' + e.message;
document.getElementById('perfEmbeddings').textContent = '!';
document.getElementById('perfCacheHits').textContent = '!';
document.getElementById('perfAvgTime').textContent = '!';
}
}
// Test FAISS Index
async function testFaissIndex() {
const btn = document.getElementById('btnTestFaiss');
const resultsEl = document.getElementById('faissTestResults');
btn.disabled = true;
btn.textContent = '⏳ Test en cours...';
resultsEl.style.display = 'block';
resultsEl.innerHTML = '<div style="text-align:center;color:#94a3b8;">🔄 Exécution du test...</div>';
try {
const data = await fetchJSON('/api/system/faiss/test', { method: 'POST' });
if (data.success) {
let html = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;">
<span style="font-size:16px;font-weight:bold;color:#22c55e;">✅ Test réussi</span>
<span style="color:#64748b;font-size:12px;">${new Date().toLocaleTimeString('fr-FR')}</span>
</div>
<div class="grid grid-3" style="gap:15px;margin-bottom:15px;">
<div style="text-align:center;padding:10px;background:#1e293b;border-radius:8px;">
<div style="font-size:20px;font-weight:bold;color:#3b82f6;">${data.load_time_ms}ms</div>
<div style="font-size:11px;color:#64748b;">Chargement</div>
</div>
<div style="text-align:center;padding:10px;background:#1e293b;border-radius:8px;">
<div style="font-size:20px;font-weight:bold;color:#22c55e;">${data.search_time_ms}ms</div>
<div style="font-size:11px;color:#64748b;">Recherche</div>
</div>
<div style="text-align:center;padding:10px;background:#1e293b;border-radius:8px;">
<div style="font-size:20px;font-weight:bold;color:#8b5cf6;">${data.results_count}</div>
<div style="font-size:11px;color:#64748b;">Résultats</div>
</div>
</div>
`;
if (data.results && data.results.length > 0) {
html += `<div style="font-size:13px;color:#94a3b8;margin-bottom:8px;">Top résultats (vecteur aléatoire) :</div>`;
html += `<table style="width:100%;font-size:12px;color:#e2e8f0;">
<tr style="color:#64748b;"><th>#</th><th>Score</th><th>Target ID</th><th>Session</th></tr>`;
data.results.forEach(r => {
html += `<tr>
<td>${r.rank}</td>
<td>${r.score}</td>
<td>${r.target_id}</td>
<td>${r.session_id}...</td>
</tr>`;
});
html += `</table>`;
}
if (data.recommendations && data.recommendations.length > 0) {
html += `<div style="margin-top:15px;padding:10px;background:#422006;border-radius:8px;color:#fbbf24;font-size:13px;">
💡 ${data.recommendations.join('<br>💡 ')}
</div>`;
}
resultsEl.innerHTML = html;
} else {
resultsEl.innerHTML = `
<div style="color:#ef4444;font-weight:bold;">❌ ${data.error}</div>
${data.recommendation ? `<div style="margin-top:10px;color:#fbbf24;">💡 ${data.recommendation}</div>` : ''}
`;
}
} catch (e) {
resultsEl.innerHTML = `<div style="color:#ef4444;">❌ Erreur: ${e.message}</div>`;
} finally {
btn.disabled = false;
btn.textContent = '🧪 Tester l\'index';
}
}
// Track FAISS search history for chart
let faissSearchHistory = [];
const MAX_HISTORY = 20;
function updatePerformanceCharts(data) {
const faiss = data.faiss || {};
// Add current search time to history (if available)
const searchTime = faiss.avg_search_time_ms || data.metrics?.avg_search_time_ms || 0;
if (searchTime > 0 || faissSearchHistory.length > 0) {
faissSearchHistory.push({
time: new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
value: searchTime
});
if (faissSearchHistory.length > MAX_HISTORY) {
faissSearchHistory.shift();
}
}
// FAISS Chart - Search time history
if (!faissChart) {
const ctx = document.getElementById('faissChart').getContext('2d');
faissChart = new Chart(ctx, {
type: 'line',
data: {
labels: faissSearchHistory.map(h => h.time),
datasets: [{
label: 'Temps de recherche (ms)',
data: faissSearchHistory.map(h => h.value),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
plugins: { legend: { labels: { color: '#94a3b8' } } },
scales: {
x: { ticks: { color: '#64748b' }, grid: { color: '#334155' } },
y: {
ticks: { color: '#64748b' },
grid: { color: '#334155' },
beginAtZero: true,
title: { display: true, text: 'ms', color: '#64748b' }
}
}
}
});
} else {
faissChart.data.labels = faissSearchHistory.map(h => h.time);
faissChart.data.datasets[0].data = faissSearchHistory.map(h => h.value);
faissChart.update('none');
}
// Cache Chart - Doughnut
const hits = data.embedding_cache?.hits || 0;
const misses = data.embedding_cache?.misses || 0;
if (!cacheChart) {
const ctx = document.getElementById('cacheChart').getContext('2d');
cacheChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Hits', 'Misses'],
datasets: [{
data: [hits, misses],
backgroundColor: ['#22c55e', '#ef4444']
}]
},
options: {
responsive: true,
plugins: {
legend: { labels: { color: '#94a3b8' } },
tooltip: {
callbacks: {
label: (ctx) => {
const total = hits + misses;
const pct = total > 0 ? ((ctx.raw / total) * 100).toFixed(1) : 0;
return `${ctx.label}: ${ctx.raw} (${pct}%)`;
}
}
}
}
}
});
} else {
cacheChart.data.datasets[0].data = [hits, misses];
cacheChart.update('none');
}
}
// Fonctions Tests retirées (onglet supprimé)
// Chains
async function refreshChains() {
const list = document.getElementById('chainList');
list.innerHTML = '<div class="loading"><div class="spinner"></div>Chargement...</div>';
try {
const data = await fetchJSON('/api/chains');
if (!data.chains || data.chains.length === 0) {
list.innerHTML = '<p style="text-align:center;color:#64748b;padding:40px;">Aucune chaîne configurée</p>';
return;
}
let html = '';
data.chains.forEach(chain => {
const statusColor = chain.status === 'active' ? '#22c55e' : '#64748b';
html += `
<div class="workflow-card">
<h3>${chain.name}</h3>
<div class="meta">
${chain.workflows.length} workflows •
Taux de succès: ${chain.success_rate.toFixed(1)}%
</div>
<div style="margin:10px 0;color:#94a3b8;font-size:13px;">
${chain.workflows.join(' → ')}
</div>
<div style="margin-top:15px;">
<button class="btn btn-success btn-small" onclick="executeChain('${chain.chain_id}')">▶️ Exécuter</button>
</div>
</div>
`;
});
list.innerHTML = html;
} catch (e) {
list.innerHTML = '<p style="color:#ef4444;">Erreur: ' + e.message + '</p>';
}
}
async function executeChain(chainId) {
if (!confirm('Lancer l\'exécution de cette chaîne?')) return;
try {
const data = await fetchJSON(`/api/chains/${chainId}/execute`, { method: 'POST' });
if (data.error) {
alert('Erreur: ' + data.error);
} else {
alert(`Chaîne lancée! Durée: ${data.result?.duration?.toFixed(2) || '?'}s`);
refreshChains();
}
} catch (e) {
alert('Erreur: ' + e.message);
}
}
function showCreateChainModal() {
alert('Création de chaîne non implémentée dans cette version');
}
// Triggers
async function refreshTriggers() {
const list = document.getElementById('triggerList');
list.innerHTML = '<div class="loading"><div class="spinner"></div>Chargement...</div>';
try {
const data = await fetchJSON('/api/triggers');
if (!data.triggers || data.triggers.length === 0) {
list.innerHTML = '<p style="text-align:center;color:#64748b;padding:40px;">Aucun déclencheur configuré</p>';
return;
}
let html = '';
data.triggers.forEach(trigger => {
const statusColor = trigger.enabled ? '#22c55e' : '#ef4444';
const statusText = trigger.enabled ? 'Activé' : 'Désactivé';
html += `
<div class="workflow-card">
<h3>${trigger.trigger_type.toUpperCase()}</h3>
<div class="meta">
Workflow: ${trigger.workflow_id}<br>
Déclenchements: ${trigger.fire_count}
</div>
<div style="margin:10px 0;color:#94a3b8;font-size:13px;">
Config: ${JSON.stringify(trigger.config)}
</div>
<div style="margin-top:15px;">
<button class="btn ${trigger.enabled ? 'btn-warning' : 'btn-success'} btn-small"
onclick="toggleTrigger('${trigger.trigger_id}')">
${trigger.enabled ? '⏸️ Désactiver' : '▶️ Activer'}
</button>
</div>
</div>
`;
});
list.innerHTML = html;
} catch (e) {
list.innerHTML = '<p style="color:#ef4444;">Erreur: ' + e.message + '</p>';
}
}
async function toggleTrigger(triggerId) {
try {
const data = await fetchJSON(`/api/triggers/${triggerId}/toggle`, { method: 'POST' });
if (data.error) {
alert('Erreur: ' + data.error);
} else {
refreshTriggers();
}
} catch (e) {
alert('Erreur: ' + e.message);
}
}
function showCreateTriggerModal() {
alert('Création de trigger non implémentée dans cette version');
}
// Fonctions Automation retirées (routes supprimées)
// Metrics
async function refreshMetrics() {
const output = document.getElementById('metricsOutput');
output.textContent = '⏳ Chargement des métriques...\n';
try {
const response = await fetch('/metrics');
if (!response.ok) {
throw new Error(`Serveur erreur ${response.status}`);
}
const text = await response.text();
output.textContent = text || 'Aucune métrique disponible';
} catch (e) {
output.textContent = '❌ Erreur: ' + e.message;
}
}
// Logs
async function refreshLogs() {
const output = document.getElementById('logsOutput');
output.textContent = '⏳ Chargement des logs...\n';
try {
const data = await fetchJSON('/api/logs');
if (!data.logs || data.logs.length === 0) {
output.textContent = 'Aucun log disponible';
return;
}
let text = '';
data.logs.forEach(log => {
text += `[${log.file}] ${log.message}\n`;
});
output.textContent = text;
} catch (e) {
output.textContent = '❌ Erreur: ' + e.message;
}
}
async function downloadLogs() {
try {
window.location.href = '/api/logs/download';
} catch (e) {
alert('Erreur: ' + e.message);
}
}
// initOverviewChart retiré (onglet Vue d'ensemble supprimé)
function initOverviewChart() {
}
// =====================================================
// Services Management (Panneau de contrôle pour démos)
// =====================================================
let servicesData = [];
async function refreshServices() {
const grid = document.getElementById('servicesGrid');
const quickLinks = document.getElementById('quickLinks');
grid.innerHTML = '<div class="loading"><div class="spinner"></div>Chargement des services...</div>';
try {
const data = await fetchJSON('/api/services');
servicesData = data.services || [];
let gridHtml = '';
let linksHtml = '';
servicesData.forEach(service => {
const isRunning = service.status === 'running';
const statusClass = isRunning ? 'running' : 'stopped';
const statusText = isRunning ? '🟢 En ligne' : '🔴 Arrêté';
gridHtml += `
<div class="service-card ${statusClass}" id="service-${service.service_id}">
<div class="status-indicator ${statusClass}"></div>
<div class="service-icon">${service.icon}</div>
<h3>${service.name}</h3>
<div class="description">${service.description}</div>
<div class="port-info">Port: ${service.port} ${statusText}</div>
<div class="actions">
${service.can_start ? `
<button class="btn ${isRunning ? 'btn-warning' : 'btn-success'}"
onclick="${isRunning ? `stopService('${service.service_id}')` : `startService('${service.service_id}')`}"
${!service.can_stop && isRunning ? 'disabled' : ''}>
${isRunning ? '⏹️ Arrêter' : '▶️ Démarrer'}
</button>
${isRunning && service.can_stop ? `
<button class="btn btn-secondary" onclick="restartService('${service.service_id}')">
🔄 Redémarrer
</button>
` : ''}
` : `
<button class="btn btn-secondary" disabled>
${isRunning ? '✅ Actif (ce service)' : '⚪ Non gérable'}
</button>
`}
${isRunning ? `
<button class="btn btn-primary" onclick="openService('${service.url}')">
🌐 Ouvrir
</button>
` : ''}
</div>
</div>
`;
// Quick links
linksHtml += `
<a href="${service.url}" target="_blank" class="quick-link" onclick="event.preventDefault(); openService('${service.url}');">
<span class="icon">${service.icon}</span>
<div class="info">
<div class="name">${service.name}</div>
<div class="url">${service.url}</div>
</div>
<span class="status-badge ${statusClass}">${isRunning ? 'EN LIGNE' : 'ARRÊTÉ'}</span>
</a>
`;
});
grid.innerHTML = gridHtml;
quickLinks.innerHTML = linksHtml;
} catch (e) {
grid.innerHTML = `<p style="color:#ef4444;padding:20px;">❌ Erreur: ${e.message}</p>`;
}
}
async function startService(serviceId) {
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '⏳ Démarrage...';
try {
const data = await fetchJSON(`/api/services/${serviceId}/start`, { method: 'POST' });
if (data.error) {
alert('❌ Erreur: ' + data.error);
} else {
// Notification de succès
showNotification(`${data.message || 'Service démarré'}`, 'success');
}
// Attendre un peu et rafraîchir
setTimeout(refreshServices, 1500);
} catch (e) {
alert('❌ Erreur: ' + e.message);
btn.disabled = false;
btn.innerHTML = '▶️ Démarrer';
}
}
async function stopService(serviceId) {
if (!confirm('Arrêter ce service ?')) return;
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '⏳ Arrêt...';
try {
const data = await fetchJSON(`/api/services/${serviceId}/stop`, { method: 'POST' });
if (data.error) {
alert('❌ Erreur: ' + data.error);
} else {
showNotification(`⏹️ ${data.message || 'Service arrêté'}`, 'warning');
}
setTimeout(refreshServices, 1000);
} catch (e) {
alert('❌ Erreur: ' + e.message);
btn.disabled = false;
}
}
async function restartService(serviceId) {
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '⏳ Redémarrage...';
try {
const data = await fetchJSON(`/api/services/${serviceId}/restart`, { method: 'POST' });
if (data.error) {
alert('❌ Erreur: ' + data.error);
} else {
showNotification(`🔄 Service redémarré`, 'success');
}
setTimeout(refreshServices, 2000);
} catch (e) {
alert('❌ Erreur: ' + e.message);
btn.disabled = false;
}
}
async function startAllServices() {
if (!confirm('Démarrer tous les services ?')) return;
showNotification('⏳ Démarrage de tous les services...', 'info');
try {
const data = await fetchJSON('/api/services/start-all', { method: 'POST' });
let successCount = 0;
let failCount = 0;
for (const [id, result] of Object.entries(data.results || {})) {
if (result.status === 'running' || result.status === 'already_running') {
successCount++;
} else {
failCount++;
}
}
showNotification(`${successCount} service(s) démarré(s)${failCount > 0 ? `, ${failCount} échec(s)` : ''}`, 'success');
setTimeout(refreshServices, 1000);
} catch (e) {
alert('❌ Erreur: ' + e.message);
}
}
async function stopAllServices() {
if (!confirm('Arrêter tous les services (sauf ce dashboard) ?')) return;
showNotification('⏳ Arrêt de tous les services...', 'warning');
try {
const data = await fetchJSON('/api/services/stop-all', { method: 'POST' });
showNotification('⏹️ Services arrêtés', 'warning');
setTimeout(refreshServices, 1000);
} catch (e) {
alert('❌ Erreur: ' + e.message);
}
}
function openService(url) {
window.open(url, '_blank');
}
// Notification system
function showNotification(message, type = 'info') {
// Remove existing notification
const existing = document.getElementById('notification');
if (existing) existing.remove();
const colors = {
success: { bg: '#052e16', border: '#22c55e', text: '#22c55e' },
warning: { bg: '#422006', border: '#f59e0b', text: '#f59e0b' },
error: { bg: '#450a0a', border: '#ef4444', text: '#ef4444' },
info: { bg: '#1e293b', border: '#3b82f6', text: '#3b82f6' }
};
const c = colors[type] || colors.info;
const div = document.createElement('div');
div.id = 'notification';
div.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 15px 25px;
background: ${c.bg};
border: 1px solid ${c.border};
border-radius: 10px;
color: ${c.text};
font-weight: 500;
z-index: 9999;
animation: slideIn 0.3s ease;
`;
div.textContent = message;
document.body.appendChild(div);
setTimeout(() => div.remove(), 4000);
}
// ============================================================
// SECTION: Sauvegardes / Backups
// ============================================================
async function refreshBackupStats() {
try {
const data = await fetchJSON('/api/backup/stats');
// L'API retourne { stats: { workflows: { file_count, ... }, correction_packs: { ... }, ... } }
const stats = data.stats || {};
document.getElementById('statWorkflowsBackup').textContent = stats.workflows?.file_count || 0;
document.getElementById('statCorrectionsBackup').textContent = stats.correction_packs?.file_count || 0;
// Modèles = embeddings + faiss_index
const modelsCount = (stats.embeddings?.file_count || 0) + (stats.faiss_index?.file_count || 0);
document.getElementById('statModelsBackup').textContent = modelsCount;
document.getElementById('statSessionsBackup').textContent = stats.coaching_sessions?.file_count || 0;
} catch (e) {
console.error('Error loading backup stats:', e);
}
}
async function downloadBackup(type) {
showNotification(`⬇️ Téléchargement ${type}...`, 'info');
try {
const response = await fetch(`/api/backup/${type}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `rpa_${type}_${new Date().toISOString().split('T')[0]}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
showNotification(`✅ Backup ${type} téléchargé`, 'success');
} catch (e) {
showNotification(`❌ Erreur: ${e.message}`, 'error');
}
}
async function downloadFullBackup() {
showNotification('⬇️ Génération du backup complet...', 'info');
try {
const response = await fetch('/api/backup/full', { method: 'POST' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `rpa_full_backup_${new Date().toISOString().split('T')[0]}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
showNotification('✅ Backup complet téléchargé', 'success');
} catch (e) {
showNotification(`❌ Erreur: ${e.message}`, 'error');
}
}
// ============================================================
// SECTION: Système / Version
// ============================================================
async function refreshSystemInfo() {
try {
// Version info — l'API retourne { version: { version, date, build, components } }
const versionData = await fetchJSON('/api/version');
const vInfo = versionData.version || {};
document.getElementById('sysVersion').textContent = vInfo.version || '-';
document.getElementById('sysCommit').textContent = (vInfo.build || '-').substring(0, 7);
// System info — l'API retourne { system_info: { version, system, backups_available, update_available } }
const sysData = await fetchJSON('/api/version/system-info');
const sysInfo = sysData.system_info || {};
const sysDetails = sysInfo.system || {};
const infoDiv = document.getElementById('systemInfoDetails');
infoDiv.innerHTML = `
<div class="system-info-row"><span>Python</span><span>${sysDetails.python_version || '-'}</span></div>
<div class="system-info-row"><span>Base path</span><span>${sysDetails.base_path || '-'}</span></div>
<div class="system-info-row"><span>Backups disponibles</span><span>${sysInfo.backups_available || 0}</span></div>
<div class="system-info-row"><span>Version</span><span>${vInfo.version || '-'}</span></div>
<div class="system-info-row"><span>Build</span><span>${vInfo.build || '-'}</span></div>
<div class="system-info-row"><span>Date</span><span>${vInfo.date || '-'}</span></div>
`;
// Check for updates
const update = await fetchJSON('/api/version/check-update');
if (update.update_available) {
document.getElementById('sysUpdateStatus').innerHTML = `<span style="color:#f59e0b;">⬆️ ${update.update?.version}</span>`;
} else {
document.getElementById('sysUpdateStatus').innerHTML = '<span style="color:#22c55e;">✅ À jour</span>';
}
// Version backups
await refreshVersionBackups();
} catch (e) {
console.error('Error loading system info:', e);
document.getElementById('systemInfoDetails').innerHTML = `<p style="color:#ef4444;">Erreur: ${e.message}</p>`;
}
}
async function refreshVersionBackups() {
try {
const data = await fetchJSON('/api/version/backups');
const list = document.getElementById('versionBackupsList');
if (!data.backups || data.backups.length === 0) {
list.innerHTML = '<p style="color:#64748b;text-align:center;padding:20px;">Aucun point de restauration</p>';
return;
}
list.innerHTML = data.backups.slice(0, 10).map(b => `
<div class="version-backup-item">
<div class="version-backup-info">
<h5>${b.label || 'Point de restauration'}</h5>
<span>${new Date(b.created_at || b.timestamp).toLocaleString('fr-FR')} — v${b.version || '-'}</span>
</div>
<button class="btn btn-secondary btn-small" onclick="restoreVersion('${b.id || b.path}')" title="Restauration non disponible dans cette version" disabled style="opacity:0.5;cursor:not-allowed;">⏮️ Restaurer</button>
</div>
`).join('');
} catch (e) {
document.getElementById('versionBackupsList').innerHTML = `<p style="color:#ef4444;">Erreur: ${e.message}</p>`;
}
}
async function createVersionBackup() {
const label = prompt('Nom du point de restauration (optionnel):');
showNotification('💾 Création du point de restauration...', 'info');
try {
await fetchJSON('/api/version/create-backup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label })
});
showNotification('✅ Point de restauration créé', 'success');
await refreshVersionBackups();
} catch (e) {
showNotification(`❌ Erreur: ${e.message}`, 'error');
}
}
async function restoreVersion(backupId) {
// La fonctionnalité de rollback n'est pas encore implémentée côté serveur
showNotification('La restauration n\'est pas encore disponible dans cette version.', 'warning');
}
async function uploadUpdatePackage() {
const input = document.getElementById('updateFileInput');
if (!input.files.length) return;
const file = input.files[0];
const formData = new FormData();
formData.append('file', file);
showNotification('📤 Upload du package...', 'info');
try {
const response = await fetch('/api/version/upload-update', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.error) throw new Error(data.error);
const infoDiv = document.getElementById('updatePackageInfo');
infoDiv.style.display = 'block';
infoDiv.innerHTML = `
<p style="color:#22c55e;margin-bottom:10px;">✅ Package uploadé</p>
<p style="color:#94a3b8;font-size:13px;">Version: ${data.manifest?.version || '-'}</p>
<p style="color:#94a3b8;font-size:13px;">${data.manifest?.description || ''}</p>
`;
showNotification('✅ Package uploadé avec succès', 'success');
await refreshSystemInfo();
} catch (e) {
showNotification(`❌ Erreur: ${e.message}`, 'error');
}
input.value = '';
}
// Sections Correction Packs et Apprentissage retirées (onglets supprimés)
// ============================================================
// SECTION: Fleet Management
// ============================================================
let _fleetAutoRefresh = null;
async function refreshFleet() {
const tbody = document.getElementById('fleetTableBody');
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:40px;color:#64748b;"><div class="loading"><div class="spinner"></div>Chargement...</div></td></tr>';
try {
const data = await fetchJSON('/api/fleet/fleet');
const active = data.active || [];
const uninstalled = data.uninstalled || [];
const all = [...active, ...uninstalled];
// Stats
document.getElementById('fleetActiveCount').textContent = active.length;
document.getElementById('fleetTotalCount').textContent = all.length;
document.getElementById('fleetUninstalledCount').textContent = uninstalled.length;
// Dernière activité
let lastSeen = '-';
if (active.length > 0) {
const sorted = active.filter(a => a.last_seen_at).sort((a, b) =>
new Date(b.last_seen_at) - new Date(a.last_seen_at)
);
if (sorted.length > 0) {
lastSeen = formatFleetDate(sorted[0].last_seen_at);
}
}
document.getElementById('fleetLastSeen').textContent = lastSeen;
// Badge header
updateFleetBadge(active.length, all.length);
if (all.length === 0) {
tbody.innerHTML = `<tr><td colspan="9" style="text-align:center;padding:40px;color:#64748b;">
<div style="font-size:48px;margin-bottom:15px;">🖥</div>
<p>Aucun agent enregistré</p>
<p style="font-size:12px;margin-top:10px;">Utilisez le bouton "Enregistrer un agent" pour ajouter un poste</p>
</td></tr>`;
return;
}
tbody.innerHTML = all.map(agent => {
const isActive = agent.status === 'active';
const statusBadge = isActive
? '<span style="display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:rgba(34,197,94,0.15);color:#22c55e;">actif</span>'
: '<span style="display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:rgba(100,116,139,0.15);color:#64748b;">révoqué</span>';
const downloadBtn = isActive
? `<a href="/api/fleet/download/${encodeURIComponent(agent.machine_id)}" class="btn btn-primary btn-small" style="text-decoration:none;font-size:11px;" title="Télécharger l'installeur pré-configuré">📥</a>`
: `<span style="font-size:16px;color:#475569;cursor:not-allowed;opacity:0.4;" title="Agent révoqué — installeur indisponible">📥</span>`;
return `<tr style="border-bottom:1px solid #334155;">
<td style="padding:12px 10px;font-family:monospace;font-size:12px;">${agent.machine_id || '-'}</td>
<td style="padding:12px 10px;">${agent.user_name || '-'}</td>
<td style="padding:12px 10px;font-size:12px;">${agent.hostname || '-'}</td>
<td style="padding:12px 10px;font-size:12px;">${agent.version || '-'}</td>
<td style="padding:12px 10px;">${statusBadge}</td>
<td style="padding:12px 10px;font-size:12px;color:#94a3b8;">${formatFleetDate(agent.last_seen_at)}</td>
<td style="padding:12px 10px;font-size:12px;color:#94a3b8;">${formatFleetDate(agent.enrolled_at)}</td>
<td style="padding:12px 10px;text-align:center;">${downloadBtn}</td>
<td style="padding:12px 10px;">
${isActive
? `<button class="btn btn-danger btn-small" onclick="revokeAgent('${agent.machine_id}')">Révoquer</button>`
: `<span style="font-size:12px;color:#64748b;" title="${agent.uninstall_reason || ''}">${formatFleetDate(agent.uninstalled_at)}</span>`
}
</td>
</tr>`;
}).join('');
} catch (e) {
console.error('Fleet error:', e);
tbody.innerHTML = `<tr><td colspan="9" style="text-align:center;padding:40px;color:#ef4444;">
Erreur : ${e.message}<br>
<span style="font-size:12px;color:#94a3b8;">Vérifiez que le serveur streaming (port 5005) est démarré.</span>
</td></tr>`;
document.getElementById('fleetActiveCount').textContent = '!';
}
}
async function refreshFleetBadge() {
try {
const data = await fetchJSON('/api/fleet/fleet');
const active = (data.active || []).length;
const total = active + (data.uninstalled || []).length;
updateFleetBadge(active, total);
} catch (e) {
// Silencieux — le badge restera à son état précédent
}
}
function updateFleetBadge(active, total) {
const el = document.getElementById('fleetBadgeCount');
if (el) {
el.textContent = `${active} actif${active !== 1 ? 's' : ''} / ${total}`;
}
}
function formatFleetDate(iso) {
if (!iso) return '-';
try {
const d = new Date(iso);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const mo = String(d.getMonth() + 1).padStart(2, '0');
return `${hh}:${mm} - ${dd}/${mo}`;
} catch (e) { return iso; }
}
function showEnrollModal() {
document.getElementById('enrollModal').style.display = 'flex';
}
function closeEnrollModal() {
document.getElementById('enrollModal').style.display = 'none';
document.getElementById('enrollUserName').value = '';
document.getElementById('enrollUserEmail').value = '';
}
async function enrollAgent() {
const userName = document.getElementById('enrollUserName').value.trim();
const userEmail = document.getElementById('enrollUserEmail').value.trim();
if (!userName) {
showNotification('Le nom du collaborateur est obligatoire', 'error');
return;
}
if (!userEmail) {
showNotification('L\'email est obligatoire', 'error');
return;
}
// Identifiant technique auto-généré
const machineId = 'lea-' + Math.random().toString(36).slice(2, 10);
const payload = {
machine_id: machineId,
user_name: userName,
user_email: userEmail,
};
showNotification('Enregistrement en cours...', 'info');
try {
const data = await fetchJSON('/api/fleet/enroll', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
closeEnrollModal();
showNotification('Agent enregistré avec succès', 'success');
// Afficher le token + lien de téléchargement
if (data.api_token) {
document.getElementById('generatedToken').value = data.api_token;
document.getElementById('enrollDownloadLink').href =
'/api/fleet/download/' + encodeURIComponent(machineId);
document.getElementById('tokenModal').style.display = 'flex';
}
await refreshFleet();
} catch (e) {
if (e.message.includes('409') || e.message.includes('already_enrolled')) {
showNotification('Ce machine_id est déjà enregistré et actif', 'error');
} else {
showNotification('Erreur : ' + e.message, 'error');
}
}
}
async function revokeAgent(machineId) {
if (!confirm(`Révoquer l'agent "${machineId}" ?\n\nL'agent ne pourra plus se connecter au serveur.`)) return;
showNotification('Révocation en cours...', 'info');
try {
await fetchJSON('/api/fleet/uninstall', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ machine_id: machineId, reason: 'admin_revoke' }),
});
showNotification('Agent révoqué', 'success');
await refreshFleet();
} catch (e) {
showNotification('Erreur : ' + e.message, 'error');
}
}
function copyToken() {
const input = document.getElementById('generatedToken');
input.select();
navigator.clipboard.writeText(input.value).then(() => {
showNotification('Token copié dans le presse-papiers', 'success');
}).catch(() => {
// Fallback
document.execCommand('copy');
showNotification('Token copié', 'success');
});
}
function closeTokenModal() {
document.getElementById('tokenModal').style.display = 'none';
}
// ============================================================
// SECTION: Configuration
// ============================================================
let currentConfig = {};
async function refreshConfig() {
try {
const data = await fetchJSON('/api/config');
if (data.success) {
currentConfig = data.config;
populateConfigForm(data.config);
showNotification('Configuration chargee', 'success');
}
} catch (e) {
console.error('Error loading config:', e);
showNotification('Erreur chargement config: ' + e.message, 'error');
}
}
function populateConfigForm(config) {
// Services
if (config.services) {
for (const [key, svc] of Object.entries(config.services)) {
const hostEl = document.getElementById(`cfg_${key}_host`);
const portEl = document.getElementById(`cfg_${key}_port`);
if (hostEl) hostEl.value = svc.host || 'localhost';
if (portEl) portEl.value = svc.port || '';
}
}
// LLM
if (config.llm) {
document.getElementById('cfg_llm_provider').value = config.llm.provider || 'ollama';
document.getElementById('cfg_llm_base_url').value = config.llm.base_url || 'http://localhost:11434';
document.getElementById('cfg_llm_temperature').value = config.llm.temperature || 0.7;
document.getElementById('tempValue').textContent = config.llm.temperature || 0.7;
document.getElementById('cfg_llm_max_tokens').value = config.llm.max_tokens || 2048;
// Charger les modeles Ollama
refreshOllamaModels();
}
// Detection (OWL-v2 legacy) — section UI retiree, config preservee telle quelle
// Database
if (config.database) {
document.getElementById('cfg_database_type').value = config.database.type || 'sqlite';
document.getElementById('cfg_database_path').value = config.database.path || 'data/training/workflows.db';
document.getElementById('cfg_database_backup_enabled').checked = config.database.backup_enabled !== false;
document.getElementById('cfg_database_backup_interval').value = config.database.backup_interval_hours || 24;
}
// Security
if (config.security) {
document.getElementById('cfg_security_encryption').checked = config.security.enable_encryption !== false;
document.getElementById('cfg_security_auth').checked = config.security.require_authentication === true;
document.getElementById('cfg_security_timeout').value = config.security.session_timeout_minutes || 60;
document.getElementById('cfg_security_origins').value = (config.security.allowed_origins || []).join(',');
}
// Logging
if (config.logging) {
document.getElementById('cfg_logging_level').value = config.logging.level || 'INFO';
document.getElementById('cfg_logging_file').value = config.logging.file_path || 'logs/rpa_vision.log';
document.getElementById('cfg_logging_max_size').value = config.logging.max_size_mb || 50;
document.getElementById('cfg_logging_backup_count').value = config.logging.backup_count || 5;
}
}
function collectConfigFromForm() {
const config = {
version: currentConfig.version || '1.0.0',
services: {
vwb_backend: {
host: document.getElementById('cfg_vwb_backend_host').value || 'localhost',
port: parseInt(document.getElementById('cfg_vwb_backend_port').value) || 5002,
description: 'Visual Workflow Builder - Backend API'
},
vwb_frontend: {
host: document.getElementById('cfg_vwb_frontend_host').value || 'localhost',
port: parseInt(document.getElementById('cfg_vwb_frontend_port').value) || 3002,
description: 'Visual Workflow Builder - Interface React'
},
web_dashboard: {
host: 'localhost',
port: 5001,
description: 'Dashboard de monitoring RPA'
},
agent_chat: {
host: document.getElementById('cfg_agent_chat_host').value || 'localhost',
port: parseInt(document.getElementById('cfg_agent_chat_port').value) || 5004,
description: 'Agent conversationnel RPA'
},
streaming: {
host: document.getElementById('cfg_streaming_host').value || 'localhost',
port: parseInt(document.getElementById('cfg_streaming_port').value) || 5005,
description: 'Serveur de streaming et capture temps réel'
},
upload_api: {
host: document.getElementById('cfg_upload_api_host').value || 'localhost',
port: parseInt(document.getElementById('cfg_upload_api_port').value) || 8000,
description: 'API d\'upload de sessions'
}
},
llm: {
provider: document.getElementById('cfg_llm_provider').value,
base_url: document.getElementById('cfg_llm_base_url').value,
model: document.getElementById('cfg_llm_model').value,
temperature: parseFloat(document.getElementById('cfg_llm_temperature').value),
max_tokens: parseInt(document.getElementById('cfg_llm_max_tokens').value),
description: 'Modele LLM pour le parsing de commandes'
},
vlm: {
provider: document.getElementById('cfg_llm_provider').value,
base_url: document.getElementById('cfg_llm_base_url').value,
model: document.getElementById('cfg_vlm_model').value,
description: 'Modele VLM pour l\'analyse visuelle'
},
// 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',
dimension: 512,
use_gpu: true,
cache_size: 10000,
description: 'Configuration des embeddings visuels'
},
database: {
type: document.getElementById('cfg_database_type').value,
path: document.getElementById('cfg_database_path').value,
backup_enabled: document.getElementById('cfg_database_backup_enabled').checked,
backup_interval_hours: parseInt(document.getElementById('cfg_database_backup_interval').value),
description: 'Base de donnees des workflows'
},
faiss: currentConfig.faiss || {
index_type: 'Flat',
use_gpu: true,
nprobe: 10,
description: 'Configuration de l\'index FAISS'
},
security: {
enable_encryption: document.getElementById('cfg_security_encryption').checked,
session_timeout_minutes: parseInt(document.getElementById('cfg_security_timeout').value),
require_authentication: document.getElementById('cfg_security_auth').checked,
allowed_origins: document.getElementById('cfg_security_origins').value.split(',').map(s => s.trim()).filter(s => s),
description: 'Parametres de securite'
},
logging: {
level: document.getElementById('cfg_logging_level').value,
file_path: document.getElementById('cfg_logging_file').value,
max_size_mb: parseInt(document.getElementById('cfg_logging_max_size').value),
backup_count: parseInt(document.getElementById('cfg_logging_backup_count').value),
description: 'Configuration des logs'
}
};
return config;
}
async function saveConfig() {
try {
const config = collectConfigFromForm();
showNotification('Sauvegarde en cours...', 'info');
const response = await fetchJSON('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (response.success) {
currentConfig = config;
showNotification('Configuration sauvegardee avec succes', 'success');
} else {
showNotification('Erreur: ' + (response.error || 'Inconnu'), 'error');
}
} catch (e) {
showNotification('Erreur: ' + e.message, 'error');
}
}
async function refreshOllamaModels() {
try {
const data = await fetchJSON('/api/config/ollama-models');
const llmSelect = document.getElementById('cfg_llm_model');
const vlmSelect = document.getElementById('cfg_vlm_model');
if (data.success && data.models) {
const llmOptions = data.models.map(m => `<option value="${m}" ${m === currentConfig.llm?.model ? 'selected' : ''}>${m}</option>`).join('');
const vlmOptions = data.models.map(m => `<option value="${m}" ${m === currentConfig.vlm?.model ? 'selected' : ''}>${m}</option>`).join('');
llmSelect.innerHTML = llmOptions || '<option value="">Aucun modele</option>';
vlmSelect.innerHTML = vlmOptions || '<option value="">Aucun modele</option>';
} else {
llmSelect.innerHTML = '<option value="">Ollama inaccessible</option>';
vlmSelect.innerHTML = '<option value="">Ollama inaccessible</option>';
}
} catch (e) {
console.error('Error loading Ollama models:', e);
document.getElementById('cfg_llm_model').innerHTML = '<option value="">Erreur chargement</option>';
document.getElementById('cfg_vlm_model').innerHTML = '<option value="">Erreur chargement</option>';
}
}
async function testOllamaConnection() {
const baseUrl = document.getElementById('cfg_llm_base_url').value;
showNotification('Test connexion Ollama...', 'info');
try {
const response = await fetchJSON('/api/config/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'ollama', base_url: baseUrl })
});
updateTestResults('Ollama', response.success, response.message || response.error);
if (response.success) {
showNotification('Connexion Ollama OK - ' + (response.models?.length || 0) + ' modeles', 'success');
await refreshOllamaModels();
} else {
showNotification('Erreur Ollama: ' + response.error, 'error');
}
} catch (e) {
updateTestResults('Ollama', false, e.message);
showNotification('Erreur: ' + e.message, 'error');
}
}
async function testDatabaseConnection() {
const path = document.getElementById('cfg_database_path').value;
showNotification('Test connexion base de donnees...', 'info');
try {
const response = await fetchJSON('/api/config/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'database', path: path })
});
updateTestResults('Database', response.success, response.message || response.error);
if (response.success) {
showNotification('Base de donnees accessible', 'success');
} else {
showNotification('Erreur DB: ' + response.error, 'error');
}
} catch (e) {
updateTestResults('Database', false, e.message);
showNotification('Erreur: ' + e.message, 'error');
}
}
async function testConnection(type, serviceKey) {
const hostEl = document.getElementById(`cfg_${serviceKey}_host`);
const portEl = document.getElementById(`cfg_${serviceKey}_port`);
if (!hostEl || !portEl) return;
showNotification(`Test connexion ${serviceKey}...`, 'info');
try {
const response = await fetchJSON('/api/config/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'service',
host: hostEl.value,
port: portEl.value
})
});
updateTestResults(serviceKey, response.success, response.message || response.error);
if (response.success) {
showNotification(`${serviceKey} accessible`, 'success');
} else {
showNotification(`${serviceKey} inaccessible: ` + response.error, 'error');
}
} catch (e) {
updateTestResults(serviceKey, false, e.message);
showNotification('Erreur: ' + e.message, 'error');
}
}
function updateTestResults(name, success, message) {
const container = document.getElementById('testResultsContent');
const existing = container.querySelector(`[data-test="${name}"]`);
const html = `
<div data-test="${name}" style="display:flex;align-items:center;gap:10px;padding:8px;border-radius:6px;margin-bottom:5px;background:${success ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)'};">
<span style="color:${success ? '#22c55e' : '#ef4444'};">${success ? '✅' : '❌'}</span>
<span style="color:#e2e8f0;font-weight:500;">${name}</span>
<span style="color:#94a3b8;font-size:12px;">${message}</span>
</div>
`;
if (existing) {
existing.outerHTML = html;
} else {
if (container.querySelector('.loading') || container.textContent.includes('Cliquez')) {
container.innerHTML = html;
} else {
container.innerHTML += html;
}
}
}
async function exportConfig() {
try {
const response = await fetch('/api/config/export');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `rpa_config_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
showNotification('Configuration exportee', 'success');
} catch (e) {
showNotification('Erreur export: ' + e.message, 'error');
}
}
async function importConfig(input) {
if (!input.files.length) return;
const file = input.files[0];
showNotification('Import en cours...', 'info');
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/config/import', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
showNotification('Configuration importee avec succes', 'success');
await refreshConfig();
} else {
showNotification('Erreur import: ' + data.error, 'error');
}
} catch (e) {
showNotification('Erreur import: ' + e.message, 'error');
}
input.value = ''; // Reset file input
}
// ============================================================
// SECTION: Streaming
// ============================================================
// Utiliser le proxy du dashboard pour éviter les problèmes CORS
const STREAMING_BASE = '/api/streaming';
const VWB_IMPORT_URL = 'http://localhost:5002/api/workflows/import-core';
async function refreshStreaming() {
await Promise.all([
refreshStreamingStats(),
refreshStreamingSessions(),
refreshStreamingWorkflows()
]);
}
async function refreshStreamingStats() {
const statusEl = document.getElementById('streamServerStatus');
const detailsEl = document.getElementById('streamServerDetails');
try {
const data = await fetchJSON(`${STREAMING_BASE}/stats`);
statusEl.innerHTML = '<span style="color:#22c55e;">✅</span>';
statusEl.title = 'Serveur streaming en ligne';
document.getElementById('streamActiveSessions').textContent = data.active_sessions || 0;
document.getElementById('streamTotalEvents').textContent = data.total_events || 0;
document.getElementById('streamWorkflowsBuilt').textContent = data.workflows_built || 0;
// Détails du serveur
const rows = [];
if (data.uptime !== undefined) rows.push({label: 'Uptime', value: formatUptime(data.uptime)});
if (data.total_sessions !== undefined) rows.push({label: 'Sessions totales', value: data.total_sessions});
if (data.active_sessions !== undefined) rows.push({label: 'Sessions actives', value: data.active_sessions});
if (data.total_events !== undefined) rows.push({label: 'Événements totaux', value: data.total_events});
if (data.workflows_built !== undefined) rows.push({label: 'Workflows construits', value: data.workflows_built});
if (data.events_per_second !== undefined) rows.push({label: 'Événements/sec', value: (data.events_per_second || 0).toFixed(2)});
if (data.memory_usage_mb !== undefined) rows.push({label: 'Mémoire utilisée', value: Math.round(data.memory_usage_mb) + ' MB'});
if (data.server_version) rows.push({label: 'Version serveur', value: data.server_version});
if (rows.length === 0) {
// Afficher les données brutes si les clés attendues ne sont pas présentes
const rawRows = Object.entries(data).map(([k, v]) => ({label: k, value: JSON.stringify(v)}));
detailsEl.innerHTML = rawRows.map(r => `
<div class="stat-row"><span>${r.label}</span><span>${r.value}</span></div>
`).join('');
} else {
detailsEl.innerHTML = rows.map(r => `
<div class="stat-row"><span>${r.label}</span><span>${r.value}</span></div>
`).join('');
}
} catch (e) {
statusEl.innerHTML = '<span style="color:#ef4444;">❌</span>';
statusEl.title = 'Serveur streaming hors ligne';
document.getElementById('streamActiveSessions').textContent = '-';
document.getElementById('streamTotalEvents').textContent = '-';
document.getElementById('streamWorkflowsBuilt').textContent = '-';
detailsEl.innerHTML = `<div style="text-align:center;padding:20px;color:#ef4444;">
❌ Serveur streaming inaccessible (port 5005)<br>
<span style="font-size:12px;color:#94a3b8;margin-top:5px;display:block;">${e.message}</span>
</div>`;
}
}
async function refreshStreamingSessions() {
const list = document.getElementById('streamSessionsList');
try {
const data = await fetchJSON(`${STREAMING_BASE}/sessions`);
const sessions = data.sessions || data || [];
if (!Array.isArray(sessions) || sessions.length === 0) {
list.innerHTML = '<p style="text-align:center;color:#64748b;padding:30px;">Aucune session de streaming active</p>';
return;
}
list.innerHTML = sessions.map(s => {
const sessionId = s.session_id || s.id || 'N/A';
const status = s.status || 'inconnu';
const events = s.events_count || s.total_events || 0;
const started = s.started_at ? new Date(s.started_at).toLocaleString('fr-FR') : '-';
const statusColor = status === 'active' ? '#22c55e' : (status === 'completed' ? '#3b82f6' : '#64748b');
return `
<div class="session-item">
<div class="session-info">
<h4>
${sessionId}
<span style="margin-left:10px;padding:3px 8px;border-radius:4px;font-size:12px;background:rgba(${statusColor === '#22c55e' ? '34,197,94' : '59,130,246'},0.15);color:${statusColor};">
${status}
</span>
</h4>
<div class="meta">📅 ${started} • 🎬 ${events} événements</div>
</div>
</div>
`;
}).join('');
} catch (e) {
list.innerHTML = `<p style="color:#ef4444;text-align:center;padding:20px;">Erreur: ${e.message}</p>`;
}
}
async function refreshStreamingWorkflows() {
const list = document.getElementById('streamWorkflowsList');
try {
const data = await fetchJSON(`${STREAMING_BASE}/workflows`);
const workflows = data.workflows || data || [];
if (!Array.isArray(workflows) || workflows.length === 0) {
list.innerHTML = '<p style="text-align:center;color:#64748b;padding:30px;">Aucun workflow construit par le streaming</p>';
return;
}
list.innerHTML = workflows.map(wf => {
const wfId = wf.workflow_id || wf.id || 'N/A';
const name = wf.name || wfId;
const nodes = wf.nodes_count || wf.nodes || 0;
const edges = wf.edges_count || wf.edges || 0;
const created = wf.created_at ? new Date(wf.created_at).toLocaleString('fr-FR') : '-';
const sessionId = wf.source_session_id || wf.session_id || '-';
return `
<div class="session-item">
<div class="session-info">
<h4>${name}</h4>
<div class="meta">📅 ${created} • 📦 ${nodes} nœuds, ${edges} arêtes • 🎬 Session: ${sessionId}</div>
</div>
<div class="session-actions">
<button class="btn btn-success btn-small" onclick="importStreamingWorkflow('${wfId}')">
📥 Importer dans VWB
</button>
</div>
</div>
`;
}).join('');
} catch (e) {
list.innerHTML = `<p style="color:#ef4444;text-align:center;padding:20px;">Erreur: ${e.message}</p>`;
}
}
async function importStreamingWorkflow(workflowId) {
if (!confirm(`Importer le workflow "${workflowId}" dans le Visual Workflow Builder ?`)) return;
showNotification('📥 Import en cours...', 'info');
try {
const data = await fetchJSON(VWB_IMPORT_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow_id: workflowId, source: 'streaming' })
});
if (data.error) {
showNotification('❌ Erreur: ' + data.error, 'error');
} else {
showNotification('✅ Workflow importé dans le VWB', 'success');
}
} catch (e) {
showNotification('❌ Erreur: ' + e.message, 'error');
}
}
function formatUptime(seconds) {
if (!seconds && seconds !== 0) return '-';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
refreshServices(); // Charger les services en premier
refreshWorkflows();
refreshFleetBadge(); // Badge agents actifs dans le header
setInterval(refreshServices, 15000);
setInterval(refreshFleetBadge, 30000); // Rafraîchir le badge fleet toutes les 30s
// Charger les stats des sections secondaires (en arrière-plan)
setTimeout(() => {
refreshBackupStats();
refreshSystemInfo();
}, 1000);
});
// Add CSS animation for notifications
const style = document.createElement('style');
style.textContent = '@keyframes slideIn { from { transform: translateX(100px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }';
document.head.appendChild(style);
</script>
</body>
</html>