refactor: factorisation input_handler partagé + page cartographie processus
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 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
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 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
core/execution/input_handler.py (NOUVEAU) : - safe_type_text() : setxkbmap fr + xdotool, partagé entre les 2 executors - check_screen_for_patterns() : détection dialogues UI via OCR - handle_detected_pattern() : clic bouton par OCR (mot exact, le plus bas) - post_execution_cleanup() : vérification post-workflow VWB executor : suppression du code dupliqué, alias vers input_handler Core executor : pyautogui.write() remplacé par safe_type_text() Page dashboard "Cartographie des processus" : - GET /process-mining : vue analyse des flux de travail - POST /api/process-mining/discover : génère BPMN + indicateurs - 4 cartes indicateurs, diagramme, points d'attention, variantes - Dark theme, français, zéro jargon technique - Onglet ajouté dans la navigation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -654,7 +654,8 @@ class ActionExecutor:
|
||||
if PYAUTOGUI_AVAILABLE:
|
||||
pyautogui.click(click_x, click_y)
|
||||
time.sleep(0.2)
|
||||
pyautogui.write(text, interval=0.05)
|
||||
from .input_handler import safe_type_text
|
||||
safe_type_text(text)
|
||||
else:
|
||||
logger.info(f" (Simulated click at {click_x:.0f}, {click_y:.0f})")
|
||||
logger.info(f" (Simulated typing: {text[:50]}...)")
|
||||
|
||||
243
core/execution/input_handler.py
Normal file
243
core/execution/input_handler.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
Module partagé de saisie texte et gestion des dialogues.
|
||||
|
||||
Utilisé par les deux executors :
|
||||
- VWB executor (visual_workflow_builder/backend/api_v3/execute.py)
|
||||
- Core executor (core/execution/action_executor.py)
|
||||
|
||||
Garantit le même comportement AZERTY/VM/Citrix partout.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import shutil
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import pyautogui
|
||||
PYAUTOGUI_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYAUTOGUI_AVAILABLE = False
|
||||
|
||||
|
||||
def safe_type_text(text: str):
|
||||
"""Saisie de texte compatible VM/Citrix et claviers AZERTY/QWERTY.
|
||||
|
||||
Priorité :
|
||||
1. xdotool type avec refresh layout → traverse les VM spice/QEMU
|
||||
2. Presse-papier (xclip) + Ctrl+V → fallback
|
||||
3. pyautogui.write() → dernier recours
|
||||
"""
|
||||
if not text:
|
||||
return
|
||||
|
||||
# Méthode 1 : xdotool type avec refresh du layout clavier
|
||||
if shutil.which('xdotool') and shutil.which('setxkbmap'):
|
||||
try:
|
||||
subprocess.run(['setxkbmap', 'fr'], timeout=2)
|
||||
subprocess.run(
|
||||
['xdotool', 'type', '--delay', '0', '--clearmodifiers', '--', text],
|
||||
timeout=max(30, len(text) * 0.05),
|
||||
check=True
|
||||
)
|
||||
logger.debug(f"Saisie via xdotool type ({len(text)} car.)")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"xdotool type échoué: {e}")
|
||||
|
||||
# Méthode 2 : Presse-papier
|
||||
xclip = shutil.which('xclip')
|
||||
if xclip and PYAUTOGUI_AVAILABLE:
|
||||
try:
|
||||
p = subprocess.Popen(
|
||||
['xclip', '-selection', 'clipboard'],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
p.stdin.write(text.encode('utf-8'))
|
||||
p.stdin.close()
|
||||
time.sleep(0.2)
|
||||
pyautogui.hotkey('ctrl', 'v')
|
||||
time.sleep(0.3)
|
||||
logger.debug(f"Saisie via presse-papier ({len(text)} car.)")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"xclip échoué: {e}")
|
||||
|
||||
# Méthode 3 : pyautogui
|
||||
if PYAUTOGUI_AVAILABLE:
|
||||
logger.warning("Saisie via pyautogui.write() (AZERTY non garanti)")
|
||||
pyautogui.write(text, interval=0.02)
|
||||
else:
|
||||
logger.warning(f"Aucune méthode de saisie disponible pour: {text[:50]}")
|
||||
|
||||
|
||||
def check_screen_for_patterns() -> Optional[Dict[str, Any]]:
|
||||
"""Vérifie si l'écran contient un pattern UI connu (dialogue, popup).
|
||||
|
||||
Capture l'écran, extrait le texte via OCR, et cherche un pattern
|
||||
dans la UIPatternLibrary.
|
||||
|
||||
Returns:
|
||||
Dict avec le pattern trouvé, ou None.
|
||||
"""
|
||||
try:
|
||||
from core.knowledge.ui_patterns import UIPatternLibrary
|
||||
import mss
|
||||
from PIL import Image
|
||||
|
||||
lib = UIPatternLibrary()
|
||||
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[1]
|
||||
screenshot = sct.grab(monitor)
|
||||
screen = Image.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
|
||||
|
||||
try:
|
||||
# Essayer docTR d'abord (peut être importé depuis différents chemins)
|
||||
try:
|
||||
from services.ocr_service import ocr_extract_text
|
||||
except ImportError:
|
||||
from core.extraction.field_extractor import FieldExtractor
|
||||
extractor = FieldExtractor()
|
||||
ocr_extract_text = lambda img: extractor.extract_text_from_image(img)
|
||||
|
||||
ocr_text = ocr_extract_text(screen)
|
||||
except ImportError:
|
||||
logger.debug("OCR non disponible pour pattern check")
|
||||
return None
|
||||
|
||||
if not ocr_text or len(ocr_text) < 5:
|
||||
return None
|
||||
|
||||
pattern = lib.find_pattern(ocr_text)
|
||||
if pattern and pattern['category'] in ('dialog', 'popup'):
|
||||
logger.info(f"Pattern UI détecté: {pattern['pattern']} → {pattern['action']} '{pattern['target']}'")
|
||||
return pattern
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Pattern check échoué: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def handle_detected_pattern(pattern: Dict[str, Any]) -> bool:
|
||||
"""Gère automatiquement un pattern UI détecté.
|
||||
|
||||
Cherche le bouton cible via OCR (position réelle sur l'écran).
|
||||
100% vision — zéro coordonnée hardcodée.
|
||||
|
||||
Returns:
|
||||
True si le pattern a été géré avec succès.
|
||||
"""
|
||||
if not PYAUTOGUI_AVAILABLE:
|
||||
logger.warning("pyautogui non disponible — impossible de gérer le pattern")
|
||||
return False
|
||||
|
||||
action = pattern.get('action')
|
||||
target = pattern.get('target', '')
|
||||
alternatives = pattern.get('alternatives', [])
|
||||
|
||||
if action == 'click':
|
||||
candidates_labels = [target] + alternatives
|
||||
|
||||
try:
|
||||
import mss
|
||||
from PIL import Image
|
||||
|
||||
# Importer OCR (essayer les deux chemins)
|
||||
try:
|
||||
from services.ocr_service import ocr_extract_words
|
||||
except ImportError:
|
||||
from core.extraction.field_extractor import FieldExtractor
|
||||
extractor = FieldExtractor()
|
||||
def ocr_extract_words(img):
|
||||
return extractor.extract_words_from_image(img)
|
||||
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[1]
|
||||
screenshot = sct.grab(monitor)
|
||||
screen = Image.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
|
||||
|
||||
words = ocr_extract_words(screen)
|
||||
|
||||
# Collecter tous les matchs, prendre le plus bas (bouton = bas du dialogue)
|
||||
all_matches = []
|
||||
|
||||
for candidate in candidates_labels:
|
||||
candidate_lower = candidate.lower()
|
||||
for word in words:
|
||||
word_text = word['text'].lower()
|
||||
if len(word_text) < 2 or len(candidate_lower) < 2:
|
||||
continue
|
||||
if word_text == candidate_lower:
|
||||
x1, y1, x2, y2 = word['bbox']
|
||||
all_matches.append({
|
||||
'text': word['text'],
|
||||
'x': int((x1 + x2) / 2),
|
||||
'y': int((y1 + y2) / 2),
|
||||
'match_type': 'exact',
|
||||
})
|
||||
|
||||
# Recherche partielle (lettre soulignée manquante)
|
||||
if not all_matches:
|
||||
for candidate in candidates_labels:
|
||||
if len(candidate) > 3:
|
||||
partial = candidate[1:].lower()
|
||||
for word in words:
|
||||
if partial in word['text'].lower():
|
||||
x1, y1, x2, y2 = word['bbox']
|
||||
all_matches.append({
|
||||
'text': word['text'],
|
||||
'x': int((x1 + x2) / 2),
|
||||
'y': int((y1 + y2) / 2),
|
||||
'match_type': 'partial',
|
||||
})
|
||||
|
||||
if all_matches:
|
||||
best = max(all_matches, key=lambda m: m['y'])
|
||||
logger.info(f"Clic sur '{best['text']}' à ({best['x']}, {best['y']})")
|
||||
pyautogui.click(best['x'], best['y'])
|
||||
time.sleep(1.0)
|
||||
return True
|
||||
|
||||
logger.info(f"Bouton '{target}' introuvable par OCR")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"OCR bouton échoué: {e}")
|
||||
return False
|
||||
|
||||
elif action == 'hotkey':
|
||||
keys = target.split('+')
|
||||
logger.info(f"Raccourci automatique: {target}")
|
||||
pyautogui.hotkey(*keys)
|
||||
time.sleep(0.5)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def post_execution_cleanup(execution_mode: str = 'debug'):
|
||||
"""Vérifie l'écran après exécution et gère les dialogues restants.
|
||||
|
||||
Appelé après la dernière étape d'un workflow pour laisser l'écran propre.
|
||||
"""
|
||||
if execution_mode not in ('intelligent', 'debug'):
|
||||
return
|
||||
|
||||
logger.info("Vérification écran final...")
|
||||
time.sleep(1.0)
|
||||
for _ in range(3):
|
||||
detected = check_screen_for_patterns()
|
||||
if detected:
|
||||
logger.info(f"Dialogue résiduel détecté: {detected.get('pattern')}")
|
||||
handle_detected_pattern(detected)
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
break
|
||||
@@ -24,107 +24,17 @@ from . import api_v3_bp
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_CHAR_TO_KEYSYM = {
|
||||
' ': 'space', '\t': 'Tab', '\n': 'Return',
|
||||
'!': 'exclam', '"': 'quotedbl', '#': 'numbersign', '$': 'dollar',
|
||||
'%': 'percent', '&': 'ampersand', "'": 'apostrophe',
|
||||
'(': 'parenleft', ')': 'parenright', '*': 'asterisk', '+': 'plus',
|
||||
',': 'comma', '-': 'minus', '.': 'period', '/': 'slash',
|
||||
':': 'colon', ';': 'semicolon', '<': 'less', '=': 'equal',
|
||||
'>': 'greater', '?': 'question', '@': 'at',
|
||||
'[': 'bracketleft', '\\': 'backslash', ']': 'bracketright',
|
||||
'^': 'asciicircum', '_': 'underscore', '`': 'grave',
|
||||
'{': 'braceleft', '|': 'bar', '}': 'braceright', '~': 'asciitilde',
|
||||
}
|
||||
from core.execution.input_handler import (
|
||||
safe_type_text as _shared_safe_type_text,
|
||||
check_screen_for_patterns as _shared_check_patterns,
|
||||
handle_detected_pattern as _shared_handle_pattern,
|
||||
post_execution_cleanup as _shared_post_cleanup,
|
||||
)
|
||||
|
||||
|
||||
def _xdotool_type_by_keysym(text):
|
||||
"""Tape du texte via xdotool — hybride rapide + fiable.
|
||||
|
||||
Les caractères alphanumériques passent par xdotool type (un seul appel,
|
||||
rapide). Les caractères spéciaux (:, /, @, etc.) passent par xdotool key
|
||||
avec les noms de keysym X11 pour éviter les erreurs AZERTY dans les VM.
|
||||
"""
|
||||
segments = []
|
||||
buf = []
|
||||
|
||||
def flush_buf():
|
||||
if buf:
|
||||
segments.append(('type', ''.join(buf)))
|
||||
buf.clear()
|
||||
|
||||
for ch in text:
|
||||
if ch in _CHAR_TO_KEYSYM:
|
||||
flush_buf()
|
||||
segments.append(('key', _CHAR_TO_KEYSYM[ch]))
|
||||
else:
|
||||
buf.append(ch)
|
||||
flush_buf()
|
||||
|
||||
for kind, value in segments:
|
||||
if kind == 'type':
|
||||
subprocess.run(
|
||||
['xdotool', 'type', '--delay', '0', '--clearmodifiers', '--', value],
|
||||
timeout=10, check=True
|
||||
)
|
||||
else:
|
||||
subprocess.run(
|
||||
['xdotool', 'key', '--clearmodifiers', value],
|
||||
timeout=2, check=True
|
||||
)
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
def safe_type_text(text):
|
||||
"""Saisie de texte compatible VM/Citrix et claviers AZERTY/QWERTY.
|
||||
|
||||
Priorité :
|
||||
1. xdotool type avec refresh layout → traverse les VM spice/QEMU
|
||||
2. Presse-papier (xclip) + Ctrl+V → fallback
|
||||
3. pyautogui.write() → dernier recours
|
||||
"""
|
||||
import shutil
|
||||
import pyautogui
|
||||
|
||||
# Méthode 1 : xdotool type avec refresh du layout clavier
|
||||
# setxkbmap fr AVANT xdotool force X11 à recharger le keymap
|
||||
# → xdotool utilise les bons keycodes AZERTY
|
||||
if shutil.which('xdotool') and shutil.which('setxkbmap'):
|
||||
try:
|
||||
subprocess.run(['setxkbmap', 'fr'], timeout=2)
|
||||
subprocess.run(
|
||||
['xdotool', 'type', '--delay', '0', '--clearmodifiers', '--', text],
|
||||
timeout=max(30, len(text) * 0.05),
|
||||
check=True
|
||||
)
|
||||
print(f" ✅ Saisie via xdotool type ({len(text)} car.)")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f" ⚠️ xdotool type échoué: {e}")
|
||||
|
||||
# Méthode 2 : Presse-papier (fonctionne en local, pas toujours en VM)
|
||||
xclip = shutil.which('xclip')
|
||||
if xclip:
|
||||
try:
|
||||
p = subprocess.Popen(
|
||||
['xclip', '-selection', 'clipboard'],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
p.stdin.write(text.encode('utf-8'))
|
||||
p.stdin.close()
|
||||
time.sleep(0.2)
|
||||
pyautogui.hotkey('ctrl', 'v')
|
||||
time.sleep(0.3)
|
||||
print(f" ✅ Saisie via presse-papier ({len(text)} car.)")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f" ⚠️ xclip échoué: {e}")
|
||||
|
||||
# Méthode 3 : pyautogui (dernier recours)
|
||||
print(" ⚠️ Saisie via pyautogui.write()")
|
||||
pyautogui.write(text)
|
||||
safe_type_text = _shared_safe_type_text
|
||||
_check_screen_for_patterns = _shared_check_patterns
|
||||
_handle_detected_pattern = _shared_handle_pattern
|
||||
|
||||
|
||||
def minimize_active_window():
|
||||
@@ -179,151 +89,6 @@ _execution_state = {
|
||||
}
|
||||
|
||||
|
||||
def _check_screen_for_patterns() -> Optional[Dict[str, Any]]:
|
||||
"""Vérifie si l'écran actuel contient un pattern UI connu (dialogue, popup).
|
||||
|
||||
Capture l'écran, extrait le texte via OCR léger, et cherche
|
||||
un pattern dans la UIPatternLibrary.
|
||||
|
||||
Returns:
|
||||
Dict avec le pattern trouvé et l'action à effectuer, ou None.
|
||||
"""
|
||||
try:
|
||||
from core.knowledge.ui_patterns import UIPatternLibrary
|
||||
import mss
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
lib = UIPatternLibrary()
|
||||
# Debug: vérifier les triggers du dialog_save
|
||||
save_patterns = [p for p in lib._patterns if p.name == 'dialog_save']
|
||||
if save_patterns:
|
||||
print(f" 🔎 [Pattern] dialog_save triggers: {save_patterns[0].triggers}")
|
||||
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[1]
|
||||
screenshot = sct.grab(monitor)
|
||||
screen = Image.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
|
||||
|
||||
try:
|
||||
from services.ocr_service import ocr_extract_text
|
||||
ocr_text = ocr_extract_text(screen)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
if not ocr_text or len(ocr_text) < 5:
|
||||
print(f" 🔎 [Pattern] OCR vide ou trop court ({len(ocr_text) if ocr_text else 0} chars)")
|
||||
return None
|
||||
|
||||
print(f" 🔎 [Pattern] OCR ({len(ocr_text)} chars): {ocr_text[:500]}")
|
||||
|
||||
pattern = lib.find_pattern(ocr_text)
|
||||
if pattern:
|
||||
print(f" 🔎 [Pattern] Match: {pattern['pattern']} (category={pattern['category']})")
|
||||
if pattern['category'] in ('dialog', 'popup'):
|
||||
print(f"🧠 [Pattern] DÉTECTÉ: {pattern['pattern']} → {pattern['action']} '{pattern['target']}'")
|
||||
return pattern
|
||||
else:
|
||||
print(f" 🔎 [Pattern] Ignoré (catégorie {pattern['category']})")
|
||||
return None
|
||||
else:
|
||||
print(f" 🔎 [Pattern] Aucun match dans le texte OCR")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f" 🔎 [Pattern] EXCEPTION: {e}")
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def _handle_detected_pattern(pattern: Dict[str, Any]) -> bool:
|
||||
"""Gère automatiquement un pattern UI détecté.
|
||||
|
||||
Cherche le bouton cible via OCR (position réelle sur l'écran),
|
||||
avec fallback sur les coordonnées typiques si l'OCR ne trouve pas.
|
||||
"""
|
||||
import pyautogui
|
||||
|
||||
action = pattern.get('action')
|
||||
target = pattern.get('target', '')
|
||||
alternatives = pattern.get('alternatives', [])
|
||||
|
||||
if action == 'click':
|
||||
candidates = [target] + alternatives
|
||||
|
||||
# Chercher le bouton via OCR sur l'écran actuel
|
||||
try:
|
||||
import mss
|
||||
from PIL import Image
|
||||
from services.ocr_service import ocr_extract_words
|
||||
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[1]
|
||||
screenshot = sct.grab(monitor)
|
||||
screen = Image.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
|
||||
|
||||
words = ocr_extract_words(screen)
|
||||
|
||||
# Collecter TOUS les matchs, puis prendre le plus bas (boutons = bas du dialogue)
|
||||
all_matches = []
|
||||
|
||||
for candidate in candidates:
|
||||
candidate_lower = candidate.lower()
|
||||
for word in words:
|
||||
word_text = word['text'].lower()
|
||||
if len(word_text) < 2 or len(candidate_lower) < 2:
|
||||
continue
|
||||
if word_text == candidate_lower:
|
||||
x1, y1, x2, y2 = word['bbox']
|
||||
all_matches.append({
|
||||
'text': word['text'],
|
||||
'x': int((x1 + x2) / 2),
|
||||
'y': int((y1 + y2) / 2),
|
||||
'match_type': 'exact',
|
||||
})
|
||||
|
||||
# Recherche partielle (ex: "nregistrer" sans le E souligné)
|
||||
if not all_matches:
|
||||
for candidate in candidates:
|
||||
if len(candidate) > 3:
|
||||
partial = candidate[1:].lower()
|
||||
for word in words:
|
||||
if partial in word['text'].lower():
|
||||
x1, y1, x2, y2 = word['bbox']
|
||||
all_matches.append({
|
||||
'text': word['text'],
|
||||
'x': int((x1 + x2) / 2),
|
||||
'y': int((y1 + y2) / 2),
|
||||
'match_type': 'partial',
|
||||
})
|
||||
|
||||
if all_matches:
|
||||
for m in all_matches:
|
||||
print(f" 🔎 [Pattern] Candidat: '{m['text']}' à ({m['x']}, {m['y']}) [{m['match_type']}]")
|
||||
|
||||
best = max(all_matches, key=lambda m: m['y'])
|
||||
print(f"🤖 [Pattern] Clic sur '{best['text']}' à ({best['x']}, {best['y']}) [le plus bas = bouton]")
|
||||
pyautogui.click(best['x'], best['y'])
|
||||
time.sleep(1.0)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" 🔎 [Pattern] OCR bouton échoué: {e}")
|
||||
|
||||
print(f" 🔎 [Pattern] Bouton '{target}' introuvable par OCR — pas de clic")
|
||||
return False
|
||||
|
||||
elif action == 'hotkey':
|
||||
keys = target.split('+')
|
||||
print(f"🤖 [Pattern] Raccourci automatique: {target}")
|
||||
pyautogui.hotkey(*keys)
|
||||
time.sleep(0.5)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def execute_workflow_thread(execution_id: str, workflow_id: str, app):
|
||||
"""
|
||||
Thread d'exécution du workflow.
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
<nav class="header-nav">
|
||||
<a href="/">🎛️ Dashboard</a>
|
||||
<a href="/audit" class="active">⚖️ Audit</a>
|
||||
<a href="/process-mining">🗺️ Cartographie</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
|
||||
471
web_dashboard/templates/process_mining.html
Normal file
471
web_dashboard/templates/process_mining.html
Normal file
@@ -0,0 +1,471 @@
|
||||
<!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 - Cartographie des processus</title>
|
||||
<style>
|
||||
/* === Reset & base — identique au dashboard === */
|
||||
* { 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 === */
|
||||
.header { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); color: white; padding: 20px 30px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
|
||||
.header h1 { font-size: 24px; display: flex; align-items: center; gap: 10px; }
|
||||
.header-subtitle { color: rgba(255,255,255,0.75); font-size: 13px; margin-top: 4px; }
|
||||
.header-nav { display: flex; align-items: center; gap: 8px; }
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.8); text-decoration: none; font-size: 13px;
|
||||
padding: 6px 14px; border-radius: 6px; transition: all 0.2s;
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
.header-nav a:hover { background: rgba(255,255,255,0.2); }
|
||||
.header-nav a.active { background: rgba(255,255,255,0.25); color: #fff; font-weight: 600; }
|
||||
|
||||
/* === Layout === */
|
||||
.container { max-width: 1600px; margin: 0 auto; padding: 20px; }
|
||||
|
||||
/* === Cards === */
|
||||
.card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; margin-bottom: 20px; }
|
||||
.card h2 { font-size: 16px; margin-bottom: 15px; color: #94a3b8; display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* === Grille indicateurs === */
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 20px; }
|
||||
@media (max-width: 900px) { .grid-4 { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 500px) { .grid-4 { grid-template-columns: 1fr; } }
|
||||
.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; }
|
||||
|
||||
/* === Boutons === */
|
||||
.btn { padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-primary:hover { background: #2563eb; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* === Sélecteur === */
|
||||
.selector-bar {
|
||||
display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end;
|
||||
margin-bottom: 20px; padding: 20px; background: #1e293b;
|
||||
border-radius: 12px; border: 1px solid #334155;
|
||||
}
|
||||
.selector-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.selector-group label { font-size: 11px; color: #64748b; text-transform: uppercase; font-weight: 600; }
|
||||
.selector-input {
|
||||
padding: 10px 14px; background: #0f172a; border: 1px solid #334155;
|
||||
border-radius: 8px; color: #e2e8f0; font-size: 14px; min-width: 250px;
|
||||
}
|
||||
.selector-input:focus { border-color: #3b82f6; outline: none; }
|
||||
|
||||
/* === Image cartographie === */
|
||||
.process-image-container {
|
||||
background: #ffffff; border-radius: 12px; padding: 20px;
|
||||
text-align: center; margin-bottom: 20px; overflow-x: auto;
|
||||
}
|
||||
.process-image-container img {
|
||||
max-width: 100%; height: auto; border-radius: 8px;
|
||||
}
|
||||
.image-legend {
|
||||
color: #64748b; font-size: 12px; margin-top: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* === Points d'attention === */
|
||||
.bottleneck-list { list-style: none; padding: 0; }
|
||||
.bottleneck-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 16px; background: #0f172a; border-radius: 8px;
|
||||
margin-bottom: 8px; border-left: 3px solid #f59e0b;
|
||||
}
|
||||
.bottleneck-icon { font-size: 20px; flex-shrink: 0; }
|
||||
.bottleneck-text { font-size: 14px; color: #e2e8f0; }
|
||||
.bottleneck-duration { color: #f59e0b; font-weight: 600; }
|
||||
|
||||
/* === Variantes === */
|
||||
.variant-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 14px; background: #0f172a; border-radius: 8px;
|
||||
margin-bottom: 6px; font-size: 13px;
|
||||
}
|
||||
.variant-path { color: #94a3b8; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 80%; }
|
||||
.variant-count { color: #3b82f6; font-weight: 600; white-space: nowrap; }
|
||||
|
||||
/* === Distribution apps === */
|
||||
.app-dist-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 0; font-size: 13px;
|
||||
}
|
||||
.app-dist-name { min-width: 140px; color: #94a3b8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.app-dist-track { flex: 1; height: 20px; background: #0f172a; border-radius: 4px; overflow: hidden; }
|
||||
.app-dist-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #8b5cf6); border-radius: 4px; transition: width 0.5s ease; }
|
||||
.app-dist-val { min-width: 40px; text-align: right; color: #e2e8f0; font-weight: 500; }
|
||||
|
||||
/* === Loading === */
|
||||
.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 15px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* === Placeholder === */
|
||||
.placeholder-zone {
|
||||
text-align: center; padding: 60px 30px; color: #475569;
|
||||
background: #1e293b; border-radius: 12px; border: 2px dashed #334155;
|
||||
}
|
||||
.placeholder-zone .icon { font-size: 48px; margin-bottom: 15px; display: block; }
|
||||
.placeholder-zone p { font-size: 15px; line-height: 1.6; }
|
||||
|
||||
/* === Erreur === */
|
||||
.error-banner {
|
||||
background: #7f1d1d; border: 1px solid #ef4444; border-radius: 8px;
|
||||
padding: 12px 20px; color: #fca5a5; font-size: 13px; margin-bottom: 15px;
|
||||
display: none; align-items: center; gap: 10px;
|
||||
}
|
||||
.error-banner.visible { display: flex; }
|
||||
|
||||
/* === Grille détails === */
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
@media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||
|
||||
/* === Toggle views === */
|
||||
.view-toggle { display: flex; gap: 8px; margin-bottom: 15px; }
|
||||
.view-toggle .toggle-btn {
|
||||
padding: 8px 16px; background: #0f172a; border: 1px solid #334155;
|
||||
border-radius: 6px; color: #94a3b8; cursor: pointer; font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.view-toggle .toggle-btn:hover { background: #334155; }
|
||||
.view-toggle .toggle-btn.active { background: #3b82f6; color: white; border-color: #3b82f6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>🗺️ Cartographie des processus</h1>
|
||||
<div class="header-subtitle">Analyse automatique des flux de travail observes par Lea</div>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<a href="/">🎛️ Dashboard</a>
|
||||
<a href="/audit">⚖️ Audit</a>
|
||||
<a href="/process-mining" class="active">🗺️ Cartographie</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Banniere erreur -->
|
||||
<div class="error-banner" id="errorBanner">
|
||||
⚠️ <span id="errorText"></span>
|
||||
</div>
|
||||
|
||||
<!-- Selecteur machine + bouton -->
|
||||
<div class="selector-bar">
|
||||
<div class="selector-group">
|
||||
<label>Machine</label>
|
||||
<select class="selector-input" id="machineSelect">
|
||||
<option value="">Toutes les machines</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="analyzeBtn" onclick="launchAnalysis()">
|
||||
🔍 Analyser
|
||||
</button>
|
||||
<span id="analyzeStatus" style="color:#64748b;font-size:13px;align-self:center;"></span>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder (avant analyse) -->
|
||||
<div id="placeholder" class="placeholder-zone">
|
||||
<span class="icon">🔍</span>
|
||||
<p>
|
||||
Selectionnez une machine (ou gardez "Toutes") puis cliquez sur <strong>Analyser</strong>
|
||||
pour generer la cartographie des processus observes par Lea.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="loadingZone" style="display:none;" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Analyse en cours... Cela peut prendre quelques secondes.</p>
|
||||
</div>
|
||||
|
||||
<!-- Resultats (masques au depart) -->
|
||||
<div id="resultsZone" style="display:none;">
|
||||
|
||||
<!-- Indicateurs -->
|
||||
<div class="grid-4" id="kpiGrid">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value" id="kpiSessions">-</div>
|
||||
<div class="stat-label">Sessions analysees</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value" id="kpiActivities">-</div>
|
||||
<div class="stat-label">Activites detectees</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value" id="kpiDuration">-</div>
|
||||
<div class="stat-label">Duree moyenne</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value" id="kpiVariants">-</div>
|
||||
<div class="stat-label">Variantes de parcours</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cartographie visuelle -->
|
||||
<div class="card">
|
||||
<h2>🗺️ Cartographie visuelle</h2>
|
||||
<div class="view-toggle">
|
||||
<button class="toggle-btn active" onclick="switchView('bpmn', this)">Vue structurelle</button>
|
||||
<button class="toggle-btn" onclick="switchView('dfg', this)">Vue flux</button>
|
||||
</div>
|
||||
<div id="bpmnView" class="process-image-container">
|
||||
<img id="bpmnImage" src="" alt="Cartographie des processus" />
|
||||
<p class="image-legend">Chaque rectangle represente une etape du processus observe. Les fleches indiquent l'ordre des actions.</p>
|
||||
</div>
|
||||
<div id="dfgView" class="process-image-container" style="display:none;">
|
||||
<img id="dfgImage" src="" alt="Graphe de flux" />
|
||||
<p class="image-legend">Chaque noeud represente une action. Les chiffres sur les fleches indiquent la frequence de passage.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grille bas : goulots + variantes -->
|
||||
<div class="grid-2">
|
||||
<!-- Points d'attention (goulots) -->
|
||||
<div class="card">
|
||||
<h2>⏱️ Points d'attention</h2>
|
||||
<p style="color:#64748b;font-size:12px;margin-bottom:12px;">Les etapes qui prennent le plus de temps en moyenne</p>
|
||||
<ul class="bottleneck-list" id="bottleneckList">
|
||||
<li class="bottleneck-item" style="color:#475569;">Aucune donnee</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Distribution par application -->
|
||||
<div class="card">
|
||||
<h2>📊 Repartition par application</h2>
|
||||
<p style="color:#64748b;font-size:12px;margin-bottom:12px;">Nombre d'actions par logiciel utilise</p>
|
||||
<div id="appDistribution">
|
||||
<p style="color:#475569;">Aucune donnee</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top variantes -->
|
||||
<div class="card">
|
||||
<h2>🔀 Principaux chemins observes</h2>
|
||||
<p style="color:#64748b;font-size:12px;margin-bottom:12px;">Les enchainements d'actions les plus frequents</p>
|
||||
<div id="variantsList">
|
||||
<p style="color:#475569;">Aucune donnee</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /resultsZone -->
|
||||
|
||||
</div><!-- /container -->
|
||||
|
||||
<script>
|
||||
// ========================================================================
|
||||
// Chargement des machines disponibles
|
||||
// ========================================================================
|
||||
async function loadMachines() {
|
||||
try {
|
||||
const resp = await fetch('/api/process-mining/machines');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const select = document.getElementById('machineSelect');
|
||||
(data.machines || []).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.machine_id;
|
||||
opt.textContent = m.machine_id + ' (' + m.sessions_count + ' sessions)';
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Erreur chargement machines:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Lancement de l'analyse
|
||||
// ========================================================================
|
||||
async function launchAnalysis() {
|
||||
const machineId = document.getElementById('machineSelect').value;
|
||||
const btn = document.getElementById('analyzeBtn');
|
||||
const status = document.getElementById('analyzeStatus');
|
||||
|
||||
// UI : loading
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Analyse en cours...';
|
||||
document.getElementById('placeholder').style.display = 'none';
|
||||
document.getElementById('resultsZone').style.display = 'none';
|
||||
document.getElementById('loadingZone').style.display = 'block';
|
||||
hideError();
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/process-mining/discover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ machine_id: machineId || '' }),
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok || data.error) {
|
||||
showError(data.error || 'Erreur inconnue', data.detail || '');
|
||||
document.getElementById('loadingZone').style.display = 'none';
|
||||
document.getElementById('placeholder').style.display = 'block';
|
||||
btn.disabled = false;
|
||||
status.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Afficher les resultats
|
||||
renderResults(data);
|
||||
document.getElementById('loadingZone').style.display = 'none';
|
||||
document.getElementById('resultsZone').style.display = 'block';
|
||||
status.textContent = 'Analyse terminee';
|
||||
|
||||
} catch (e) {
|
||||
showError('Erreur de communication avec le serveur', e.message);
|
||||
document.getElementById('loadingZone').style.display = 'none';
|
||||
document.getElementById('placeholder').style.display = 'block';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Affichage des resultats
|
||||
// ========================================================================
|
||||
function renderResults(data) {
|
||||
const kpis = data.kpis || {};
|
||||
|
||||
// -- Indicateurs --
|
||||
document.getElementById('kpiSessions').textContent = data.sessions_loaded || 0;
|
||||
document.getElementById('kpiActivities').textContent = kpis.unique_activities || 0;
|
||||
document.getElementById('kpiVariants').textContent = kpis.variants_count || 0;
|
||||
|
||||
// Duree moyenne formatee
|
||||
const avgDur = kpis.avg_case_duration_seconds || 0;
|
||||
document.getElementById('kpiDuration').textContent = formatDuration(avgDur);
|
||||
|
||||
// -- Image BPMN --
|
||||
const bpmnImg = document.getElementById('bpmnImage');
|
||||
if (data.bpmn_image_url) {
|
||||
bpmnImg.src = data.bpmn_image_url + '?t=' + Date.now();
|
||||
bpmnImg.style.display = 'block';
|
||||
} else {
|
||||
bpmnImg.style.display = 'none';
|
||||
}
|
||||
|
||||
// -- Image DFG --
|
||||
const dfgImg = document.getElementById('dfgImage');
|
||||
if (data.dfg_image_url) {
|
||||
dfgImg.src = data.dfg_image_url + '?t=' + Date.now();
|
||||
dfgImg.style.display = 'block';
|
||||
} else {
|
||||
dfgImg.style.display = 'none';
|
||||
}
|
||||
|
||||
// -- Goulots --
|
||||
const bottlenecks = kpis.bottlenecks || [];
|
||||
const bnList = document.getElementById('bottleneckList');
|
||||
if (bottlenecks.length === 0) {
|
||||
bnList.innerHTML = '<li class="bottleneck-item" style="color:#475569;">Aucun goulot detecte</li>';
|
||||
} else {
|
||||
bnList.innerHTML = bottlenecks.map((b, i) => {
|
||||
const icons = ['\u23F1\uFE0F', '\u26A0\uFE0F', '\u{1F4CB}'];
|
||||
return '<li class="bottleneck-item">' +
|
||||
'<span class="bottleneck-icon">' + (icons[i] || '\u{1F4CB}') + '</span>' +
|
||||
'<span class="bottleneck-text">L\'etape <strong>' + escapeHtml(b.activity) +
|
||||
'</strong> prend en moyenne <span class="bottleneck-duration">' +
|
||||
formatDuration(b.avg_duration_seconds) + '</span></span>' +
|
||||
'</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// -- Distribution par application --
|
||||
const appDist = kpis.app_distribution || {};
|
||||
const appDiv = document.getElementById('appDistribution');
|
||||
const appEntries = Object.entries(appDist).sort((a, b) => b[1] - a[1]);
|
||||
if (appEntries.length === 0) {
|
||||
appDiv.innerHTML = '<p style="color:#475569;">Aucune donnee</p>';
|
||||
} else {
|
||||
const maxVal = appEntries[0][1];
|
||||
appDiv.innerHTML = appEntries.slice(0, 8).map(([name, count]) => {
|
||||
const pct = Math.round((count / maxVal) * 100);
|
||||
return '<div class="app-dist-bar">' +
|
||||
'<span class="app-dist-name" title="' + escapeHtml(name) + '">' + escapeHtml(name || 'inconnu') + '</span>' +
|
||||
'<div class="app-dist-track"><div class="app-dist-fill" style="width:' + pct + '%"></div></div>' +
|
||||
'<span class="app-dist-val">' + count + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// -- Top variantes --
|
||||
const variants = kpis.variants_top5 || [];
|
||||
const varDiv = document.getElementById('variantsList');
|
||||
if (variants.length === 0) {
|
||||
varDiv.innerHTML = '<p style="color:#475569;">Aucune variante detectee</p>';
|
||||
} else {
|
||||
varDiv.innerHTML = variants.map((v, i) => {
|
||||
return '<div class="variant-item">' +
|
||||
'<span class="variant-path" title="' + escapeHtml(v.variant) + '">' +
|
||||
'#' + (i + 1) + ' — ' + escapeHtml(v.variant) +
|
||||
'</span>' +
|
||||
'<span class="variant-count">' + v.count + ' fois</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Toggle vue BPMN / DFG
|
||||
// ========================================================================
|
||||
function switchView(view, btn) {
|
||||
document.querySelectorAll('.view-toggle .toggle-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
document.getElementById('bpmnView').style.display = (view === 'bpmn') ? 'block' : 'none';
|
||||
document.getElementById('dfgView').style.display = (view === 'dfg') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Helpers
|
||||
// ========================================================================
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds || seconds <= 0) return '0s';
|
||||
if (seconds < 60) return Math.round(seconds) + 's';
|
||||
if (seconds < 3600) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
return m + 'min ' + (s > 0 ? s + 's' : '');
|
||||
}
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.round((seconds % 3600) / 60);
|
||||
return h + 'h ' + (m > 0 ? m + 'min' : '');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showError(msg, detail) {
|
||||
const banner = document.getElementById('errorBanner');
|
||||
const text = document.getElementById('errorText');
|
||||
text.textContent = msg + (detail ? ' — ' + detail : '');
|
||||
banner.classList.add('visible');
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
document.getElementById('errorBanner').classList.remove('visible');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Init
|
||||
// ========================================================================
|
||||
document.addEventListener('DOMContentLoaded', loadMachines);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user