#!/usr/bin/env python3 """ Application Flask pour le Dashboard Web RPA Vision V3 Fonctionnalités: - Monitoring temps réel avec WebSocket - Gestion des workflows - Visualisation des sessions et screenshots - Graphiques de performance Sécurité (Fix P0-A) : - HTTP Basic Auth sur tous les endpoints (middleware before_request). - Credentials via DASHBOARD_USER / DASHBOARD_PASSWORD. - Exceptions : /health, /healthz, /api/health (monitoring externe). - Désactivation auth en dev local : DASHBOARD_AUTH_DISABLED=true """ import base64 import hmac import os import sys import json import subprocess import threading import time from pathlib import Path from datetime import datetime from flask import Flask, render_template, jsonify, request, send_file, Response from flask_socketio import SocketIO, emit from prometheus_client import generate_latest, CONTENT_TYPE_LATEST # Ajouter le répertoire parent au path sys.path.insert(0, str(Path(__file__).parent.parent)) from core.persistence import StorageManager from core.models import RawSession from core.monitoring import get_logger, api_logger from core.monitoring.chain_manager import ChainManager from core.monitoring.trigger_manager import TriggerManager from core.monitoring.log_exporter import LogExporter from core.monitoring.automation_scheduler import AutomationScheduler # Modules pour backup et versioning from core.system.backup_exporter import get_backup_exporter from core.system.version_manager import get_version_manager app = Flask(__name__) app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-key-change-in-production') socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading') # ============================================================================= # Fix P0-A : HTTP Basic Auth sur le dashboard (port 5001) # ============================================================================= # Avant ce fix, 71 endpoints étaient exposés sans authentification. # On ajoute un middleware Flask (before_request) qui exige un header # Authorization: Basic . Les credentials sont pris dans l'environnement. # # Chemins publics (pas de challenge) : healthcheck uniquement — ils servent au # monitoring externe (Prometheus, systemd, k8s, NPM reverse proxy). # # Pour désactiver l'auth (dev local, tests) : DASHBOARD_AUTH_DISABLED=true. # Les tests unitaires définissent cette variable via un flag Flask config. _DASHBOARD_USER = os.getenv("DASHBOARD_USER", "lea").strip() _DASHBOARD_PASSWORD = os.getenv("DASHBOARD_PASSWORD", "").strip() _DASHBOARD_AUTH_DISABLED = os.getenv("DASHBOARD_AUTH_DISABLED", "").lower() in ( "1", "true", "yes", ) # Si pas de password défini en env ET auth pas explicitement désactivée → # on utilise un mot de passe par défaut "safe" (long, random-ish) ET on log # un WARNING très visible au démarrage pour forcer Dom à le configurer # avant un déploiement prod. On ne veut surtout pas générer un mot de passe # aléatoire à chaque boot (même problème que l'API token auto-généré). if not _DASHBOARD_PASSWORD and not _DASHBOARD_AUTH_DISABLED: _DASHBOARD_PASSWORD = "changeme-dashboard-Medecin2026!" api_logger.warning( "[SÉCURITÉ] DASHBOARD_PASSWORD non défini en env — utilisation d'un " "mot de passe par défaut temporaire. DÉFINIR DASHBOARD_PASSWORD " "AVANT TOUT DÉPLOIEMENT (identifiant : DASHBOARD_USER)." ) # Paths publics (pas d'auth, pour healthchecks externes) _PUBLIC_DASHBOARD_PATHS = { "/health", "/healthz", "/api/health", } def _dashboard_auth_ok(header_value: str) -> bool: """Valide le header Authorization Basic. Comparaison constant-time.""" if not header_value or not header_value.lower().startswith("basic "): return False try: decoded = base64.b64decode(header_value[6:].strip()).decode("utf-8") except (ValueError, UnicodeDecodeError): return False if ":" not in decoded: return False user, _, password = decoded.partition(":") # Comparaison constant-time pour éviter les timing attacks. user_ok = hmac.compare_digest(user, _DASHBOARD_USER) pwd_ok = hmac.compare_digest(password, _DASHBOARD_PASSWORD) return user_ok and pwd_ok @app.before_request def _dashboard_basic_auth_middleware(): """Middleware d'auth HTTP Basic sur tous les endpoints HTTP du dashboard. - Bypass complet si DASHBOARD_AUTH_DISABLED=true (dev/tests). - Bypass complet si app.config['TESTING'] (pytest) et qu'aucun credential n'est passé : les tests existants du dashboard doivent continuer de passer sans retoucher chaque fixture. - Paths dans _PUBLIC_DASHBOARD_PATHS : toujours publics (healthchecks). - Sinon : header Authorization: Basic obligatoire. Note WebSocket : Flask-SocketIO utilise son propre canal pour le handshake. Le before_request ci-dessus s'applique à la requête HTTP de l'upgrade (compatible mode threading). Les sockets post-handshake ne passent pas par Flask, c'est acceptable pour un MVP (le client doit avoir passé l'auth HTTP). """ # Dev / tests : bypass total if _DASHBOARD_AUTH_DISABLED: return None if app.config.get("TESTING") and not app.config.get("DASHBOARD_AUTH_ENABLED"): return None path = request.path or "/" if path in _PUBLIC_DASHBOARD_PATHS: return None header_value = request.headers.get("Authorization", "") if _dashboard_auth_ok(header_value): return None # Pas authentifié — challenge 401 avec WWW-Authenticate return Response( '{"error": "authentication required"}', status=401, mimetype="application/json", headers={"WWW-Authenticate": 'Basic realm="RPA Vision V3 Dashboard"'}, ) @app.get('/healthz') def healthz(): """Healthcheck minimal (systemd/k8s). Public — pas d'auth.""" return jsonify({ 'status': 'ok', 'service': 'rpa-vision-v3-dashboard', 'timestamp': datetime.now().isoformat(), }) @app.get('/api/health') def api_health(): """Healthcheck JSON public — pas d'auth (monitoring externe).""" return jsonify({ 'status': 'ok', 'service': 'rpa-vision-v3-dashboard', 'timestamp': datetime.now().isoformat(), }) @app.get('/health') def health(): """Healthcheck public — pas d'auth (NPM reverse proxy).""" return jsonify({ 'status': 'ok', 'service': 'rpa-vision-v3-dashboard', 'timestamp': datetime.now().isoformat(), }) # Chemins BASE_PATH = Path(__file__).parent.parent DATA_PATH = BASE_PATH / "data" / "training" SESSIONS_PATH = DATA_PATH / "sessions" WORKFLOWS_PATH = DATA_PATH / "workflows" LOGS_PATH = BASE_PATH / "logs" # StorageManager storage = StorageManager(base_path=str(DATA_PATH)) # Monitoring managers chain_manager = ChainManager(storage_path=DATA_PATH / "chains") trigger_manager = TriggerManager(storage_path=DATA_PATH / "triggers") log_exporter = LogExporter(logs_path=LOGS_PATH) # Automation scheduler automation_scheduler = AutomationScheduler( trigger_manager=trigger_manager, chain_manager=chain_manager, check_interval=1.0 # Vérifier toutes les secondes ) # Démarrer le scheduler automatiquement automation_scheduler.start() api_logger.info("Automation scheduler started") # État global pour le monitoring temps réel execution_state = { "running": False, "workflow_id": None, "current_node": None, "steps_executed": 0, "last_confidence": 0.0, "mode": "idle", "history": [] } # Métriques de performance performance_metrics = { "faiss_searches": [], "embedding_times": [], "capture_times": [], "total_embeddings": 0, "cache_hits": 0, "cache_misses": 0 } # ============================================================================= # Processing Status Tracker # ============================================================================= PROCESSING_STATUS_FILE = DATA_PATH / "processing_status.json" def load_processing_status(): """Charge les statuts de traitement depuis le fichier.""" try: if PROCESSING_STATUS_FILE.exists(): with open(PROCESSING_STATUS_FILE, 'r') as f: return json.load(f) except Exception: pass return {} def save_processing_status(status_dict): """Sauvegarde les statuts de traitement.""" try: PROCESSING_STATUS_FILE.parent.mkdir(parents=True, exist_ok=True) with open(PROCESSING_STATUS_FILE, 'w') as f: json.dump(status_dict, f, indent=2) except Exception as e: api_logger.error(f"Erreur sauvegarde processing status: {e}") def get_session_processing_status(session_id): """Retourne le statut de traitement d'une session.""" statuses = load_processing_status() return statuses.get(session_id, {"state": "pending", "processed_at": None}) def set_session_processing_status(session_id, state, stats=None, error=None): """Met à jour le statut de traitement d'une session.""" statuses = load_processing_status() statuses[session_id] = { "state": state, # pending, processing, completed, failed "updated_at": datetime.now().isoformat(), "processed_at": datetime.now().isoformat() if state == "completed" else None, "stats": stats, "error": str(error) if error else None } save_processing_status(statuses) # ============================================================================= # Routes principales # ============================================================================= @app.route('/') def index(): """Page d'accueil du dashboard.""" return render_template('index.html') # ============================================================================= # API Système # ============================================================================= @app.route('/api/system/status') def system_status(): """Statut du système.""" try: sessions_count = len(list(SESSIONS_PATH.glob('*'))) if SESSIONS_PATH.exists() else 0 workflows_count = len(list(WORKFLOWS_PATH.glob('*.json'))) if WORKFLOWS_PATH.exists() else 0 tests_path = BASE_PATH / "tests" unit_tests = len(list(tests_path.glob('unit/test_*.py'))) if tests_path.exists() else 0 integration_tests = len(list(tests_path.glob('integration/test_*.py'))) if tests_path.exists() else 0 dependencies_ok = True try: import torch import faiss except ImportError: dependencies_ok = False return jsonify({ 'status': 'online', 'sessions_count': sessions_count, 'workflows_count': workflows_count, 'tests': { 'total': unit_tests + integration_tests, 'unit': unit_tests, 'integration': integration_tests }, 'dependencies_ok': dependencies_ok, 'execution': execution_state, 'timestamp': datetime.now().isoformat() }) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/system/performance') def system_performance(): """Métriques de performance.""" try: # Essayer de récupérer les vraies stats faiss_stats = {} embedding_stats = {} try: from core.embedding.faiss_manager import FAISSManager # Charger l'index FAISS existant depuis le disque faiss_index_path = DATA_PATH / "faiss_index" / "main.index" faiss_metadata_path = DATA_PATH / "faiss_index" / "main.metadata" if faiss_index_path.exists() and faiss_metadata_path.exists(): fm = FAISSManager.load(faiss_index_path, faiss_metadata_path) faiss_stats = fm.get_stats() else: faiss_stats = {"total_vectors": 0, "status": "index_not_found"} except Exception as e: faiss_stats = {"error": str(e)} try: from core.embedding.embedding_cache import EmbeddingCache cache = EmbeddingCache() embedding_stats = cache.get_stats() except Exception: pass return jsonify({ 'faiss': faiss_stats, 'embedding_cache': embedding_stats, 'metrics': performance_metrics, 'timestamp': datetime.now().isoformat() }) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/system/faiss/test', methods=['POST']) def test_faiss_index(): """Teste l'index FAISS avec une recherche aléatoire.""" try: import numpy as np import time from core.embedding.faiss_manager import FAISSManager faiss_index_path = DATA_PATH / "faiss_index" / "main.index" faiss_metadata_path = DATA_PATH / "faiss_index" / "main.metadata" if not faiss_index_path.exists() or not faiss_metadata_path.exists(): return jsonify({ 'success': False, 'error': 'Index FAISS non trouvé', 'recommendation': 'Traitez des sessions pour créer l\'index' }) # Charger l'index start_load = time.time() fm = FAISSManager.load(faiss_index_path, faiss_metadata_path) load_time = (time.time() - start_load) * 1000 stats = fm.get_stats() total_vectors = stats.get('total_vectors', 0) if total_vectors == 0: return jsonify({ 'success': False, 'error': 'Index vide (0 vecteurs)', 'recommendation': 'Traitez des sessions pour ajouter des vecteurs' }) # Créer un vecteur de test aléatoire dimensions = stats.get('dimensions', 512) test_vector = np.random.rand(dimensions).astype(np.float32) test_vector = test_vector / np.linalg.norm(test_vector) # Normaliser # Rechercher les plus proches voisins start_search = time.time() results = fm.search_similar(test_vector, k=min(5, total_vectors)) search_time = (time.time() - start_search) * 1000 # Analyser les résultats (SearchResult: embedding_id, similarity, distance, metadata) result_details = [] for i, sr in enumerate(results): meta = sr.metadata or {} result_details.append({ 'rank': i + 1, 'score': round(sr.similarity, 4), 'target_id': meta.get('target_id', sr.embedding_id[:20] if sr.embedding_id else 'N/A'), 'session_id': meta.get('session_id', '')[:20] if meta.get('session_id') else 'N/A' }) # Recommandations basées sur les stats recommendations = [] if total_vectors > 10000 and stats.get('index_type') == 'Flat': recommendations.append('Passez à un index IVF pour de meilleures performances') if not stats.get('use_gpu') and total_vectors > 50000: recommendations.append('Activez le GPU pour accélérer les recherches') if search_time > 100: recommendations.append('Temps de recherche élevé - optimisez l\'index') return jsonify({ 'success': True, 'load_time_ms': round(load_time, 2), 'search_time_ms': round(search_time, 2), 'total_vectors': total_vectors, 'results_count': len(results), 'results': result_details, 'index_healthy': search_time < 100 and len(results) > 0, 'recommendations': recommendations, 'timestamp': datetime.now().isoformat() }) except Exception as e: return jsonify({ 'success': False, 'error': str(e), 'recommendation': 'Vérifiez les logs pour plus de détails' }), 500 # ============================================================================= # API Sessions # ============================================================================= @app.route('/api/agent/sessions') def list_sessions(): """Liste toutes les sessions agent.""" try: sessions = [] hide_empty = request.args.get('hide_empty', 'true').lower() == 'true' if not SESSIONS_PATH.exists(): return jsonify({'sessions': [], 'total': 0, 'hidden_empty': 0}) for session_dir in SESSIONS_PATH.iterdir(): if not session_dir.is_dir(): continue # Chercher les fichiers JSON dans le répertoire et ses sous-répertoires json_files = list(session_dir.glob('*.json')) + list(session_dir.glob('*/*.json')) if not json_files: continue # Traiter chaque fichier JSON comme une session séparée for json_path in json_files: try: # Essayer de charger avec RawSession, sinon charger comme JSON brut try: session = RawSession.load_from_file(json_path) session_id = session.session_id started_at = session.started_at ended_at = session.ended_at events_count = len(session.events) user = session.user context = session.context except Exception: # Fallback: charger comme JSON brut with open(json_path, 'r') as f: data = json.load(f) session_id = data.get('session_id', json_path.stem) started_at = datetime.fromisoformat(data.get('started_at', '2025-01-01T00:00:00')) ended_at = datetime.fromisoformat(data.get('ended_at')) if data.get('ended_at') else None events_count = len(data.get('events', [])) user = data.get('user', 'unknown') context = data.get('context', {}) # Calculer la taille du fichier JSON size_bytes = json_path.stat().st_size # Chercher les screenshots dans différents emplacements possibles # Utiliser json_path.parent car les shots sont au même niveau que le JSON json_parent = json_path.parent screenshots_dir = json_parent / "screenshots" # Structure standard shots_dir = json_parent / "shots" # Structure agent_v0 screenshot_files = [] if screenshots_dir.exists(): screenshot_files.extend(list(screenshots_dir.glob('*.png'))) if shots_dir.exists(): screenshot_files.extend(list(shots_dir.glob('*.png'))) # Ajouter la taille des screenshots for img_file in screenshot_files: size_bytes += img_file.stat().st_size size_mb = round(size_bytes / (1024 * 1024), 2) # Récupérer le statut de traitement processing_info = get_session_processing_status(session_id) sessions.append({ 'session_id': session_id, 'started_at': started_at.isoformat(), 'ended_at': ended_at.isoformat() if ended_at else None, 'events_count': events_count, 'screenshots_count': len(screenshot_files), 'user': user, 'context': context, 'size_mb': size_mb, 'path': str(json_path.parent), 'json_path': str(json_path), 'processing_state': processing_info.get('state', 'pending'), 'processed_at': processing_info.get('processed_at'), 'processing_stats': processing_info.get('stats') }) except Exception as e: print(f"Erreur lecture session {json_path.name}: {e}") continue sessions.sort(key=lambda x: x['started_at'], reverse=True) # Filtrer les sessions vides si demandé if hide_empty: all_sessions = sessions sessions = [s for s in sessions if s['screenshots_count'] > 0] hidden_empty = len(all_sessions) - len(sessions) else: hidden_empty = len([s for s in sessions if s['screenshots_count'] == 0]) return jsonify({ 'sessions': sessions, 'total': len(sessions), 'hidden_empty': hidden_empty }) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/agent/sessions/cleanup-empty', methods=['POST']) def cleanup_empty_sessions(): """Supprime les sessions sans screenshots.""" try: deleted = [] errors = [] if not SESSIONS_PATH.exists(): return jsonify({'deleted': 0, 'errors': []}) for session_dir in SESSIONS_PATH.iterdir(): if not session_dir.is_dir(): continue # Compter les screenshots screenshots = list(session_dir.glob('**/*.png')) if len(screenshots) == 0: # Vérifier qu'il y a au moins un JSON (c'est bien une session) json_files = list(session_dir.glob('**/*.json')) if json_files: try: import shutil shutil.rmtree(session_dir) deleted.append(session_dir.name) except Exception as e: errors.append({'path': str(session_dir), 'error': str(e)}) return jsonify({ 'success': True, 'deleted_count': len(deleted), 'deleted': deleted, 'errors': errors }) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/agent/sessions/') def get_session(session_id): """Détails d'une session.""" try: # Chercher la session dans tous les répertoires # Collecter tous les matches et préférer celui avec screenshots candidates = [] for dir_path in SESSIONS_PATH.iterdir(): if not dir_path.is_dir(): continue # Chercher dans le répertoire et ses sous-répertoires json_files = list(dir_path.glob('*.json')) + list(dir_path.glob('*/*.json')) for json_path in json_files: try: session = RawSession.load_from_file(json_path) if session.session_id == session_id: session_dir = json_path.parent has_shots = (session_dir / "shots").exists() or (session_dir / "screenshots").exists() candidates.append((json_path, session_dir, has_shots)) except Exception: continue if not candidates: return jsonify({'error': 'Session non trouvée'}), 404 # Préférer la version avec screenshots candidates.sort(key=lambda x: x[2], reverse=True) session_file, session_dir, _ = candidates[0] session = RawSession.load_from_file(session_file) # Lister les screenshots avec URLs screenshots = [] # Chercher dans différents emplacements possibles screenshots_dir = session_dir / "screenshots" shots_dir = session_dir / "shots" for img_dir in [screenshots_dir, shots_dir]: if img_dir.exists(): for img_file in sorted(img_dir.glob('*.png')): screenshots.append({ 'filename': img_file.name, 'url': f'/api/agent/sessions/{session_id}/screenshot/{img_file.name}', 'size': img_file.stat().st_size }) return jsonify({ 'session': { 'session_id': session.session_id, 'started_at': session.started_at.isoformat(), 'ended_at': session.ended_at.isoformat() if session.ended_at else None, 'user': session.user, 'context': session.context, 'environment': session.environment, 'events': [ { 'type': e.type, 'timestamp': e.t if hasattr(e, 't') else 0, 'window': e.window, 'screenshot_id': e.screenshot_id } for e in session.events ] }, 'screenshots': screenshots, 'screenshots_count': len(screenshots) }) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/agent/sessions//screenshot/') def get_screenshot(session_id, filename): """Récupère un screenshot. Par défaut, si une version floutée (PII) `_blurred.png` existe à côté du fichier demandé, elle est servie à la place (affichage conforme RGPD). Pour obtenir la version brute, passer `?raw=1` (réservé aux endpoints d'entraînement/grounding, à protéger par auth). """ try: want_raw = request.args.get('raw', '0') in ('1', 'true', 'yes') # Chercher le screenshot dans tous les répertoires screenshot_path = None for dir_path in SESSIONS_PATH.iterdir(): if not dir_path.is_dir(): continue # Chercher dans différents emplacements possibles possible_paths = [ dir_path / "screenshots" / filename, dir_path / "shots" / filename, ] # Chercher aussi dans les sous-répertoires for subdir in dir_path.iterdir(): if subdir.is_dir(): possible_paths.extend([ subdir / "screenshots" / filename, subdir / "shots" / filename, ]) for path in possible_paths: if path.exists(): screenshot_path = path break if screenshot_path: break if not screenshot_path: return jsonify({'error': 'Screenshot non trouvé'}), 404 # Préférer la version floutée si dispo et si l'appelant ne demande pas le brut if not want_raw and "_blurred" not in screenshot_path.stem: blurred_candidate = screenshot_path.with_name( f"{screenshot_path.stem}_blurred{screenshot_path.suffix}" ) if blurred_candidate.is_file(): screenshot_path = blurred_candidate return send_file(screenshot_path, mimetype='image/png') except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/agent/sessions//process', methods=['POST']) def process_session(session_id): """Lance le traitement d'une session.""" try: session_dir = SESSIONS_PATH / session_id if not session_dir.exists(): return jsonify({'error': 'Session non trouvée'}), 404 # Marquer comme "en cours" set_session_processing_status(session_id, "processing") from server.processing_pipeline import process_session_async def process_in_background(): try: stats = process_session_async(session_id, str(DATA_PATH)) # Marquer comme "terminé" avec les stats set_session_processing_status(session_id, "completed", stats=stats) socketio.emit('processing_complete', { 'session_id': session_id, 'stats': stats }) except Exception as e: # Marquer comme "échoué" set_session_processing_status(session_id, "failed", error=e) socketio.emit('processing_error', { 'session_id': session_id, 'error': str(e) }) thread = threading.Thread(target=process_in_background, daemon=True) thread.start() return jsonify({'status': 'started', 'session_id': session_id}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/agent/sessions//rename', methods=['PATCH', 'POST']) def rename_session_workflow(session_id): """Renomme le workflow (training_label) d'une session.""" try: data = request.get_json() if not data or 'name' not in data: return jsonify({'error': 'Le champ "name" est requis'}), 400 new_name = data['name'].strip() if not new_name: return jsonify({'error': 'Le nom ne peut pas être vide'}), 400 # Sanitizer le nom import re new_name = re.sub(r'[<>:"/\\|?*]', '_', new_name) new_name = re.sub(r'_+', '_', new_name).strip('_') if len(new_name) > 60: new_name = new_name[:57] + "..." # Trouver le fichier JSON de la session session_file = None for dir_path in SESSIONS_PATH.iterdir(): if not dir_path.is_dir(): continue json_files = list(dir_path.glob('*.json')) + list(dir_path.glob('*/*.json')) for json_path in json_files: try: session = RawSession.load_from_file(json_path) if session.session_id == session_id: session_file = json_path break except Exception: continue if session_file: break if not session_file: return jsonify({'error': 'Session non trouvée'}), 404 # Charger et mettre à jour la session session = RawSession.load_from_file(session_file) old_name = session.context.get('training_label', '') session.context['training_label'] = new_name # Sauvegarder with open(session_file, 'w', encoding='utf-8') as f: json.dump(session.to_json(), f, ensure_ascii=False, indent=2) return jsonify({ 'success': True, 'session_id': session_id, 'old_name': old_name, 'new_name': new_name }) except Exception as e: return jsonify({'error': str(e)}), 500 # ============================================================================= # API Workflows # ============================================================================= @app.route('/api/workflows') def list_workflows(): """Liste tous les workflows.""" try: workflows = [] hide_unnamed = request.args.get('hide_unnamed', 'true').lower() == 'true' if not WORKFLOWS_PATH.exists(): WORKFLOWS_PATH.mkdir(parents=True, exist_ok=True) return jsonify({'workflows': [], 'total': 0, 'hidden_unnamed': 0}) for wf_file in WORKFLOWS_PATH.glob('*.json'): try: with open(wf_file, 'r') as f: wf_data = json.load(f) workflows.append({ 'workflow_id': wf_data.get('workflow_id', wf_file.stem), 'name': wf_data.get('name', wf_file.stem), 'description': wf_data.get('description', ''), 'nodes_count': len(wf_data.get('nodes', [])), 'edges_count': len(wf_data.get('edges', [])), 'learning_state': wf_data.get('learning_state', 'OBSERVATION'), 'created_at': wf_data.get('created_at', ''), 'updated_at': wf_data.get('updated_at', ''), 'execution_count': wf_data.get('execution_count', 0), 'file_path': str(wf_file) }) except Exception as e: print(f"Erreur lecture workflow {wf_file}: {e}") # Filtrer les workflows "Unnamed" si demandé if hide_unnamed: all_workflows = workflows workflows = [w for w in workflows if w['name'] != 'Unnamed Workflow'] hidden_unnamed = len(all_workflows) - len(workflows) else: hidden_unnamed = len([w for w in workflows if w['name'] == 'Unnamed Workflow']) return jsonify({ 'workflows': workflows, 'total': len(workflows), 'hidden_unnamed': hidden_unnamed }) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/workflows/cleanup-unnamed', methods=['POST']) def cleanup_unnamed_workflows(): """Supprime les workflows sans nom (Unnamed Workflow).""" try: deleted = [] errors = [] if not WORKFLOWS_PATH.exists(): return jsonify({'deleted': 0, 'errors': []}) for wf_file in WORKFLOWS_PATH.glob('*.json'): try: with open(wf_file, 'r') as f: wf_data = json.load(f) if wf_data.get('name') == 'Unnamed Workflow': wf_file.unlink() deleted.append(wf_file.name) except Exception as e: errors.append({'file': str(wf_file), 'error': str(e)}) return jsonify({ 'success': True, 'deleted_count': len(deleted), 'deleted': deleted, 'errors': errors }) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/workflows/') def get_workflow(workflow_id): """Détails d'un workflow.""" try: wf_file = WORKFLOWS_PATH / f"{workflow_id}.json" if not wf_file.exists(): return jsonify({'error': 'Workflow non trouvé'}), 404 with open(wf_file, 'r') as f: wf_data = json.load(f) return jsonify({'workflow': wf_data}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/workflows//execute', methods=['POST']) def execute_workflow(workflow_id): """Lance l'exécution d'un workflow.""" global execution_state try: data = request.get_json() or {} mode = data.get('mode', 'supervised') wf_file = WORKFLOWS_PATH / f"{workflow_id}.json" if not wf_file.exists(): return jsonify({'error': 'Workflow non trouvé'}), 404 # Mettre à jour l'état execution_state = { "running": True, "workflow_id": workflow_id, "current_node": None, "steps_executed": 0, "last_confidence": 0.0, "mode": mode, "started_at": datetime.now().isoformat(), "history": [] } # Notifier via WebSocket socketio.emit('execution_started', execution_state) # Lancer l'exécution en arrière-plan def run_workflow(): global execution_state try: from core.pipeline.workflow_pipeline import WorkflowPipeline from core.execution.execution_loop import ExecutionLoop, ExecutionMode pipeline = WorkflowPipeline() loop = ExecutionLoop(pipeline) # Callback pour les mises à jour def on_step(step_result): global execution_state execution_state["current_node"] = step_result.node_id execution_state["steps_executed"] += 1 execution_state["last_confidence"] = step_result.match_confidence execution_state["history"].append({ "node_id": step_result.node_id, "success": step_result.success, "confidence": step_result.match_confidence, "timestamp": datetime.now().isoformat() }) socketio.emit('execution_step', execution_state) loop.on_step_complete(on_step) mode_map = { 'observation': ExecutionMode.OBSERVATION, 'coaching': ExecutionMode.COACHING, 'supervised': ExecutionMode.SUPERVISED, 'automatic': ExecutionMode.AUTOMATIC } loop.start(workflow_id, mode=mode_map.get(mode, ExecutionMode.SUPERVISED)) except Exception as e: execution_state["running"] = False execution_state["error"] = str(e) socketio.emit('execution_error', execution_state) thread = threading.Thread(target=run_workflow, daemon=True) thread.start() return jsonify({'status': 'started', 'execution': execution_state}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/workflows//stop', methods=['POST']) def stop_workflow(workflow_id): """Arrête l'exécution d'un workflow.""" global execution_state execution_state["running"] = False socketio.emit('execution_stopped', execution_state) return jsonify({'status': 'stopped'}) # ============================================================================= # API Tests # ============================================================================= @app.route('/api/tests') def list_tests(): """Liste tous les tests disponibles.""" try: tests = [] tests_path = BASE_PATH / "tests" if not tests_path.exists(): return jsonify({'tests': [], 'total': 0}) for test_type in ['unit', 'integration', 'performance']: type_path = tests_path / test_type if type_path.exists(): for test_file in type_path.glob('test_*.py'): tests.append({ 'name': test_file.stem, 'path': str(test_file.relative_to(BASE_PATH)), 'type': test_type }) return jsonify({ 'tests': tests, 'total': len(tests) }) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/tests/run', methods=['POST']) def run_test(): """Lance un test spécifique.""" try: data = request.get_json() test_path = data.get('test_path') if not test_path: return jsonify({'error': 'test_path requis'}), 400 result = subprocess.run( ['pytest', test_path, '-v', '--tb=short'], cwd=BASE_PATH, capture_output=True, text=True, timeout=120 ) return jsonify({ 'success': result.returncode == 0, 'stdout': result.stdout, 'stderr': result.stderr, 'returncode': result.returncode }) except subprocess.TimeoutExpired: return jsonify({'error': 'Timeout (120s)'}), 408 except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/tests/run-all', methods=['POST']) def run_all_tests(): """Lance tous les tests d'un type.""" try: data = request.get_json() test_type = data.get('type', 'unit') tests_path = BASE_PATH / "tests" / test_type if not tests_path.exists(): return jsonify({'error': f'Répertoire {test_type} non trouvé'}), 404 result = subprocess.run( ['pytest', str(tests_path), '-v', '--tb=short'], cwd=BASE_PATH, capture_output=True, text=True, timeout=300 ) return jsonify({ 'success': result.returncode == 0, 'stdout': result.stdout, 'stderr': result.stderr, 'returncode': result.returncode }) except subprocess.TimeoutExpired: return jsonify({'error': 'Timeout (300s)'}), 408 except Exception as e: return jsonify({'error': str(e)}), 500 # ============================================================================= # API Chains # ============================================================================= @app.route('/api/chains') def api_chains(): """Liste toutes les chaînes de workflows.""" try: chains = chain_manager.list_chains() return jsonify({ 'chains': [ { 'chain_id': chain.chain_id, 'name': chain.name, 'workflows': chain.workflows, 'status': chain.status, 'created_at': chain.created_at.isoformat(), 'last_execution': chain.last_execution.isoformat() if chain.last_execution else None, 'success_rate': chain.success_rate } for chain in chains ], 'total': len(chains) }) except Exception as e: api_logger.error(f"Error listing chains: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/chains', methods=['POST']) def create_chain(): """Crée une nouvelle chaîne.""" try: data = request.get_json() name = data.get('name') workflows = data.get('workflows', []) if not name or not workflows: return jsonify({'error': 'name and workflows required'}), 400 chain = chain_manager.create_chain(name, workflows) api_logger.info(f"Created chain {chain.chain_id}") return jsonify({ 'chain': { 'chain_id': chain.chain_id, 'name': chain.name, 'workflows': chain.workflows, 'status': chain.status } }), 201 except ValueError as e: return jsonify({'error': str(e)}), 400 except Exception as e: api_logger.error(f"Error creating chain: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/chains//execute', methods=['POST']) def execute_chain_route(chain_id): """Lance l'exécution d'une chaîne.""" try: def on_progress(workflow_id, current, total): socketio.emit('chain_progress', { 'chain_id': chain_id, 'workflow_id': workflow_id, 'current': current, 'total': total }) result = chain_manager.execute_chain(chain_id, on_progress=on_progress) socketio.emit('chain_complete', { 'chain_id': chain_id, 'success': result.success, 'duration': result.duration }) return jsonify({ 'result': { 'chain_id': result.chain_id, 'success': result.success, 'duration': result.duration, 'workflows_executed': result.workflows_executed, 'failed_at': result.failed_at, 'error_message': result.error_message } }) except ValueError as e: return jsonify({'error': str(e)}), 404 except Exception as e: api_logger.error(f"Error executing chain {chain_id}: {e}") return jsonify({'error': str(e)}), 500 # ============================================================================= # API Triggers # ============================================================================= @app.route('/api/triggers') def api_triggers(): """Liste tous les triggers.""" try: triggers = trigger_manager.list_triggers() return jsonify({ 'triggers': [ { 'trigger_id': trigger.trigger_id, 'trigger_type': trigger.trigger_type, 'workflow_id': trigger.workflow_id, 'config': trigger.config, 'enabled': trigger.enabled, 'created_at': trigger.created_at.isoformat(), 'last_fired': trigger.last_fired.isoformat() if trigger.last_fired else None, 'fire_count': trigger.fire_count } for trigger in triggers ], 'total': len(triggers) }) except Exception as e: api_logger.error(f"Error listing triggers: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/triggers', methods=['POST']) def create_trigger(): """Crée un nouveau trigger.""" try: data = request.get_json() trigger_type = data.get('trigger_type') workflow_id = data.get('workflow_id') config = data.get('config', {}) if not trigger_type or not workflow_id: return jsonify({'error': 'trigger_type and workflow_id required'}), 400 trigger = trigger_manager.create_trigger(trigger_type, workflow_id, config) api_logger.info(f"Created trigger {trigger.trigger_id}") return jsonify({ 'trigger': { 'trigger_id': trigger.trigger_id, 'trigger_type': trigger.trigger_type, 'workflow_id': trigger.workflow_id, 'config': trigger.config, 'enabled': trigger.enabled } }), 201 except ValueError as e: return jsonify({'error': str(e)}), 400 except Exception as e: api_logger.error(f"Error creating trigger: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/triggers//toggle', methods=['POST']) def toggle_trigger(trigger_id): """Active/désactive un trigger.""" try: trigger = trigger_manager.get_trigger(trigger_id) if not trigger: return jsonify({'error': 'Trigger not found'}), 404 if trigger.enabled: trigger_manager.disable_trigger(trigger_id) api_logger.info(f"Disabled trigger {trigger_id}") else: trigger_manager.enable_trigger(trigger_id) api_logger.info(f"Enabled trigger {trigger_id}") trigger = trigger_manager.get_trigger(trigger_id) return jsonify({ 'trigger': { 'trigger_id': trigger.trigger_id, 'enabled': trigger.enabled } }) except Exception as e: api_logger.error(f"Error toggling trigger {trigger_id}: {e}") return jsonify({'error': str(e)}), 500 # ============================================================================= # API Logs # ============================================================================= @app.route('/api/logs') def get_logs(): """Récupère les logs récents.""" try: logs = [] if not LOGS_PATH.exists(): return jsonify({'logs': [], 'total': 0}) for log_file in LOGS_PATH.glob('*.log'): try: with open(log_file, 'r') as f: lines = f.readlines() for line in lines[-100:]: logs.append({ 'file': log_file.name, 'message': line.strip() }) except Exception as e: print(f"Erreur lecture log {log_file}: {e}") return jsonify({ 'logs': logs[-200:], 'total': len(logs) }) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/logs/download') def download_logs(): """Télécharge les logs en format ZIP.""" try: # Récupérer les paramètres de date optionnels start_time = request.args.get('start_time') end_time = request.args.get('end_time') start_dt = datetime.fromisoformat(start_time) if start_time else None end_dt = datetime.fromisoformat(end_time) if end_time else None # Générer le ZIP zip_file = log_exporter.export_to_zip(start_dt, end_dt) api_logger.info(f"Logs downloaded (start={start_time}, end={end_time})") return send_file( zip_file, mimetype='application/zip', as_attachment=True, download_name=f'rpa_logs_{datetime.now().strftime("%Y%m%d_%H%M%S")}.zip' ) except Exception as e: api_logger.error(f"Error downloading logs: {e}") return jsonify({'error': str(e)}), 500 # ============================================================================= # API Automation # ============================================================================= @app.route('/api/automation/status') def automation_status(): """Statut du scheduler d'automatisation.""" try: status = automation_scheduler.get_status() return jsonify(status) except Exception as e: api_logger.error(f"Error getting automation status: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/automation/start', methods=['POST']) def automation_start(): """Démarre le scheduler.""" try: automation_scheduler.start() return jsonify({'status': 'started'}) except Exception as e: api_logger.error(f"Error starting automation: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/automation/stop', methods=['POST']) def automation_stop(): """Arrête le scheduler.""" try: automation_scheduler.stop() return jsonify({'status': 'stopped'}) except Exception as e: api_logger.error(f"Error stopping automation: {e}") return jsonify({'error': str(e)}), 500 # ============================================================================= # API Backup & Export - Sauvegardes client # ============================================================================= @app.route('/api/backup/stats') def backup_stats(): """Statistiques des données disponibles pour backup.""" try: exporter = get_backup_exporter() stats = exporter.get_backup_stats() return jsonify({ 'stats': stats, 'timestamp': datetime.now().isoformat() }) except Exception as e: api_logger.error(f"Error getting backup stats: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/backup/full', methods=['POST']) def backup_full(): """Génère et télécharge un backup complet.""" try: data = request.get_json() or {} include_models = data.get('include_models', False) exporter = get_backup_exporter() zip_path = exporter.export_full_backup(include_models=include_models) api_logger.info(f"Full backup created (include_models={include_models})") return send_file( zip_path, mimetype='application/zip', as_attachment=True, download_name=f'rpa_vision_backup_{datetime.now().strftime("%Y%m%d_%H%M%S")}.zip' ) except Exception as e: api_logger.error(f"Error creating full backup: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/backup/workflows') def backup_workflows(): """Génère et télécharge un backup des workflows uniquement.""" try: exporter = get_backup_exporter() zip_path = exporter.export_workflows() api_logger.info("Workflows backup created") return send_file( zip_path, mimetype='application/zip', as_attachment=True, download_name=f'rpa_workflows_{datetime.now().strftime("%Y%m%d_%H%M%S")}.zip' ) except Exception as e: api_logger.error(f"Error creating workflows backup: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/backup/correction-packs') def backup_correction_packs(): """Génère et télécharge un backup des correction packs.""" try: exporter = get_backup_exporter() zip_path = exporter.export_correction_packs() api_logger.info("Correction packs backup created") return send_file( zip_path, mimetype='application/zip', as_attachment=True, download_name=f'rpa_correction_packs_{datetime.now().strftime("%Y%m%d_%H%M%S")}.zip' ) except Exception as e: api_logger.error(f"Error creating correction packs backup: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/backup/trained-models') def backup_trained_models(): """ Génère et télécharge les modèles entraînés (opt-in). Ces modèles sont anonymisés et contiennent uniquement les patterns appris, pas les données brutes ou les screenshots. """ try: exporter = get_backup_exporter() zip_path = exporter.export_trained_models() api_logger.info("Trained models backup created (opt-in)") return send_file( zip_path, mimetype='application/zip', as_attachment=True, download_name=f'rpa_trained_models_{datetime.now().strftime("%Y%m%d_%H%M%S")}.zip' ) except Exception as e: api_logger.error(f"Error creating trained models backup: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/backup/config') def backup_config(): """Génère et télécharge la configuration (sanitisée).""" try: exporter = get_backup_exporter() zip_path = exporter.export_config() api_logger.info("Config backup created (sanitized)") return send_file( zip_path, mimetype='application/zip', as_attachment=True, download_name=f'rpa_config_{datetime.now().strftime("%Y%m%d_%H%M%S")}.zip' ) except Exception as e: api_logger.error(f"Error creating config backup: {e}") return jsonify({'error': str(e)}), 500 # ============================================================================= # API Version & Updates - Gestion des versions # ============================================================================= @app.route('/api/version') def get_version(): """Retourne la version actuelle du système.""" try: vm = get_version_manager() version_info = vm.get_current_version() return jsonify({ 'version': version_info.to_dict(), 'timestamp': datetime.now().isoformat() }) except Exception as e: api_logger.error(f"Error getting version: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/version/system-info') def get_system_info(): """Retourne les informations système complètes.""" try: vm = get_version_manager() system_info = vm.get_system_info() return jsonify({ 'system_info': system_info, 'timestamp': datetime.now().isoformat() }) except Exception as e: api_logger.error(f"Error getting system info: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/version/check-update') def check_update(): """Vérifie si une mise à jour est disponible.""" try: vm = get_version_manager() update = vm.check_for_updates() if update: return jsonify({ 'update_available': True, 'update': update.to_dict(), 'timestamp': datetime.now().isoformat() }) else: return jsonify({ 'update_available': False, 'message': 'Système à jour', 'timestamp': datetime.now().isoformat() }) except Exception as e: api_logger.error(f"Error checking update: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/version/backups') def list_version_backups(): """Liste les backups de version disponibles pour rollback.""" try: vm = get_version_manager() backups = vm.list_backups() return jsonify({ 'backups': backups, 'total': len(backups), 'timestamp': datetime.now().isoformat() }) except Exception as e: api_logger.error(f"Error listing version backups: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/version/create-backup', methods=['POST']) def create_version_backup(): """Crée un backup de la version actuelle (point de restauration).""" try: data = request.get_json() or {} label = data.get('label') vm = get_version_manager() backup_path = vm.create_backup(label=label) api_logger.info(f"Version backup created: {backup_path}") return jsonify({ 'success': True, 'backup_path': str(backup_path), 'label': label, 'timestamp': datetime.now().isoformat() }) except Exception as e: api_logger.error(f"Error creating version backup: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/version/upload-update', methods=['POST']) def upload_update_package(): """ Upload d'un package de mise à jour. Le package doit être un fichier ZIP contenant: - update_manifest.json - Les fichiers de mise à jour """ try: if 'file' not in request.files: return jsonify({'error': 'Aucun fichier fourni'}), 400 file = request.files['file'] if file.filename == '': return jsonify({'error': 'Nom de fichier vide'}), 400 if not file.filename.endswith('.zip'): return jsonify({'error': 'Le fichier doit être un ZIP'}), 400 # Sauvegarder dans le répertoire updates vm = get_version_manager() update_path = vm.updates_dir / file.filename file.save(update_path) # Extraire et valider le manifest import zipfile with zipfile.ZipFile(update_path, 'r') as zf: if 'update_manifest.json' not in zf.namelist(): update_path.unlink() return jsonify({'error': 'Package invalide: manifest manquant'}), 400 # Extraire le manifest zf.extract('update_manifest.json', vm.updates_dir) with open(vm.updates_dir / 'update_manifest.json', 'r') as f: manifest = json.load(f) api_logger.info(f"Update package uploaded: {file.filename} (version {manifest.get('version')})") return jsonify({ 'success': True, 'filename': file.filename, 'manifest': manifest, 'message': 'Package uploadé. Utilisez /api/version/check-update pour vérifier.', 'timestamp': datetime.now().isoformat() }) except Exception as e: api_logger.error(f"Error uploading update package: {e}") return jsonify({'error': str(e)}), 500 # ============================================================================= # API Metrics (Prometheus) # ============================================================================= @app.route('/metrics') def prometheus_metrics(): """Endpoint Prometheus pour les métriques.""" try: return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST) except Exception as e: api_logger.error(f"Error generating metrics: {e}") return Response(f"# Error: {e}\n", mimetype='text/plain'), 500 # ============================================================================= # WebSocket Events # ============================================================================= @socketio.on('connect') def handle_connect(): """Client connecté.""" print(f"Client connecté: {request.sid}") emit('connected', {'status': 'ok', 'execution': execution_state}) @socketio.on('disconnect') def handle_disconnect(): """Client déconnecté.""" print(f"Client déconnecté: {request.sid}") @socketio.on('subscribe_execution') def handle_subscribe(): """S'abonner aux mises à jour d'exécution.""" emit('execution_state', execution_state) @socketio.on('get_performance') def handle_get_performance(): """Demande les métriques de performance.""" emit('performance_update', performance_metrics) # ============================================================================= # Background tasks # ============================================================================= def broadcast_metrics(): """Diffuse les métriques périodiquement.""" while True: time.sleep(5) try: socketio.emit('metrics_update', { 'execution': execution_state, 'performance': performance_metrics, 'timestamp': datetime.now().isoformat() }) except Exception: pass # Démarrer le thread de broadcast metrics_thread = threading.Thread(target=broadcast_metrics, daemon=True) metrics_thread.start() # ============================================================================= # API Service Manager - Panneau de contrôle pour démos # ============================================================================= # Configuration des services RPA Vision V3 SERVICES_CONFIG = { "api_server": { "name": "API Server", "description": "API principale RPA Vision V3 (upload, sessions)", "port": 8000, "start_cmd": "cd {base} && {base}/.venv/bin/python server/api_upload.py", "url": "http://localhost:8000", "icon": "🚀" }, "monitoring": { "name": "Monitoring", "description": "Métriques et surveillance système", "port": 5003, "start_cmd": "cd {base} && {base}/.venv/bin/python monitoring_server.py", "url": "http://localhost:5003", "icon": "📈" }, "vwb_backend": { "name": "VWB Backend", "description": "API Visual Workflow Builder", "port": 5002, "start_cmd": "cd {base}/visual_workflow_builder/backend && {base}/.venv/bin/python app.py", "url": "http://localhost:5002", "icon": "⚙️" }, "vwb_frontend": { "name": "VWB Frontend", "description": "Interface React du Workflow Builder (V4)", "port": 3002, "start_cmd": "cd {base}/visual_workflow_builder/frontend_v4 && npm run dev -- --port 3002 --host 0.0.0.0", "url": "http://localhost:3002", "icon": "🎨" }, "agent_chat": { "name": "Agent Chat (LLM)", "description": "Interface conversationnelle avec Ollama", "port": 5004, "start_cmd": "cd {base} && ./.venv/bin/python -m agent_chat.app", "url": "http://localhost:5004", "icon": "🤖" }, "streaming": { "name": "Streaming Server", "description": "Serveur de capture et streaming temps réel", "port": 5005, "start_cmd": "cd {base} && ./.venv/bin/python -m agent_v0.server_v1.api_stream", "url": "http://localhost:5005", "icon": "📡" }, "session_cleaner": { "name": "Session Cleaner", "description": "Nettoyage de sessions avant replay (dépend du Streaming Server)", "port": 5006, "start_cmd": "cd {base} && {base}/.venv/bin/python3 tools/session_cleaner.py", "url": "http://localhost:5006", "icon": "🧹" }, "web_dashboard": { "name": "Dashboard (ce service)", "description": "Panneau de contrôle RPA Vision V3", "port": 5001, "start_cmd": None, # Déjà en cours "url": "http://localhost:5001", "icon": "📊" } } # Processus en cours (PID tracking) _running_processes = {} def _check_port(port: int) -> bool: """Vérifie si un port est en écoute.""" import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.settimeout(1) try: s.connect(('127.0.0.1', port)) return True except (socket.timeout, ConnectionRefusedError): return False def _get_service_status(service_id: str) -> dict: """Retourne le statut détaillé d'un service.""" config = SERVICES_CONFIG.get(service_id) if not config: return {"error": "Service inconnu"} port = config["port"] is_running = _check_port(port) pid = _running_processes.get(service_id) return { "service_id": service_id, "name": config["name"], "description": config["description"], "icon": config["icon"], "port": port, "url": config["url"], "status": "running" if is_running else "stopped", "pid": pid, "can_start": config["start_cmd"] is not None, "can_stop": service_id != "web_dashboard" } @app.route('/api/services') def api_services(): """Liste tous les services avec leur statut.""" services = [] for service_id in SERVICES_CONFIG: services.append(_get_service_status(service_id)) return jsonify({ "services": services, "timestamp": datetime.now().isoformat() }) @app.route('/api/services//status') def api_service_status(service_id): """Statut d'un service spécifique.""" status = _get_service_status(service_id) if "error" in status: return jsonify(status), 404 return jsonify(status) @app.route('/api/services//start', methods=['POST']) def api_service_start(service_id): """Démarre un service.""" config = SERVICES_CONFIG.get(service_id) if not config: return jsonify({"error": "Service inconnu"}), 404 if not config["start_cmd"]: return jsonify({"error": "Ce service ne peut pas être démarré via l'API"}), 400 # Vérifier si déjà en cours if _check_port(config["port"]): return jsonify({"status": "already_running", "message": f"{config['name']} est déjà en cours"}) try: # Préparer la commande cmd = config["start_cmd"].format(base=str(BASE_PATH)) # Lancer en arrière-plan process = subprocess.Popen( cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True ) _running_processes[service_id] = process.pid # Attendre un peu que le service démarre time.sleep(2) # Vérifier si le service a démarré is_running = _check_port(config["port"]) api_logger.info(f"Service {service_id} started (PID: {process.pid}, running: {is_running})") return jsonify({ "status": "started" if is_running else "starting", "pid": process.pid, "message": f"{config['name']} démarré" if is_running else "Démarrage en cours...", "url": config["url"] }) except Exception as e: api_logger.error(f"Error starting service {service_id}: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/services//stop', methods=['POST']) def api_service_stop(service_id): """Arrête un service.""" config = SERVICES_CONFIG.get(service_id) if not config: return jsonify({"error": "Service inconnu"}), 404 if service_id == "web_dashboard": return jsonify({"error": "Impossible d'arrêter le dashboard depuis lui-même"}), 400 try: port = config["port"] # Trouver et tuer le processus sur ce port result = subprocess.run( f"lsof -ti :{port} | xargs -r kill -TERM", shell=True, capture_output=True, text=True ) # Nettoyer le tracking if service_id in _running_processes: del _running_processes[service_id] # Attendre que le port se libère time.sleep(1) is_stopped = not _check_port(port) api_logger.info(f"Service {service_id} stopped (success: {is_stopped})") return jsonify({ "status": "stopped" if is_stopped else "stopping", "message": f"{config['name']} arrêté" if is_stopped else "Arrêt en cours..." }) except Exception as e: api_logger.error(f"Error stopping service {service_id}: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/services//restart', methods=['POST']) def api_service_restart(service_id): """Redémarre un service.""" # Arrêter stop_response = api_service_stop(service_id) if stop_response[1] if isinstance(stop_response, tuple) else 200 >= 400: return stop_response # Attendre time.sleep(2) # Redémarrer return api_service_start(service_id) @app.route('/api/services/start-all', methods=['POST']) def api_services_start_all(): """Démarre tous les services.""" results = {} for service_id, config in SERVICES_CONFIG.items(): if config["start_cmd"]: try: if not _check_port(config["port"]): cmd = config["start_cmd"].format(base=str(BASE_PATH)) process = subprocess.Popen( cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True ) _running_processes[service_id] = process.pid results[service_id] = {"status": "starting", "pid": process.pid} else: results[service_id] = {"status": "already_running"} except Exception as e: results[service_id] = {"status": "error", "error": str(e)} # Attendre que les services démarrent time.sleep(3) # Vérifier les statuts for service_id in results: if results[service_id].get("status") == "starting": port = SERVICES_CONFIG[service_id]["port"] results[service_id]["status"] = "running" if _check_port(port) else "failed" return jsonify({"results": results, "timestamp": datetime.now().isoformat()}) @app.route('/api/services/stop-all', methods=['POST']) def api_services_stop_all(): """Arrête tous les services (sauf le dashboard).""" results = {} for service_id, config in SERVICES_CONFIG.items(): if service_id != "web_dashboard" and config["start_cmd"]: try: port = config["port"] subprocess.run(f"lsof -ti :{port} | xargs -r kill -TERM", shell=True) results[service_id] = {"status": "stopped"} except Exception as e: results[service_id] = {"status": "error", "error": str(e)} _running_processes.clear() return jsonify({"results": results, "timestamp": datetime.now().isoformat()}) # ============================================================================= # API Configuration - Interface de configuration centralisee # ============================================================================= CONFIG_FILE_PATH = BASE_PATH / "data" / "config" / "system_config.json" def load_system_config(): """Charge la configuration systeme depuis le fichier JSON.""" try: if CONFIG_FILE_PATH.exists(): with open(CONFIG_FILE_PATH, 'r', encoding='utf-8') as f: return json.load(f) except Exception as e: api_logger.error(f"Erreur chargement config: {e}") # Config par defaut return { "version": "1.0.0", "services": {}, "llm": {"provider": "ollama", "base_url": "http://localhost:11434", "model": "qwen2.5:7b"}, "vlm": {"provider": "ollama", "base_url": "http://localhost:11434", "model": "gemma4:e4b"}, "detection": {"owl_model": "google/owlv2-base-patch16-ensemble", "confidence_threshold": 0.3}, "database": {"type": "sqlite", "path": "data/training/workflows.db"}, "security": {"enable_encryption": True, "require_authentication": False} } def save_system_config(config): """Sauvegarde la configuration systeme dans le fichier JSON.""" try: CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True) with open(CONFIG_FILE_PATH, 'w', encoding='utf-8') as f: json.dump(config, f, indent=2, ensure_ascii=False) return True except Exception as e: api_logger.error(f"Erreur sauvegarde config: {e}") return False @app.route('/api/config') def get_config(): """Retourne la configuration complete du systeme.""" try: config = load_system_config() return jsonify({ "success": True, "config": config, "timestamp": datetime.now().isoformat() }) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/config', methods=['POST']) def update_config(): """Met a jour la configuration complete du systeme.""" try: new_config = request.get_json() if not new_config: return jsonify({"success": False, "error": "Configuration vide"}), 400 # Garder la version current = load_system_config() new_config['version'] = current.get('version', '1.0.0') if save_system_config(new_config): api_logger.info("Configuration systeme mise a jour") return jsonify({ "success": True, "message": "Configuration sauvegardee", "timestamp": datetime.now().isoformat() }) else: return jsonify({"success": False, "error": "Erreur lors de la sauvegarde"}), 500 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/config/
') def get_config_section(section): """Retourne une section specifique de la configuration.""" try: config = load_system_config() if section not in config: return jsonify({"success": False, "error": f"Section '{section}' introuvable"}), 404 return jsonify({ "success": True, "section": section, "data": config[section], "timestamp": datetime.now().isoformat() }) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/config/
', methods=['PUT']) def update_config_section(section): """Met a jour une section specifique de la configuration.""" try: section_data = request.get_json() if not section_data: return jsonify({"success": False, "error": "Donnees vides"}), 400 config = load_system_config() config[section] = section_data if save_system_config(config): api_logger.info(f"Section config '{section}' mise a jour") return jsonify({ "success": True, "message": f"Section '{section}' sauvegardee", "timestamp": datetime.now().isoformat() }) else: return jsonify({"success": False, "error": "Erreur lors de la sauvegarde"}), 500 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/config/test-connection', methods=['POST']) def test_connection(): """Teste la connexion a un service (LLM, base de donnees, etc.).""" try: data = request.get_json() service_type = data.get('type') if service_type == 'ollama': # Test connexion Ollama import urllib.request url = data.get('base_url', 'http://localhost:11434') + '/api/tags' try: with urllib.request.urlopen(url, timeout=5) as response: models = json.loads(response.read().decode()) return jsonify({ "success": True, "message": "Connexion Ollama OK", "models": [m.get('name') for m in models.get('models', [])] }) except Exception as e: return jsonify({"success": False, "error": f"Connexion Ollama echouee: {e}"}) elif service_type == 'database': # Test connexion base de donnees db_path = data.get('path') or 'data/training/workflows.db' full_path = BASE_PATH / db_path if full_path.exists(): return jsonify({"success": True, "message": f"Base de donnees accessible: {db_path}"}) else: return jsonify({"success": False, "error": f"Fichier introuvable: {full_path}"}) elif service_type == 'service': # Test connexion a un service HTTP port = data.get('port') host = data.get('host', 'localhost') import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.settimeout(2) try: s.connect((host, int(port))) return jsonify({"success": True, "message": f"Service {host}:{port} accessible"}) except: return jsonify({"success": False, "error": f"Service {host}:{port} inaccessible"}) return jsonify({"success": False, "error": "Type de test inconnu"}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/config/ollama-models') def get_ollama_models(): """Recupere la liste des modeles Ollama disponibles.""" try: config = load_system_config() base_url = config.get('llm', {}).get('base_url', 'http://localhost:11434') import urllib.request url = base_url + '/api/tags' with urllib.request.urlopen(url, timeout=5) as response: data = json.loads(response.read().decode()) models = [m.get('name') for m in data.get('models', [])] return jsonify({ "success": True, "models": models, "timestamp": datetime.now().isoformat() }) except Exception as e: return jsonify({"success": False, "error": str(e), "models": []}), 500 @app.route('/api/config/export') def export_config(): """Exporte la configuration en JSON.""" try: config = load_system_config() return Response( json.dumps(config, indent=2, ensure_ascii=False), mimetype='application/json', headers={'Content-Disposition': 'attachment;filename=rpa_config.json'} ) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/config/import', methods=['POST']) def import_config(): """Importe une configuration depuis un fichier JSON.""" try: if 'file' in request.files: file = request.files['file'] config = json.load(file) else: config = request.get_json() if not config: return jsonify({"success": False, "error": "Configuration vide"}), 400 if save_system_config(config): return jsonify({ "success": True, "message": "Configuration importee avec succes", "timestamp": datetime.now().isoformat() }) else: return jsonify({"success": False, "error": "Erreur lors de l'import"}), 500 except json.JSONDecodeError: return jsonify({"success": False, "error": "Format JSON invalide"}), 400 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 # ============================================================================= # Catalogue de gestes primitifs (intégré) # ============================================================================= # Catalogue intégré des gestes primitifs connus par le moteur RPA _BUILTIN_GESTURE_CATALOG = [ # Fenêtres {"name": "Fermer la fenêtre", "shortcut": "Alt+F4", "category": "windows", "icon": "❌", "description": "Ferme la fenêtre active ou l'application en cours."}, {"name": "Agrandir la fenêtre", "shortcut": "Win+Up", "category": "windows", "icon": "⬆️", "description": "Agrandit la fenêtre active en plein écran."}, {"name": "Réduire la fenêtre", "shortcut": "Win+Down", "category": "windows", "icon": "⬇️", "description": "Réduit ou restaure la fenêtre active."}, {"name": "Fenêtre à gauche", "shortcut": "Win+Left", "category": "windows", "icon": "⬅️", "description": "Ancre la fenêtre active sur la moitié gauche de l'écran."}, {"name": "Fenêtre à droite", "shortcut": "Win+Right", "category": "windows", "icon": "➡️", "description": "Ancre la fenêtre active sur la moitié droite de l'écran."}, {"name": "Changer de fenêtre", "shortcut": "Alt+Tab", "category": "windows", "icon": "🔄", "description": "Bascule entre les fenêtres ouvertes."}, {"name": "Bureau", "shortcut": "Win+D", "category": "windows", "icon": "🖥️", "description": "Affiche le bureau (réduit toutes les fenêtres)."}, # Navigation Chrome {"name": "Nouvel onglet", "shortcut": "Ctrl+T", "category": "chrome", "icon": "➕", "description": "Ouvre un nouvel onglet dans le navigateur."}, {"name": "Fermer l'onglet", "shortcut": "Ctrl+W", "category": "chrome", "icon": "✖️", "description": "Ferme l'onglet actif du navigateur."}, {"name": "Onglet précédent", "shortcut": "Ctrl+Shift+Tab", "category": "chrome", "icon": "◀️", "description": "Passe à l'onglet précédent."}, {"name": "Onglet suivant", "shortcut": "Ctrl+Tab", "category": "chrome", "icon": "▶️", "description": "Passe à l'onglet suivant."}, {"name": "Rouvrir onglet fermé", "shortcut": "Ctrl+Shift+T", "category": "chrome", "icon": "♻️", "description": "Rouvre le dernier onglet fermé."}, {"name": "Barre d'adresse", "shortcut": "Ctrl+L", "category": "chrome", "icon": "🔗", "description": "Sélectionne la barre d'adresse."}, {"name": "Rechercher dans la page", "shortcut": "Ctrl+F", "category": "chrome", "icon": "🔍", "description": "Ouvre la barre de recherche dans la page."}, {"name": "Actualiser la page", "shortcut": "F5", "category": "chrome", "icon": "🔄", "description": "Recharge la page courante."}, {"name": "Page précédente", "shortcut": "Alt+Left", "category": "chrome", "icon": "⏪", "description": "Retourne à la page précédente dans l'historique."}, {"name": "Page suivante", "shortcut": "Alt+Right", "category": "chrome", "icon": "⏩", "description": "Avance à la page suivante dans l'historique."}, # Édition {"name": "Copier", "shortcut": "Ctrl+C", "category": "edition", "icon": "📋", "description": "Copie la sélection dans le presse-papiers."}, {"name": "Coller", "shortcut": "Ctrl+V", "category": "edition", "icon": "📌", "description": "Colle le contenu du presse-papiers."}, {"name": "Couper", "shortcut": "Ctrl+X", "category": "edition", "icon": "✂️", "description": "Coupe la sélection et la place dans le presse-papiers."}, {"name": "Annuler", "shortcut": "Ctrl+Z", "category": "edition", "icon": "↩️", "description": "Annule la dernière action."}, {"name": "Rétablir", "shortcut": "Ctrl+Y", "category": "edition", "icon": "↪️", "description": "Rétablit la dernière action annulée."}, {"name": "Tout sélectionner", "shortcut": "Ctrl+A", "category": "edition", "icon": "📝", "description": "Sélectionne tout le contenu."}, {"name": "Enregistrer", "shortcut": "Ctrl+S", "category": "edition", "icon": "💾", "description": "Enregistre le document en cours."}, {"name": "Imprimer", "shortcut": "Ctrl+P", "category": "edition", "icon": "🖨️", "description": "Ouvre la boîte de dialogue d'impression."}, # Système {"name": "Menu Démarrer", "shortcut": "Win", "category": "system", "icon": "🪟", "description": "Ouvre le menu Démarrer de Windows."}, {"name": "Explorateur de fichiers", "shortcut": "Win+E", "category": "system", "icon": "📁", "description": "Ouvre l'explorateur de fichiers."}, {"name": "Gestionnaire de tâches", "shortcut": "Ctrl+Shift+Esc", "category": "system", "icon": "📊", "description": "Ouvre le gestionnaire de tâches."}, {"name": "Paramètres", "shortcut": "Win+I", "category": "system", "icon": "⚙️", "description": "Ouvre les paramètres Windows."}, {"name": "Verrouiller l'écran", "shortcut": "Win+L", "category": "system", "icon": "🔒", "description": "Verrouille la session utilisateur."}, {"name": "Capture d'écran", "shortcut": "Win+Shift+S", "category": "system", "icon": "📷", "description": "Lance l'outil de capture d'écran."}, {"name": "Exécuter", "shortcut": "Win+R", "category": "system", "icon": "▶️", "description": "Ouvre la boîte de dialogue Exécuter."}, # Actions souris {"name": "Clic gauche", "shortcut": "", "category": "mouse", "icon": "🖱️", "description": "Clic gauche sur un élément de l'interface."}, {"name": "Double clic", "shortcut": "", "category": "mouse", "icon": "🖱️", "description": "Double clic gauche pour ouvrir ou sélectionner."}, {"name": "Clic droit", "shortcut": "", "category": "mouse", "icon": "🖱️", "description": "Clic droit pour ouvrir le menu contextuel."}, {"name": "Glisser-déposer", "shortcut": "", "category": "mouse", "icon": "✋", "description": "Maintenir le clic et déplacer un élément."}, {"name": "Scroll (molette)", "shortcut": "", "category": "mouse", "icon": "🔃", "description": "Défilement vertical avec la molette de la souris."}, ] _GESTURE_CATEGORIES = { "windows": {"id": "windows", "name": "Gestion des fenêtres", "icon": "🪟"}, "chrome": {"id": "chrome", "name": "Navigation Chrome / Web", "icon": "🌐"}, "edition": {"id": "edition", "name": "Édition de texte", "icon": "✏️"}, "system": {"id": "system", "name": "Système", "icon": "⚙️"}, "mouse": {"id": "mouse", "name": "Actions souris", "icon": "🖱️"}, } def _get_gestures(): """Récupère les gestes depuis le module agent_chat ou le catalogue intégré.""" catalog_available = False gestures = [] try: from agent_chat.gesture_catalog import get_all_gestures gestures = get_all_gestures() catalog_available = True except (ImportError, AttributeError): gestures = _BUILTIN_GESTURE_CATALOG catalog_available = False return gestures, catalog_available def _build_gesture_categories(gestures): """Organise les gestes par catégorie pour le template.""" categories_map = {} for g in gestures: cat_id = g.get('category', 'other') if cat_id not in categories_map: cat_info = _GESTURE_CATEGORIES.get(cat_id, { "id": cat_id, "name": cat_id.capitalize(), "icon": "📦" }) categories_map[cat_id] = {**cat_info, "gestures": []} categories_map[cat_id]["gestures"].append(g) # Ordre déterministe order = ["windows", "chrome", "edition", "system", "mouse"] result = [] for cat_id in order: if cat_id in categories_map: result.append(categories_map.pop(cat_id)) # Catégories restantes for cat_id in sorted(categories_map.keys()): result.append(categories_map[cat_id]) return result @app.route('/gestures') def gestures_page(): """Page listant tous les gestes primitifs connus.""" gestures, available = _get_gestures() categories = _build_gesture_categories(gestures) with_shortcut = sum(1 for g in gestures if g.get('shortcut')) stats = { 'total': len(gestures), 'categories': len(categories), 'with_shortcut': with_shortcut, 'source': 'Module' if available else 'Intégré', } return render_template('gestures.html', categories=categories, stats=stats, available=available) @app.route('/api/gestures') def api_gestures(): """API JSON des gestes primitifs.""" gestures, available = _get_gestures() categories = _build_gesture_categories(gestures) return jsonify({ 'gestures': gestures, 'categories': [{'id': c['id'], 'name': c['name'], 'count': len(c['gestures'])} for c in categories], 'total': len(gestures), 'source': 'agent_chat.gesture_catalog' if available else 'builtin', 'available': available, }) # ============================================================================= # Pages Streaming et Extractions # ============================================================================= @app.route('/streaming') def streaming_page(): """Page montrant l'état des sessions de streaming.""" return render_template('streaming.html') @app.route('/api/streaming/status') def api_streaming_status(): """Statut en temps réel du streaming server.""" import urllib.request import urllib.error streaming_url = 'http://localhost:5005/api/v1/traces/stream/stats' try: req = urllib.request.Request(streaming_url, headers={'Accept': 'application/json'}) with urllib.request.urlopen(req, timeout=5) as response: data = json.loads(response.read().decode()) return jsonify(data) except urllib.error.URLError: return jsonify({ 'error': 'Serveur streaming inaccessible', 'active_sessions': 0, 'total_events': 0, 'workflows_built': 0, }), 502 except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/extractions') def extractions_page(): """Page listant les extractions de données.""" extraction_available = False extractions = [] stats = {'total': 0, 'total_records': 0, 'schemas': 0, 'last_extraction': None} try: from core.extraction import get_extractions raw = get_extractions() extractions = raw.get('extractions', []) stats = raw.get('stats', stats) extraction_available = True except (ImportError, AttributeError): extraction_available = False return render_template('extractions.html', extractions=extractions, stats=stats, available=extraction_available) @app.route('/api/extractions') def api_extractions(): """API JSON des extractions de données.""" try: from core.extraction import get_extractions data = get_extractions() return jsonify({**data, 'available': True}) except (ImportError, AttributeError): return jsonify({ 'available': False, 'extractions': [], 'message': 'Module core.extraction non disponible', }) @app.route('/api/extractions//export') def api_extraction_export(extraction_id): """Export d'une extraction au format CSV.""" try: from core.extraction import export_extraction fmt = request.args.get('format', 'csv') result = export_extraction(extraction_id, fmt) if result is None: return jsonify({'error': 'Extraction non trouvée'}), 404 return send_file( result['path'], as_attachment=True, download_name=result.get('filename', f'extraction_{extraction_id}.csv'), ) except (ImportError, AttributeError): return jsonify({ 'error': 'Module core.extraction non disponible', }), 501 except Exception as e: return jsonify({'error': str(e)}), 500 # ============================================================================= # API Streaming - Proxy vers le serveur de streaming (port 5005) # ============================================================================= STREAMING_BASE_URL = 'http://localhost:5005/api/v1/traces/stream' @app.route('/api/streaming/') def proxy_streaming(endpoint): """Proxy vers le serveur de streaming pour éviter les problèmes CORS.""" import urllib.request import urllib.error try: url = f'{STREAMING_BASE_URL}/{endpoint}' req = urllib.request.Request(url, headers={'Accept': 'application/json'}) with urllib.request.urlopen(req, timeout=5) as response: data = json.loads(response.read().decode()) return jsonify(data) except urllib.error.URLError as e: return jsonify({'error': f'Serveur streaming inaccessible: {e}'}), 502 except Exception as e: return jsonify({'error': str(e)}), 500 # ============================================================================= # Chat conversationnel — Léa # ============================================================================= CHAT_BASE_URL = 'http://localhost:5005/api/v1/chat' @app.route('/chat') def chat_page(): """Page de chat conversationnel avec Léa.""" return render_template('chat.html') @app.route('/api/chat/session', methods=['POST']) def proxy_chat_session(): """Proxy : créer une session de chat côté serveur streaming.""" return _proxy_chat( method='POST', path='/session', payload=request.get_json(silent=True) or {}, ) @app.route('/api/chat//message', methods=['POST']) def proxy_chat_message(session_id): """Proxy : envoyer un message dans une session.""" return _proxy_chat( method='POST', path=f'/{session_id}/message', payload=request.get_json(silent=True) or {}, ) @app.route('/api/chat//history', methods=['GET']) def proxy_chat_history(session_id): """Proxy : récupérer l'historique.""" return _proxy_chat(method='GET', path=f'/{session_id}/history') @app.route('/api/chat//confirm', methods=['POST']) def proxy_chat_confirm(session_id): """Proxy : confirmer l'exécution d'un plan.""" return _proxy_chat( method='POST', path=f'/{session_id}/confirm', payload=request.get_json(silent=True) or {}, ) def _proxy_chat(method, path, payload=None): """Helper pour proxyfier les requêtes vers le serveur streaming (:5005).""" import urllib.request import urllib.error url = f'{CHAT_BASE_URL}{path}' headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', } # Token Bearer (lu depuis l'env — même token que le serveur streaming) token = os.environ.get('RPA_API_TOKEN', '') if token: headers['Authorization'] = f'Bearer {token}' try: data_bytes = None if payload is not None and method != 'GET': data_bytes = json.dumps(payload).encode('utf-8') req = urllib.request.Request(url, data=data_bytes, headers=headers, method=method) with urllib.request.urlopen(req, timeout=15) as response: body = response.read().decode('utf-8') try: return jsonify(json.loads(body)) except json.JSONDecodeError: return body, response.status, {'Content-Type': 'application/json'} except urllib.error.HTTPError as e: try: detail = json.loads(e.read().decode('utf-8')) except Exception: detail = {'error': str(e)} return jsonify(detail), e.code except urllib.error.URLError as e: return jsonify({'error': f'Serveur chat inaccessible : {e}'}), 502 except Exception as e: return jsonify({'error': str(e)}), 500 # ============================================================================= # Main # ============================================================================= if __name__ == '__main__': # Valider la sécurité en production (non-bloquant pour le dashboard) from core.security import get_security_config try: from core.security import validate_production_security config = get_security_config() validate_production_security(config) print("✅ Security validation passed") except Exception as e: print(f"⚠️ Security validation warning: {e}") print("Dashboard will start anyway (monitoring service)") # Ne pas arrêter le dashboard pour des problèmes de sécurité mineurs # Initialiser le système de cleanup try: from core.system import initialize_system_cleanup, shutdown_system initialize_system_cleanup() except ImportError: print("⚠️ System cleanup not available") print("=" * 50) print("RPA Vision V3 - Dashboard Web") print("=" * 50) print(f"Base path: {BASE_PATH}") print(f"Data path: {DATA_PATH}") print(f"Sessions path: {SESSIONS_PATH}") print(f"Workflows path: {WORKFLOWS_PATH}") print("") print("Démarrage sur http://0.0.0.0:5001") print("WebSocket activé") print("=" * 50) try: socketio.run( app, host='0.0.0.0', port=5001, debug=False, allow_unsafe_werkzeug=True ) except KeyboardInterrupt: print("\nReceived keyboard interrupt, shutting down...") try: shutdown_system() except: pass except Exception as e: print(f"Dashboard error: {e}") try: shutdown_system() except: pass raise