v1.0 - Version stable: multi-PC, détection UI-DETR-1, 3 modes exécution

- Frontend v4 accessible sur réseau local (192.168.1.40)
- Ports ouverts: 3002 (frontend), 5001 (backend), 5004 (dashboard)
- Ollama GPU fonctionnel
- Self-healing interactif
- Dashboard confiance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dom
2026-01-29 11:23:51 +01:00
parent 21bfa3b337
commit a27b74cf22
1595 changed files with 412691 additions and 400 deletions

View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -0,0 +1,46 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"proxy": "http://localhost:5001",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7",
"@reduxjs/toolkit": "^2.11.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@xyflow/react": "^12.10.0",
"axios": "^1.13.2",
"lodash": "^4.17.21",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-redux": "^9.2.0",
"react-scripts": "5.0.1",
"socket.io-client": "^4.8.3",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "DISABLE_ESLINT_PLUGIN=true react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"start:safe": "DISABLE_ESLINT_PLUGIN=true GENERATE_SOURCEMAP=false react-scripts start",
"build:safe": "GENERATE_SOURCEMAP=false react-scripts build",
"type-check": "tsc --noEmit"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#1976d2" />
<meta
name="description"
content="Visual Workflow Builder V2 - Interface de création de workflows RPA"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Visual Workflow Builder V2</title>
<!--
Suppression des erreurs ResizeObserver
Auteur : Dom, Alice, Kiro - 09 janvier 2026
Ces erreurs sont bénignes et générées par Material-UI/ReactFlow
-->
<script>
(function() {
// Fonction de vérification des erreurs ResizeObserver
function isResizeObserverError(message) {
return message && (
message.includes('ResizeObserver loop') ||
message.includes('ResizeObserver loop completed with undelivered notifications')
);
}
// Intercepter window.onerror
var originalOnError = window.onerror;
window.onerror = function(message, source, lineno, colno, error) {
if (isResizeObserverError(message)) {
return true; // Empêcher la propagation
}
if (originalOnError) {
return originalOnError(message, source, lineno, colno, error);
}
return false;
};
// Intercepter les événements d'erreur (phase de capture)
window.addEventListener('error', function(event) {
if (event.message && isResizeObserverError(event.message)) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
return false;
}
}, true);
// Intercepter les promesses rejetées
window.addEventListener('unhandledrejection', function(event) {
if (event.reason && event.reason.message && isResizeObserverError(event.reason.message)) {
event.preventDefault();
return false;
}
}, true);
// Intercepter console.error
var originalConsoleError = console.error;
console.error = function() {
var args = Array.prototype.slice.call(arguments);
if (args.length > 0 && typeof args[0] === 'string' && isResizeObserverError(args[0])) {
return; // Ignorer silencieusement
}
originalConsoleError.apply(console, args);
};
// Intercepter console.warn aussi
var originalConsoleWarn = console.warn;
console.warn = function() {
var args = Array.prototype.slice.call(arguments);
if (args.length > 0 && typeof args[0] === 'string' && isResizeObserverError(args[0])) {
return; // Ignorer silencieusement
}
originalConsoleWarn.apply(console, args);
};
})();
</script>
</head>
<body>
<noscript>Vous devez activer JavaScript pour utiliser cette application.</noscript>
<div id="root"></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@@ -364,13 +364,17 @@ function App() {
const handleStepAdd = (stepData: Omit<Step, 'id'>) => {
console.log('Ajout étape:', stepData);
// IMPORTANT: Copie profonde pour éviter les références partagées avec d'autres étapes
const stepDataCopy = JSON.parse(JSON.stringify(stepData));
const newStep: Step = {
...stepData,
...stepDataCopy,
id: `step_${Date.now()}`,
executionState: StepExecutionState.IDLE,
validationErrors: [],
};
setWorkflow(prev => ({
...prev,
steps: [...prev.steps, newStep],
@@ -406,19 +410,25 @@ function App() {
const handleParameterChange = (stepId: string, param: string, value: any) => {
console.log('Changement paramètre:', stepId, param, value);
// IMPORTANT: Copie profonde de la valeur pour éviter les références partagées entre étapes
const valueCopy = value && typeof value === 'object'
? JSON.parse(JSON.stringify(value))
: value;
setWorkflow(prev => ({
...prev,
steps: prev.steps.map(step =>
step.id === stepId
? {
...step,
data: {
...step.data,
parameters: {
...step.data.parameters,
[param]: value
}
}
steps: prev.steps.map(step =>
step.id === stepId
? {
...step,
data: {
...step.data,
parameters: {
...step.data.parameters,
[param]: valueCopy
}
}
}
: step
),

View File

@@ -0,0 +1,325 @@
/**
* Fournisseur d'Accessibilité - Conformité WCAG 2.1 niveau AA
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant fournit les fonctionnalités d'accessibilité nécessaires
* pour assurer la conformité aux standards WCAG 2.1 niveau AA.
*/
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { Box } from '@mui/material';
interface AccessibilitySettings {
// Préférences utilisateur
highContrast: boolean;
reducedMotion: boolean;
largeText: boolean;
screenReaderMode: boolean;
// Paramètres de navigation
focusVisible: boolean;
skipLinks: boolean;
// Paramètres d'affichage
colorBlindnessMode: 'none' | 'protanopia' | 'deuteranopia' | 'tritanopia';
fontSize: 'small' | 'medium' | 'large' | 'extra-large';
// Paramètres d'interaction
clickDelay: number; // ms
hoverDelay: number; // ms
}
interface AccessibilityContextType {
settings: AccessibilitySettings;
updateSettings: (updates: Partial<AccessibilitySettings>) => void;
announceToScreenReader: (message: string, priority?: 'polite' | 'assertive') => void;
focusElement: (elementId: string) => void;
isKeyboardNavigation: boolean;
}
const defaultSettings: AccessibilitySettings = {
highContrast: false,
reducedMotion: false,
largeText: false,
screenReaderMode: false,
focusVisible: true,
skipLinks: true,
colorBlindnessMode: 'none',
fontSize: 'medium',
clickDelay: 0,
hoverDelay: 500,
};
const AccessibilityContext = createContext<AccessibilityContextType | null>(null);
interface AccessibilityProviderProps {
children: ReactNode;
}
/**
* Fournisseur d'Accessibilité
*/
export const AccessibilityProvider: React.FC<AccessibilityProviderProps> = ({ children }) => {
const [settings, setSettings] = useState<AccessibilitySettings>(defaultSettings);
const [isKeyboardNavigation, setIsKeyboardNavigation] = useState(false);
const [screenReaderMessage, setScreenReaderMessage] = useState<{
message: string;
priority: 'polite' | 'assertive';
} | null>(null);
// Détecter les préférences système
useEffect(() => {
const mediaQueries = {
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)'),
highContrast: window.matchMedia('(prefers-contrast: high)'),
largeText: window.matchMedia('(prefers-reduced-data: reduce)'), // Approximation
};
const updateFromSystem = () => {
setSettings(prev => ({
...prev,
reducedMotion: mediaQueries.reducedMotion.matches,
highContrast: mediaQueries.highContrast.matches,
}));
};
// Écouter les changements
Object.values(mediaQueries).forEach(mq => {
mq.addEventListener('change', updateFromSystem);
});
// Initialiser
updateFromSystem();
return () => {
Object.values(mediaQueries).forEach(mq => {
mq.removeEventListener('change', updateFromSystem);
});
};
}, []);
// Détecter la navigation au clavier
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
setIsKeyboardNavigation(true);
}
};
const handleMouseDown = () => {
setIsKeyboardNavigation(false);
};
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('mousedown', handleMouseDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleMouseDown);
};
}, []);
// Appliquer les styles d'accessibilité
useEffect(() => {
const root = document.documentElement;
// Contraste élevé
if (settings.highContrast) {
root.style.setProperty('--primary-color', '#000000');
root.style.setProperty('--background-color', '#ffffff');
root.style.setProperty('--text-color', '#000000');
root.style.setProperty('--border-color', '#000000');
} else {
root.style.removeProperty('--primary-color');
root.style.removeProperty('--background-color');
root.style.removeProperty('--text-color');
root.style.removeProperty('--border-color');
}
// Taille de police
const fontSizeMap = {
'small': '14px',
'medium': '16px',
'large': '18px',
'extra-large': '20px',
};
root.style.fontSize = fontSizeMap[settings.fontSize];
// Mouvement réduit
if (settings.reducedMotion) {
root.style.setProperty('--animation-duration', '0.01ms');
root.style.setProperty('--transition-duration', '0.01ms');
} else {
root.style.removeProperty('--animation-duration');
root.style.removeProperty('--transition-duration');
}
// Focus visible
if (settings.focusVisible && isKeyboardNavigation) {
root.classList.add('keyboard-navigation');
} else {
root.classList.remove('keyboard-navigation');
}
}, [settings, isKeyboardNavigation]);
// Fonction pour mettre à jour les paramètres
const updateSettings = (updates: Partial<AccessibilitySettings>) => {
setSettings(prev => ({ ...prev, ...updates }));
};
// Fonction pour annoncer aux lecteurs d'écran
const announceToScreenReader = (message: string, priority: 'polite' | 'assertive' = 'polite') => {
setScreenReaderMessage({ message, priority });
// Effacer le message après un délai
setTimeout(() => {
setScreenReaderMessage(null);
}, 1000);
};
// Fonction pour donner le focus à un élément
const focusElement = (elementId: string) => {
const element = document.getElementById(elementId);
if (element) {
element.focus();
// Annoncer le changement de focus
const label = element.getAttribute('aria-label') || element.textContent || 'Élément';
announceToScreenReader(`Focus sur ${label}`);
}
};
const contextValue: AccessibilityContextType = {
settings,
updateSettings,
announceToScreenReader,
focusElement,
isKeyboardNavigation,
};
return (
<AccessibilityContext.Provider value={contextValue}>
{/* Liens de saut pour la navigation au clavier */}
{settings.skipLinks && (
<Box
sx={{
position: 'absolute',
top: -40,
left: 6,
zIndex: 9999,
'&:focus-within': {
top: 6,
},
}}
>
<a
href="#main-content"
style={{
background: '#000',
color: '#fff',
padding: '8px 16px',
textDecoration: 'none',
borderRadius: '4px',
}}
onFocus={() => announceToScreenReader('Lien de saut vers le contenu principal')}
>
Aller au contenu principal
</a>
</Box>
)}
{/* Zone live pour les annonces aux lecteurs d'écran */}
<div
role="status"
aria-live={screenReaderMessage?.priority || 'polite'}
aria-atomic="true"
style={{
position: 'absolute',
left: '-10000px',
width: '1px',
height: '1px',
overflow: 'hidden',
}}
>
{screenReaderMessage?.message}
</div>
{/* Contenu principal avec ID et rôle pour les liens de saut */}
<main id="main-content" role="main" tabIndex={-1}>
{children}
</main>
{/* Styles CSS d'accessibilité injectés */}
<style>{`
/* Focus visible pour la navigation au clavier */
.keyboard-navigation *:focus {
outline: 2px solid #2196f3 !important;
outline-offset: 2px !important;
}
/* Contraste élevé */
${settings.highContrast ? `
* {
background-color: var(--background-color, white) !important;
color: var(--text-color, black) !important;
border-color: var(--border-color, black) !important;
}
button, input, select, textarea {
border: 2px solid black !important;
}
` : ''}
/* Mouvement réduit */
${settings.reducedMotion ? `
*, *::before, *::after {
animation-duration: var(--animation-duration, 0.01ms) !important;
animation-iteration-count: 1 !important;
transition-duration: var(--transition-duration, 0.01ms) !important;
scroll-behavior: auto !important;
}
` : ''}
/* Filtres pour daltonisme */
${settings.colorBlindnessMode !== 'none' ? `
html {
filter: ${getColorBlindnessFilter(settings.colorBlindnessMode)};
}
` : ''}
/* Texte large */
${settings.largeText ? `
* {
font-size: 1.2em !important;
line-height: 1.5 !important;
}
` : ''}
`}</style>
</AccessibilityContext.Provider>
);
};
// Fonction utilitaire pour les filtres de daltonisme
function getColorBlindnessFilter(mode: string): string {
switch (mode) {
case 'protanopia':
return 'url(#protanopia)';
case 'deuteranopia':
return 'url(#deuteranopia)';
case 'tritanopia':
return 'url(#tritanopia)';
default:
return 'none';
}
}
// Hook pour utiliser le contexte d'accessibilité
export const useAccessibility = (): AccessibilityContextType => {
const context = useContext(AccessibilityContext);
if (!context) {
throw new Error('useAccessibility doit être utilisé dans un AccessibilityProvider');
}
return context;
};
export default AccessibilityProvider;

View File

@@ -0,0 +1,379 @@
/**
* Composant StepNode - Nœud personnalisé pour les étapes de workflow
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Composant de nœud personnalisé avec états visuels d'exécution,
* indicateurs d'erreur et style français. Optimisé pour les performances.
*
* EXTENSION VWB : Support des animations et états visuels avancés pour les actions VisionOnly.
*/
import React, { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import {
Box,
Typography,
Chip,
Tooltip,
} from '@mui/material';
import {
PlayArrow as RunningIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Warning as WarningIcon,
Pause as PauseIcon,
SkipNext as SkipNextIcon,
} from '@mui/icons-material';
// Import des types partagés
import { StepNodeData, StepExecutionState, ValidationError, StepType } from '../../types';
// Import de l'extension VWB
import VWBStepNodeExtension from './VWBStepNodeExtension';
// Import du service VWB pour la détection
// import { useVWBExecutionService } from '../../services/vwbExecutionService';
// Couleurs par état d'exécution
const executionStateColors = {
[StepExecutionState.IDLE]: '#9e9e9e',
[StepExecutionState.RUNNING]: '#2196f3',
[StepExecutionState.SUCCESS]: '#4caf50',
[StepExecutionState.ERROR]: '#f44336',
[StepExecutionState.SKIPPED]: '#9e9e9e',
[StepExecutionState.PAUSED]: '#9c27b0',
};
// Icônes par état d'exécution
const executionStateIcons = {
[StepExecutionState.IDLE]: null,
[StepExecutionState.RUNNING]: <RunningIcon fontSize="small" />,
[StepExecutionState.SUCCESS]: <SuccessIcon fontSize="small" />,
[StepExecutionState.ERROR]: <ErrorIcon fontSize="small" />,
[StepExecutionState.SKIPPED]: <SkipNextIcon fontSize="small" />,
[StepExecutionState.PAUSED]: <PauseIcon fontSize="small" />,
};
// Labels français par type d'étape
const stepTypeLabels: Record<StepType, string> = {
click: 'Cliquer',
type: 'Saisir',
wait: 'Attendre',
condition: 'Condition',
extract: 'Extraire',
scroll: 'Défiler',
navigate: 'Naviguer',
screenshot: 'Capture',
};
/**
* Composant StepNode personnalisé avec support des actions VWB
*/
const StepNode: React.FC<NodeProps> = ({ data, selected }) => {
const stepData = data as StepNodeData;
// Service VWB pour détecter les actions VWB (fonction locale)
const isVWBStep = (step: any) => Boolean(
step.data?.isVWBCatalogAction ||
step.data?.vwbActionId ||
step.action_id?.startsWith('vwb_') ||
step.action_id?.includes('catalog_')
);
// Vérification de sécurité pour les données
if (!stepData) {
return (
<Box sx={{ p: 2, border: '1px solid red', borderRadius: 1 }}>
<Typography color="error">Données manquantes</Typography>
</Box>
);
}
// Créer un objet Step temporaire pour la détection VWB
const tempStep = {
id: 'temp',
type: stepData.stepType,
name: stepData.label,
data: stepData,
position: { x: 0, y: 0 },
executionState: stepData.executionState,
validationErrors: stepData.validationErrors || []
};
// Détecter si c'est une action VWB
const isVWBAction = stepData.isVWBCatalogAction || isVWBStep(tempStep);
// Si c'est une action VWB, utiliser l'extension VWB
if (isVWBAction) {
return <VWBStepNodeExtension data={data} selected={selected} />;
}
// Sinon, utiliser le rendu standard (code existant)
return <StandardStepNode data={data} selected={selected} />;
};
/**
* Composant StepNode Standard (code existant refactorisé)
*/
interface StandardStepNodeProps {
data: any;
selected: boolean;
}
const StandardStepNode: React.FC<StandardStepNodeProps> = ({ data, selected }) => {
const stepData = data as StepNodeData;
const {
label,
stepType,
executionState = StepExecutionState.IDLE,
validationErrors = [],
isSelected = false,
isVWBCatalogAction = false,
} = stepData || {};
// Vérification de sécurité pour les données
if (!stepData) {
return (
<Box sx={{ p: 2, border: '1px solid red', borderRadius: 1 }}>
<Typography color="error">Données manquantes</Typography>
</Box>
);
}
// Déterminer la couleur de bordure selon l'état
const getBorderColor = () => {
if (validationErrors.some((e: ValidationError) => e.severity === 'error')) {
return '#f44336'; // Rouge pour les erreurs
}
if (validationErrors.some((e: ValidationError) => e.severity === 'warning')) {
return '#ff9800'; // Orange pour les avertissements
}
if (selected || isSelected) {
return '#1976d2'; // Bleu pour la sélection
}
return executionStateColors[executionState as StepExecutionState];
};
// Déterminer la couleur de fond selon l'état
const getBackgroundColor = () => {
switch (executionState) {
case StepExecutionState.RUNNING:
return '#e3f2fd';
case StepExecutionState.SUCCESS:
return '#e8f5e8';
case StepExecutionState.ERROR:
return '#ffebee';
case StepExecutionState.SKIPPED:
return '#fff3e0';
default:
return '#ffffff';
}
};
// Messages d'erreur pour le tooltip
const errorMessages = validationErrors
.filter((e: ValidationError) => e.severity === 'error')
.map((e: ValidationError) => e.message)
.join(', ');
const warningMessages = validationErrors
.filter((e: ValidationError) => e.severity === 'warning')
.map((e: ValidationError) => e.message)
.join(', ');
// Style commun pour les handles
const handleStyle = {
background: getBorderColor(),
width: 10,
height: 10,
border: '2px solid white',
};
return (
<Box
sx={{
minWidth: 120,
minHeight: 50,
maxWidth: 180,
backgroundColor: getBackgroundColor(),
border: `2px solid ${getBorderColor()}`,
borderRadius: 2,
boxShadow: selected || isSelected ? '0 4px 12px rgba(0,0,0,0.15)' : '0 2px 8px rgba(0,0,0,0.1)',
position: 'relative',
transition: 'all 0.2s ease-in-out',
'&:hover': {
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
transform: 'translateY(-1px)',
},
}}
>
{/* Poignées de connexion - Haut (entrées) */}
<Handle
type="target"
position={Position.Top}
id="top"
style={handleStyle}
/>
{/* Poignées de connexion - Gauche (entrée alternative) */}
<Handle
type="target"
position={Position.Left}
id="left"
style={handleStyle}
/>
{/* Poignées de connexion - Droite (sortie alternative) */}
<Handle
type="source"
position={Position.Right}
id="right"
style={handleStyle}
/>
{/* Poignées de connexion - Bas (sortie principale) */}
<Handle
type="source"
position={Position.Bottom}
id="bottom"
style={handleStyle}
/>
{/* Contenu principal */}
<Box sx={{ p: 1.5 }}>
{/* En-tête avec type et état */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip
label={stepTypeLabels[stepType as StepType] || stepType}
size="small"
variant="outlined"
sx={{
fontSize: '0.75rem',
height: 20,
borderColor: getBorderColor(),
color: getBorderColor(),
}}
/>
{/* Badge VWB pour les actions du catalogue */}
{isVWBCatalogAction && (
<Chip
label="VWB"
size="small"
color="primary"
sx={{
fontSize: '0.65rem',
height: 16,
minWidth: 32,
'& .MuiChip-label': {
px: 0.5,
},
}}
/>
)}
</Box>
{/* Icône d'état d'exécution */}
{executionStateIcons[executionState as StepExecutionState] && (
<Box sx={{ color: executionStateColors[executionState as StepExecutionState] }}>
{executionStateIcons[executionState as StepExecutionState]}
</Box>
)}
</Box>
{/* Nom de l'étape */}
<Typography
variant="body2"
sx={{
fontWeight: 500,
color: '#333',
textAlign: 'center',
wordBreak: 'break-word',
lineHeight: 1.2,
}}
>
{label}
</Typography>
{/* Indicateurs d'erreur/avertissement */}
{validationErrors.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1, gap: 0.5 }}>
{validationErrors.some((e: ValidationError) => e.severity === 'error') && (
<Tooltip title={`Erreurs: ${errorMessages}`} arrow>
<ErrorIcon sx={{ fontSize: 16, color: '#f44336' }} />
</Tooltip>
)}
{validationErrors.some((e: ValidationError) => e.severity === 'warning') && (
<Tooltip title={`Avertissements: ${warningMessages}`} arrow>
<WarningIcon sx={{ fontSize: 16, color: '#ff9800' }} />
</Tooltip>
)}
</Box>
)}
</Box>
{/* Indicateur de sélection */}
{(selected || isSelected) && (
<Box
sx={{
position: 'absolute',
top: -2,
left: -2,
right: -2,
bottom: -2,
border: '2px solid #1976d2',
borderRadius: 2,
pointerEvents: 'none',
animation: 'pulse 2s infinite',
'@keyframes pulse': {
'0%': { opacity: 0.7 },
'50%': { opacity: 1 },
'100%': { opacity: 0.7 },
},
}}
/>
)}
{/* Animation de pulsation pour l'état en cours d'exécution */}
{executionState === StepExecutionState.RUNNING && (
<Box
sx={{
position: 'absolute',
top: -4,
left: -4,
right: -4,
bottom: -4,
border: '2px solid #2196f3',
borderRadius: 2,
pointerEvents: 'none',
animation: 'runningPulse 1s infinite',
'@keyframes runningPulse': {
'0%': { opacity: 0.3, transform: 'scale(1)' },
'50%': { opacity: 0.7, transform: 'scale(1.02)' },
'100%': { opacity: 0.3, transform: 'scale(1)' },
},
}}
/>
)}
</Box>
);
};
// Mémorisation du composant StepNode pour éviter les re-rendus inutiles
export default memo(StepNode, (prevProps, nextProps) => {
// Comparaison personnalisée pour optimiser les re-rendus
const prevData = prevProps.data as StepNodeData;
const nextData = nextProps.data as StepNodeData;
return (
prevProps.id === nextProps.id &&
prevProps.selected === nextProps.selected &&
prevData?.label === nextData?.label &&
prevData?.stepType === nextData?.stepType &&
prevData?.executionState === nextData?.executionState &&
prevData?.isSelected === nextData?.isSelected &&
prevData?.isVWBCatalogAction === nextData?.isVWBCatalogAction &&
prevData?.vwbActionId === nextData?.vwbActionId &&
JSON.stringify(prevData?.validationErrors) === JSON.stringify(nextData?.validationErrors)
);
});

View File

@@ -0,0 +1,229 @@
/**
* Intégration Canvas VWB - Gestion du drag-and-drop et des actions VisionOnly
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Ce composant étend le Canvas principal avec la gestion complète des actions VWB,
* incluant le drag-and-drop depuis la Palette, la création d'étapes et la synchronisation.
*/
import React, { useCallback, useMemo } from 'react';
import { useReactFlow, Node, Edge, Connection } from '@xyflow/react';
import { Box, Alert, Snackbar } from '@mui/material';
// Import des hooks d'intégration VWB
import { useVWBStepIntegration } from '../../hooks/useVWBStepIntegration';
// Import des types
import { Step, StepData, StepExecutionState, Position } from '../../types';
import { VWBCatalogAction } from '../../types/catalog';
interface VWBCanvasIntegrationProps {
onStepAdd?: (step: Step) => void;
onStepUpdate?: (stepId: string, updates: Partial<Step>) => void;
onError?: (error: string) => void;
children: React.ReactNode;
}
/**
* Composant d'intégration Canvas VWB
*/
const VWBCanvasIntegration: React.FC<VWBCanvasIntegrationProps> = ({
onStepAdd,
onStepUpdate,
onError,
children,
}) => {
const { screenToFlowPosition } = useReactFlow();
const { state: vwbState, methods: vwbMethods } = useVWBStepIntegration();
const [notification, setNotification] = React.useState<{
open: boolean;
message: string;
severity: 'success' | 'error' | 'warning' | 'info';
}>({
open: false,
message: '',
severity: 'info',
});
// Gestionnaire de drop pour les actions VWB
const handleDrop = useCallback(async (event: React.DragEvent) => {
event.preventDefault();
try {
// Obtenir les données de drag
const dragData = event.dataTransfer.getData('application/reactflow');
if (!dragData) return;
// Calculer la position dans le flow
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// Tenter de créer une étape VWB
const vwbStep = await vwbMethods.convertDragDataToVWBStep(dragData, position);
if (vwbStep) {
// C'est une action VWB, l'ajouter au workflow
onStepAdd?.(vwbStep);
setNotification({
open: true,
message: `Action VWB "${vwbStep.name}" ajoutée au workflow`,
severity: 'success',
});
} else {
// Pas une action VWB, laisser le Canvas principal gérer
// (Cette logique sera gérée par le composant parent)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de l\'ajout de l\'action VWB';
onError?.(errorMessage);
setNotification({
open: true,
message: errorMessage,
severity: 'error',
});
}
}, [screenToFlowPosition, vwbMethods, onStepAdd, onError]);
// Gestionnaire de dragover pour permettre le drop
const handleDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
// Fermer la notification
const handleCloseNotification = useCallback(() => {
setNotification(prev => ({ ...prev, open: false }));
}, []);
// Convertir une étape en nœud ReactFlow avec support VWB
const convertStepToNode = useCallback((step: Step): Node => {
const nodeData = {
label: step.name,
stepType: step.type,
executionState: step.executionState || StepExecutionState.IDLE,
validationErrors: step.validationErrors || [],
isSelected: false,
parameters: step.data.parameters,
isVWBCatalogAction: step.data.isVWBCatalogAction || false,
vwbActionId: step.data.vwbActionId,
};
return {
id: step.id,
type: 'stepNode', // Type de nœud personnalisé
position: step.position,
data: nodeData,
draggable: true,
};
}, []);
// Valider une étape VWB
const validateVWBStep = useCallback(async (step: Step) => {
if (!step.data.isVWBCatalogAction) return;
try {
const isValid = await vwbMethods.validateVWBStep(step);
if (!isValid) {
setNotification({
open: true,
message: `L'étape "${step.name}" contient des erreurs de configuration`,
severity: 'warning',
});
}
} catch (error) {
console.error('Erreur lors de la validation de l\'étape VWB:', error);
}
}, [vwbMethods]);
// Mémoriser les propriétés d'intégration
const integrationProps = useMemo(() => ({
onDrop: handleDrop,
onDragOver: handleDragOver,
convertStepToNode,
validateVWBStep,
isVWBIntegrationActive: true,
}), [handleDrop, handleDragOver, convertStepToNode, validateVWBStep]);
return (
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
{/* Passer les propriétés d'intégration aux enfants */}
{React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
return React.cloneElement(child, integrationProps);
}
return child;
})}
{/* Indicateur d'état du service VWB */}
{vwbState.error && (
<Alert
severity="warning"
sx={{
position: 'absolute',
top: 10,
right: 10,
zIndex: 1000,
maxWidth: 300,
}}
>
Service VWB indisponible : {vwbState.error}
</Alert>
)}
{/* Indicateur de chargement VWB */}
{vwbState.isLoading && (
<Box
sx={{
position: 'absolute',
top: 10,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
padding: 1,
borderRadius: 1,
boxShadow: 1,
}}
>
Chargement de l'action VWB...
</Box>
)}
{/* Notifications */}
<Snackbar
open={notification.open}
autoHideDuration={4000}
onClose={handleCloseNotification}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert
onClose={handleCloseNotification}
severity={notification.severity}
sx={{ width: '100%' }}
>
{notification.message}
</Alert>
</Snackbar>
</Box>
);
};
export default VWBCanvasIntegration;
/**
* Hook pour utiliser l'intégration VWB dans un composant Canvas
*/
export const useVWBCanvasIntegration = () => {
const { state, methods } = useVWBStepIntegration();
return {
vwbState: state,
vwbMethods: methods,
isVWBAvailable: !state.error && !state.isLoading,
};
};

View File

@@ -0,0 +1,558 @@
/**
* Extension VWB pour StepNode - États visuels avancés pour les actions VisionOnly
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Cette extension ajoute des animations, indicateurs de progression et états visuels
* spécialisés pour les actions VWB avec feedback en temps réel.
*/
import React, { memo, useState, useEffect } from 'react';
import { Handle, Position } from '@xyflow/react';
import {
Box,
Typography,
Chip,
Tooltip,
LinearProgress,
CircularProgress,
Fade,
keyframes,
} from '@mui/material';
import {
PlayArrow as RunningIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Warning as WarningIcon,
Pause as PausedIcon,
Visibility as VWBIcon,
Speed as PerformanceIcon,
BugReport as DebugIcon,
Timer as TimerIcon,
} from '@mui/icons-material';
// Import des types
import {
StepNodeData,
StepExecutionState,
ValidationError,
Evidence
} from '../../types';
// Animations CSS-in-JS
const pulseAnimation = keyframes`
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.8;
}
100% {
transform: scale(1);
opacity: 1;
}
`;
const glowAnimation = keyframes`
0% {
box-shadow: 0 0 5px rgba(33, 150, 243, 0.5);
}
50% {
box-shadow: 0 0 20px rgba(33, 150, 243, 0.8);
}
100% {
box-shadow: 0 0 5px rgba(33, 150, 243, 0.5);
}
`;
const successPulse = keyframes`
0% {
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(76, 175, 80, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0);
}
`;
const errorShake = keyframes`
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-2px); }
20%, 40%, 60%, 80% { transform: translateX(2px); }
`;
// Interface étendue pour les données VWB
interface VWBStepNodeData extends StepNodeData {
// Données d'exécution VWB
executionProgress?: number;
executionDuration?: number;
evidence?: Evidence[];
vwbActionId?: string;
isVWBStep?: boolean;
// États d'exécution avancés
isPaused?: boolean;
isDebugging?: boolean;
retryCount?: number;
// Métriques de performance
averageExecutionTime?: number;
successRate?: number;
lastExecutionTime?: number;
}
// Couleurs étendues pour les états VWB
const vwbExecutionStateColors = {
[StepExecutionState.IDLE]: '#9e9e9e',
[StepExecutionState.RUNNING]: '#2196f3',
[StepExecutionState.SUCCESS]: '#4caf50',
[StepExecutionState.ERROR]: '#f44336',
[StepExecutionState.SKIPPED]: '#ff9800',
[StepExecutionState.PAUSED]: '#9c27b0',
};
// Icônes étendues pour les états VWB
const vwbExecutionStateIcons = {
[StepExecutionState.IDLE]: null,
[StepExecutionState.RUNNING]: <RunningIcon fontSize="small" />,
[StepExecutionState.SUCCESS]: <SuccessIcon fontSize="small" />,
[StepExecutionState.ERROR]: <ErrorIcon fontSize="small" />,
[StepExecutionState.SKIPPED]: <WarningIcon fontSize="small" />,
[StepExecutionState.PAUSED]: <PausedIcon fontSize="small" />,
};
/**
* Composant StepNode étendu pour les actions VWB
*/
interface VWBStepNodeExtensionProps {
data: any;
selected: boolean;
}
const VWBStepNodeExtension: React.FC<VWBStepNodeExtensionProps> = ({ data, selected }) => {
const stepData = data as VWBStepNodeData;
const [showProgress, setShowProgress] = useState(false);
const [animationKey, setAnimationKey] = useState(0);
const {
label,
stepType,
executionState = StepExecutionState.IDLE,
validationErrors = [],
isSelected = false,
isVWBCatalogAction = false,
isVWBStep = false,
executionProgress = 0,
executionDuration = 0,
evidence = [],
vwbActionId,
isPaused = false,
isDebugging = false,
retryCount = 0,
averageExecutionTime,
successRate,
} = stepData || {};
// Déclencher les animations lors des changements d'état
useEffect(() => {
if (executionState === StepExecutionState.RUNNING) {
setShowProgress(true);
} else {
setShowProgress(false);
}
// Déclencher une nouvelle animation
setAnimationKey(prev => prev + 1);
}, [executionState]);
// Vérification de sécurité
if (!stepData) {
return (
<Box sx={{ p: 2, border: '1px solid red', borderRadius: 1 }}>
<Typography color="error">Données manquantes</Typography>
</Box>
);
}
// Déterminer si c'est une action VWB
const isVWBAction = isVWBCatalogAction || isVWBStep || Boolean(vwbActionId);
// Couleur de bordure avec logique VWB
const getBorderColor = () => {
if (validationErrors.some((e: ValidationError) => e.severity === 'error')) {
return '#f44336';
}
if (validationErrors.some((e: ValidationError) => e.severity === 'warning')) {
return '#ff9800';
}
if (selected || isSelected) {
return isVWBAction ? '#1976d2' : '#1976d2';
}
return vwbExecutionStateColors[executionState as StepExecutionState];
};
// Couleur de fond avec animations
const getBackgroundColor = () => {
switch (executionState) {
case StepExecutionState.RUNNING:
return isVWBAction ? '#e3f2fd' : '#e3f2fd';
case StepExecutionState.SUCCESS:
return isVWBAction ? '#e8f5e8' : '#e8f5e8';
case StepExecutionState.ERROR:
return '#ffebee';
case StepExecutionState.PAUSED:
return '#f3e5f5';
case StepExecutionState.SKIPPED:
return '#fff3e0';
default:
return isVWBAction ? '#fafafa' : '#ffffff';
}
};
// Styles d'animation selon l'état
const getAnimationStyles = () => {
const baseStyles = {
transition: 'all 0.3s ease-in-out',
};
switch (executionState) {
case StepExecutionState.RUNNING:
return {
...baseStyles,
animation: `${pulseAnimation} 2s infinite, ${glowAnimation} 3s infinite`,
};
case StepExecutionState.SUCCESS:
return {
...baseStyles,
animation: `${successPulse} 1s ease-out`,
};
case StepExecutionState.ERROR:
return {
...baseStyles,
animation: `${errorShake} 0.5s ease-in-out`,
};
default:
return baseStyles;
}
};
// Messages d'erreur pour les tooltips
const errorMessages = validationErrors
.filter((e: ValidationError) => e.severity === 'error')
.map((e: ValidationError) => e.message)
.join(', ');
const warningMessages = validationErrors
.filter((e: ValidationError) => e.severity === 'warning')
.map((e: ValidationError) => e.message)
.join(', ');
// Formater la durée
const formatDuration = (ms: number): string => {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
return `${seconds}s`;
};
// Contenu du tooltip étendu
const getTooltipContent = () => {
const parts = [];
if (isVWBAction) {
parts.push(`Action VWB: ${vwbActionId || stepType}`);
}
if (executionDuration > 0) {
parts.push(`Durée: ${formatDuration(executionDuration)}`);
}
if (evidence.length > 0) {
parts.push(`${evidence.length} Evidence générées`);
}
if (retryCount > 0) {
parts.push(`${retryCount} tentatives`);
}
if (successRate !== undefined) {
parts.push(`Taux de succès: ${Math.round(successRate)}%`);
}
if (errorMessages) {
parts.push(`Erreurs: ${errorMessages}`);
}
if (warningMessages) {
parts.push(`Avertissements: ${warningMessages}`);
}
return parts.join('\n');
};
// Style commun pour les handles
const handleStyle = {
background: getBorderColor(),
width: 10,
height: 10,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
};
return (
<Tooltip title={getTooltipContent()} arrow placement="top">
<Box
key={animationKey}
sx={{
minWidth: isVWBAction ? 140 : 120,
minHeight: isVWBAction ? 55 : 50,
maxWidth: 180,
backgroundColor: getBackgroundColor(),
border: `2px solid ${getBorderColor()}`,
borderRadius: 2,
boxShadow: selected || isSelected
? '0 4px 12px rgba(0,0,0,0.15)'
: '0 2px 8px rgba(0,0,0,0.1)',
position: 'relative',
overflow: 'hidden',
...getAnimationStyles(),
'&:hover': {
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
transform: 'translateY(-1px)',
},
}}
>
{/* Barre de progression pour les actions en cours */}
{showProgress && executionState === StepExecutionState.RUNNING && (
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0 }}>
<LinearProgress
variant={executionProgress > 0 ? 'determinate' : 'indeterminate'}
value={executionProgress}
sx={{
height: 3,
backgroundColor: 'rgba(0,0,0,0.1)',
'& .MuiLinearProgress-bar': {
backgroundColor: isVWBAction ? '#1976d2' : '#2196f3',
},
}}
/>
</Box>
)}
{/* Poignées de connexion - Haut (entrée principale) */}
<Handle
type="target"
position={Position.Top}
id="top"
style={handleStyle}
/>
{/* Poignées de connexion - Gauche (entrée alternative) */}
<Handle
type="target"
position={Position.Left}
id="left"
style={handleStyle}
/>
{/* Poignées de connexion - Droite (sortie alternative) */}
<Handle
type="source"
position={Position.Right}
id="right"
style={handleStyle}
/>
{/* Poignées de connexion - Bas (sortie principale) */}
<Handle
type="source"
position={Position.Bottom}
id="bottom"
style={handleStyle}
/>
{/* Contenu principal */}
<Box sx={{ p: 1.5, pt: showProgress ? 2 : 1.5 }}>
{/* En-tête avec type et badges */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Chip
label={stepType}
size="small"
variant="outlined"
sx={{
fontSize: '0.75rem',
height: 20,
borderColor: getBorderColor(),
color: getBorderColor(),
}}
/>
{/* Badge VWB */}
{isVWBAction && (
<Chip
label="VWB"
size="small"
color="primary"
icon={<VWBIcon sx={{ fontSize: '0.7rem' }} />}
sx={{
fontSize: '0.65rem',
height: 18,
minWidth: 40,
'& .MuiChip-label': {
px: 0.5,
},
}}
/>
)}
{/* Badge Debug */}
{isDebugging && (
<Chip
label="DEBUG"
size="small"
color="warning"
icon={<DebugIcon sx={{ fontSize: '0.6rem' }} />}
sx={{
fontSize: '0.6rem',
height: 16,
}}
/>
)}
{/* Badge Evidence */}
{evidence.length > 0 && (
<Chip
label={evidence.length}
size="small"
color="info"
sx={{
fontSize: '0.6rem',
height: 16,
minWidth: 20,
}}
/>
)}
</Box>
{/* Métriques de performance pour VWB */}
{isVWBAction && (averageExecutionTime || successRate !== undefined) && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{averageExecutionTime && (
<Chip
label={formatDuration(averageExecutionTime)}
size="small"
variant="outlined"
icon={<TimerIcon sx={{ fontSize: '0.6rem' }} />}
sx={{
fontSize: '0.6rem',
height: 16,
color: 'text.secondary',
borderColor: 'text.secondary',
}}
/>
)}
{successRate !== undefined && (
<Chip
label={`${Math.round(successRate)}%`}
size="small"
variant="outlined"
icon={<PerformanceIcon sx={{ fontSize: '0.6rem' }} />}
sx={{
fontSize: '0.6rem',
height: 16,
color: successRate >= 90 ? 'success.main' : 'warning.main',
borderColor: successRate >= 90 ? 'success.main' : 'warning.main',
}}
/>
)}
</Box>
)}
</Box>
{/* Icône d'état avec animation */}
<Fade in={Boolean(vwbExecutionStateIcons[executionState as StepExecutionState])} timeout={300}>
<Box sx={{
color: vwbExecutionStateColors[executionState as StepExecutionState],
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}>
{executionState === StepExecutionState.RUNNING && (
<CircularProgress size={16} thickness={4} />
)}
{vwbExecutionStateIcons[executionState as StepExecutionState]}
</Box>
</Fade>
</Box>
{/* Label de l'étape */}
<Typography
variant="body2"
sx={{
fontWeight: 500,
color: 'text.primary',
lineHeight: 1.2,
wordBreak: 'break-word',
fontSize: isVWBAction ? '0.85rem' : '0.8rem',
}}
>
{label}
</Typography>
{/* Informations d'exécution en temps réel */}
{executionState === StepExecutionState.RUNNING && executionDuration > 0 && (
<Typography
variant="caption"
sx={{
color: 'text.secondary',
display: 'block',
mt: 0.5,
fontSize: '0.7rem',
}}
>
{formatDuration(executionDuration)}
{executionProgress > 0 && ` (${Math.round(executionProgress)}%)`}
</Typography>
)}
{/* Compteur de retry */}
{retryCount > 0 && (
<Typography
variant="caption"
sx={{
color: 'warning.main',
display: 'block',
mt: 0.5,
fontSize: '0.7rem',
}}
>
Tentative {retryCount}
</Typography>
)}
</Box>
{/* Indicateur de pause */}
{isPaused && (
<Box
sx={{
position: 'absolute',
top: 4,
right: 4,
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: 'warning.main',
animation: `${pulseAnimation} 1s infinite`,
}}
/>
)}
</Box>
</Tooltip>
);
};
export default memo(VWBStepNodeExtension);

View File

@@ -0,0 +1,709 @@
/**
* Composant Canvas Principal - Interface de création de workflows visuels
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant gère le rendu visuel des workflows avec @xyflow/react,
* la sélection d'étapes, le déplacement en temps réel et la minimap.
* Optimisé pour maintenir 60fps lors des interactions.
*/
import React, { useCallback, useEffect, useMemo, useRef, memo } from 'react';
import {
ReactFlow,
Node,
Edge,
addEdge,
Connection,
useNodesState,
useEdgesState,
Controls,
MiniMap,
Background,
BackgroundVariant,
NodeTypes,
EdgeTypes,
MarkerType,
useReactFlow,
} from '@xyflow/react';
import { Box, Paper, Typography } from '@mui/material';
import '@xyflow/react/dist/style.css';
// Composants de nœuds personnalisés
import StepNode from './StepNode';
// Import des types partagés
import {
CanvasProps,
Step,
StepExecutionState,
StepNodeData,
StepType
} from '../../types';
// Types de nœuds personnalisés - mémorisés pour éviter les re-créations
const nodeTypes: NodeTypes = {
stepNode: StepNode,
};
// Types d'arêtes personnalisés - mémorisés pour éviter les re-créations
const edgeTypes: EdgeTypes = {};
// Options par défaut pour les arêtes - mémorisées pour éviter les re-créations
const defaultEdgeOptions = {
type: 'smoothstep',
markerEnd: {
type: MarkerType.ArrowClosed,
width: 20,
height: 20,
color: '#1976d2',
},
style: {
strokeWidth: 2,
stroke: '#1976d2',
},
// Permettre la sélection et suppression des edges
focusable: true,
deletable: true,
// Style au survol
interactionWidth: 20, // Zone de clic plus large
};
// Styles mémorisés pour éviter les re-créations d'objets
const canvasStyles = {
width: '100%',
height: '100%',
backgroundColor: '#fafafa',
position: 'relative' as const,
// Styles pour les flèches (edges) sélectionnées - ROUGE VIVE pour éviter confusion
'& .react-flow__edge.selected .react-flow__edge-path': {
stroke: '#f44336 !important', // Rouge Material UI
strokeWidth: '4px !important',
},
'& .react-flow__edge.selected .react-flow__edge-interaction': {
stroke: '#f44336 !important',
},
'& .react-flow__edge.selected marker': {
fill: '#f44336 !important',
},
// Style au survol des edges pour feedback visuel
'& .react-flow__edge:hover .react-flow__edge-path': {
stroke: '#ff9800 !important', // Orange au survol
strokeWidth: '3px !important',
},
'& .react-flow__edge:hover marker': {
fill: '#ff9800 !important',
},
} as const;
const paperStyles = {
width: '100%',
height: '100%',
overflow: 'hidden',
position: 'relative' as const,
} as const;
const emptyStateBoxStyles = {
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center' as const,
zIndex: 1000,
pointerEvents: 'none' as const,
} as const;
const emptyStatePaperStyles = {
p: 4,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
backdropFilter: 'blur(4px)',
} as const;
const executionIndicatorBoxStyles = {
position: 'absolute' as const,
top: 16,
right: 16,
zIndex: 1000,
} as const;
const executionIndicatorPaperStyles = {
p: 2,
backgroundColor: '#2196f3',
color: 'white',
display: 'flex',
alignItems: 'center',
gap: 1,
} as const;
const executionIndicatorAnimationStyles = {
width: 12,
height: 12,
borderRadius: '50%',
backgroundColor: 'white',
animation: 'pulse 1s infinite',
'@keyframes pulse': {
'0%': { opacity: 1 },
'50%': { opacity: 0.5 },
'100%': { opacity: 1 },
},
} as const;
/**
* Composant Canvas principal pour l'édition de workflows
*/
const Canvas: React.FC<CanvasProps> = ({
workflow,
selectedStep,
executionState,
onStepSelect,
onStepMove,
onConnection,
onConnectionDelete,
onStepAdd,
onStepDelete,
}) => {
const { fitView, getViewport } = useReactFlow();
// Conversion des étapes du workflow en nœuds ReactFlow
const initialNodes: Node[] = useMemo(() => {
if (!workflow?.steps) return [];
return workflow.steps.map((step) => ({
id: step.id,
type: 'stepNode',
position: step.position,
data: {
label: step.name,
stepType: step.type,
executionState: step.executionState || StepExecutionState.IDLE,
validationErrors: step.validationErrors || [],
isSelected: selectedStep?.id === step.id,
parameters: step.data?.parameters || {},
// Préserver les flags VWB
isVWBCatalogAction: step.data?.isVWBCatalogAction || false,
vwbActionId: step.data?.vwbActionId || undefined,
} as StepNodeData,
selected: selectedStep?.id === step.id,
}));
}, [workflow?.steps, selectedStep]);
// Conversion des connexions du workflow en arêtes ReactFlow
const initialEdges: Edge[] = useMemo(() => {
if (!workflow?.connections) return [];
return workflow.connections.map((connection) => ({
id: connection.id,
source: connection.source,
target: connection.target,
...defaultEdgeOptions,
}));
}, [workflow?.connections]);
// États locaux pour les nœuds et connexions
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const canvasRef = useRef<HTMLDivElement>(null);
// Gestionnaire d'événements clavier pour le Canvas
const handleKeyDown = useCallback((event: KeyboardEvent) => {
// Ignorer si l'utilisateur tape dans un champ de saisie
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
switch (event.key) {
case 'Delete':
case 'Backspace':
// Supprimer les nœuds sélectionnés
const selectedNodes = nodes.filter(node => node.selected);
if (selectedNodes.length > 0 && onStepDelete) {
event.preventDefault();
selectedNodes.forEach(node => onStepDelete(node.id));
}
break;
case 'Escape':
// Désélectionner tous les nœuds
event.preventDefault();
setNodes(nodes => nodes.map(node => ({ ...node, selected: false })));
if (onStepSelect) {
onStepSelect(null);
}
break;
case 'a':
if (event.ctrlKey || event.metaKey) {
// Sélectionner tous les nœuds
event.preventDefault();
setNodes(nodes => nodes.map(node => ({ ...node, selected: true })));
}
break;
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
// Déplacer les nœuds sélectionnés
const selectedNodesForMove = nodes.filter(node => node.selected);
if (selectedNodesForMove.length > 0) {
event.preventDefault();
const moveDistance = event.shiftKey ? 50 : 10;
selectedNodesForMove.forEach(node => {
const newPosition = { ...node.position };
switch (event.key) {
case 'ArrowUp': newPosition.y -= moveDistance; break;
case 'ArrowDown': newPosition.y += moveDistance; break;
case 'ArrowLeft': newPosition.x -= moveDistance; break;
case 'ArrowRight': newPosition.x += moveDistance; break;
}
if (onStepMove) {
onStepMove(node.id, newPosition);
}
});
}
break;
case 'Tab':
// Navigation entre les nœuds
if (nodes.length > 0) {
event.preventDefault();
const currentSelectedIndex = nodes.findIndex(node => node.selected);
let nextIndex: number;
if (event.shiftKey) {
// Tab + Shift : précédent
nextIndex = currentSelectedIndex <= 0 ? nodes.length - 1 : currentSelectedIndex - 1;
} else {
// Tab : suivant
nextIndex = currentSelectedIndex >= nodes.length - 1 ? 0 : currentSelectedIndex + 1;
}
const nextNode = nodes[nextIndex];
if (nextNode && onStepSelect) {
// Désélectionner tous et sélectionner le suivant
setNodes(nodes => nodes.map((node, index) => ({
...node,
selected: index === nextIndex
})));
// Trouver l'étape correspondante
const step = workflow?.steps.find(s => s.id === nextNode.id);
if (step) {
onStepSelect(step);
}
}
}
break;
case 'Enter':
// Activer l'édition du nœud sélectionné
const selectedNode = nodes.find(node => node.selected);
if (selectedNode) {
event.preventDefault();
const step = workflow?.steps.find(s => s.id === selectedNode.id);
if (step && onStepSelect) {
onStepSelect(step);
}
}
break;
}
}, [nodes, setNodes, onStepDelete, onStepSelect, onStepMove, workflow?.steps]);
// Attacher/détacher les gestionnaires d'événements clavier
useEffect(() => {
const canvas = canvasRef.current;
if (canvas) {
canvas.addEventListener('keydown', handleKeyDown);
// Rendre le canvas focusable
canvas.setAttribute('tabindex', '0');
return () => {
canvas.removeEventListener('keydown', handleKeyDown);
};
}
}, [handleKeyDown]);
// Synchroniser les nœuds avec les changements du workflow - optimisé
useEffect(() => {
console.log('🔄 [Canvas] Synchronisation nœuds:', {
currentCount: nodes.length,
newCount: initialNodes.length,
workflowId: workflow?.id,
});
// Forcer la mise à jour si le nombre ou les IDs changent
const currentNodeIds = nodes.map(n => n.id).sort().join(',');
const newNodeIds = initialNodes.map(n => n.id).sort().join(',');
if (currentNodeIds !== newNodeIds || nodes.length !== initialNodes.length) {
console.log('✅ [Canvas] Mise à jour des nœuds:', initialNodes.length);
setNodes(initialNodes);
// Ajuster la vue après chargement
setTimeout(() => fitView({ padding: 0.2 }), 100);
}
}, [initialNodes, setNodes, nodes, workflow?.id, fitView]);
// Synchroniser les arêtes avec les changements du workflow - optimisé
useEffect(() => {
// Éviter les re-rendus inutiles en comparant les données
const currentEdgeIds = edges.map(e => e.id).sort().join(',');
const newEdgeIds = initialEdges.map(e => e.id).sort().join(',');
if (currentEdgeIds !== newEdgeIds || edges.length !== initialEdges.length) {
setEdges(initialEdges);
}
}, [initialEdges, setEdges, edges]);
// Gestionnaire de connexion entre étapes
const onConnect = useCallback(
(params: Connection) => {
const newEdge = {
...params,
...defaultEdgeOptions,
};
setEdges((eds) => addEdge(newEdge, eds));
if (onConnection && params.source && params.target) {
onConnection(params.source, params.target);
}
},
[onConnection, setEdges]
);
// Gestionnaire de sélection d'étape
const onNodeClick = useCallback(
(event: React.MouseEvent, node: Node) => {
if (onStepSelect) {
const nodeData = node.data as StepNodeData;
const step: Step = {
id: node.id,
type: nodeData?.stepType || 'click',
name: nodeData?.label || '',
position: node.position,
data: {
label: nodeData?.label || '',
stepType: nodeData?.stepType || 'click',
parameters: nodeData?.parameters || {},
isSelected: true,
// Préserver les flags VWB s'ils existent
...(nodeData?.isVWBCatalogAction && {
isVWBCatalogAction: true,
vwbActionId: nodeData?.vwbActionId || nodeData?.stepType,
}),
},
executionState: nodeData?.executionState,
validationErrors: nodeData?.validationErrors,
};
console.log('🖱️ [Canvas] Sélection étape:', {
id: step.id,
type: step.type,
isVWB: step.data.isVWBCatalogAction,
vwbActionId: step.data.vwbActionId
});
onStepSelect(step);
}
},
[onStepSelect]
);
// Gestionnaire de double-clic pour édition d'étape
const onNodeDoubleClick = useCallback(
(event: React.MouseEvent, node: Node) => {
console.log('Double-clic sur étape:', node.id);
// Sélectionner l'étape et ouvrir le panneau de propriétés
if (onStepSelect) {
const nodeData = node.data as StepNodeData;
const step: Step = {
id: node.id,
type: nodeData?.stepType || 'click',
name: nodeData?.label || '',
position: node.position,
data: {
label: nodeData?.label || '',
stepType: nodeData?.stepType || 'click',
parameters: nodeData?.parameters || {},
isSelected: true,
// Préserver les flags VWB s'ils existent
...(nodeData?.isVWBCatalogAction && {
isVWBCatalogAction: true,
vwbActionId: nodeData?.vwbActionId || nodeData?.stepType,
}),
},
executionState: nodeData?.executionState,
validationErrors: nodeData?.validationErrors,
};
onStepSelect(step);
}
},
[onStepSelect]
);
// Gestionnaire de déplacement d'étape
const onNodeDragStop = useCallback(
(event: React.MouseEvent, node: Node) => {
if (onStepMove) {
onStepMove(node.id, node.position);
}
},
[onStepMove]
);
// Gestionnaire de suppression de nœud
const onNodesDelete = useCallback(
(nodesToDelete: Node[]) => {
if (onStepDelete) {
nodesToDelete.forEach((node) => {
onStepDelete(node.id);
});
}
},
[onStepDelete]
);
// Gestionnaire de suppression de connexion (edge)
const onEdgesDelete = useCallback(
(edgesToDelete: Edge[]) => {
if (onConnectionDelete) {
edgesToDelete.forEach((edge) => {
onConnectionDelete(edge.id);
});
}
},
[onConnectionDelete]
);
// Liste des actions VWB connues du catalogue
const knownVWBActions = useMemo(() => [
'click_anchor', 'type_text', 'type_secret', 'wait_for_anchor',
'extract_text', 'screenshot_evidence', 'scroll_to_anchor',
'focus_anchor', 'hotkey', 'navigate_to_url', 'browser_back',
'verify_element_exists', 'verify_text_content'
], []);
// Fonction pour détecter si un type est une action VWB
const isVWBActionType = useCallback((stepType: string): boolean => {
// Vérifie si c'est un type connu du catalogue
if (knownVWBActions.includes(stepType)) return true;
// Vérifie les patterns VWB
if (stepType.startsWith('catalog:')) return true;
if (stepType.includes('_anchor')) return true;
if (stepType.includes('_text') && stepType !== 'type') return true;
if (stepType.includes('_secret')) return true;
return false;
}, [knownVWBActions]);
// Noms lisibles pour les actions VWB
const getVWBActionName = useCallback((actionId: string): string => {
const names: Record<string, string> = {
'click_anchor': 'Cliquer sur Ancre',
'type_text': 'Saisir Texte',
'type_secret': 'Saisir Texte Secret',
'wait_for_anchor': 'Attendre Ancre',
'extract_text': 'Extraire Texte',
'screenshot_evidence': 'Capture Evidence',
'scroll_to_anchor': 'Défiler vers Ancre',
'focus_anchor': 'Focaliser Ancre',
'hotkey': 'Raccourci Clavier',
'navigate_to_url': 'Naviguer vers URL',
'browser_back': 'Retour Navigateur',
'verify_element_exists': 'Vérifier Existence',
'verify_text_content': 'Vérifier Contenu Texte',
};
return names[actionId] || actionId.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}, []);
// Gestionnaire de drop depuis la palette
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
let stepType = event.dataTransfer.getData('application/reactflow');
if (!stepType || !onStepAdd) return;
// Gérer le format "catalog:action_id" de la palette
let actualType = stepType;
let isFromCatalog = false;
if (stepType.startsWith('catalog:')) {
actualType = stepType.replace('catalog:', '');
isFromCatalog = true;
}
// Obtenir la position relative au ReactFlow
const reactFlowBounds = event.currentTarget.getBoundingClientRect();
const { x: viewportX, y: viewportY, zoom } = getViewport();
// Calculer la position en tenant compte du zoom et du pan
const position = {
x: (event.clientX - reactFlowBounds.left - viewportX) / zoom,
y: (event.clientY - reactFlowBounds.top - viewportY) / zoom,
};
// Détecter si c'est une action VWB
const isVWB = isFromCatalog || isVWBActionType(actualType);
const stepName = isVWB ? getVWBActionName(actualType) : `Nouvelle étape ${actualType}`;
const newStep: Omit<Step, 'id'> = {
type: actualType as StepType,
name: stepName,
position,
data: {
label: stepName,
stepType: actualType as StepType,
parameters: {},
// Marquer les actions VWB avec les flags appropriés
...(isVWB && {
isVWBCatalogAction: true,
vwbActionId: actualType,
}),
},
};
console.log('📦 [Canvas] Création étape:', {
type: actualType,
isVWB,
isFromCatalog,
data: newStep.data
});
onStepAdd(newStep);
},
[onStepAdd, getViewport, isVWBActionType, getVWBActionName]
);
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
// Déterminer si la minimap doit être affichée
const shouldShowMinimap = nodes.length > 20;
// Message d'état vide
const EmptyState = () => (
<Box sx={emptyStateBoxStyles}>
<Paper elevation={2} sx={emptyStatePaperStyles}>
<Typography variant="h6" color="text.secondary" gutterBottom>
Espace de travail vide
</Typography>
<Typography variant="body2" color="text.secondary">
Glissez des étapes depuis la palette pour commencer à créer votre workflow
</Typography>
</Paper>
</Box>
);
return (
<Box
ref={canvasRef}
sx={canvasStyles}
onDrop={onDrop}
onDragOver={onDragOver}
role="application"
aria-label="Zone de création de workflow"
tabIndex={0}
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onNodeDragStop={onNodeDragStop}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={defaultEdgeOptions}
fitView
attributionPosition="bottom-left"
deleteKeyCode={['Backspace', 'Delete']}
multiSelectionKeyCode={['Meta', 'Ctrl']}
edgesFocusable={true}
edgesReconnectable={true}
panOnScroll
selectionOnDrag
panOnDrag={[1, 2]} // Clic milieu et droit pour panoramique
selectNodesOnDrag={false}
>
{/* Grille d'alignement */}
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#e0e0e0"
/>
{/* Contrôles de navigation */}
<Controls
position="top-left"
showZoom={true}
showFitView={true}
showInteractive={true}
style={{
backgroundColor: '#ffffff',
border: '1px solid #e0e0e0',
borderRadius: '8px',
}}
/>
{/* Minimap pour navigation dans les gros workflows */}
{shouldShowMinimap && (
<MiniMap
position="bottom-right"
zoomable
pannable
style={{
backgroundColor: '#ffffff',
border: '1px solid #e0e0e0',
borderRadius: '8px',
}}
nodeColor={(node) => {
switch (node.data?.executionState) {
case StepExecutionState.RUNNING:
return '#2196f3';
case StepExecutionState.SUCCESS:
return '#4caf50';
case StepExecutionState.ERROR:
return '#f44336';
default:
return '#9e9e9e';
}
}}
/>
)}
</ReactFlow>
{/* État vide */}
{nodes.length === 0 && <EmptyState />}
{/* Indicateur d'exécution */}
{executionState?.status === 'running' && (
<Box sx={executionIndicatorBoxStyles}>
<Paper elevation={3} sx={executionIndicatorPaperStyles}>
<Box sx={executionIndicatorAnimationStyles} />
<Typography variant="body2">
Exécution en cours...
</Typography>
</Paper>
</Box>
)}
</Box>
);
};
// Mémorisation du composant Canvas pour éviter les re-rendus inutiles
export default memo(Canvas, (prevProps, nextProps) => {
// Comparaison personnalisée pour optimiser les re-rendus
return (
prevProps.workflow?.id === nextProps.workflow?.id &&
prevProps.workflow?.steps?.length === nextProps.workflow?.steps?.length &&
prevProps.selectedStep?.id === nextProps.selectedStep?.id &&
prevProps.executionState?.status === nextProps.executionState?.status &&
JSON.stringify(prevProps.workflow?.connections) === JSON.stringify(nextProps.workflow?.connections)
);
});

View File

@@ -106,7 +106,7 @@ const CoachingSuggestionCard: React.FC<CoachingSuggestionCardProps> = ({
{suggestion.screenshotPath && (
<div className="suggestion-screenshot">
<img
src={`http://localhost:5000${suggestion.screenshotPath}`}
src={`http://localhost:5001${suggestion.screenshotPath}`}
alt="Target element"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';

View File

@@ -143,7 +143,7 @@ export const CoachingPanel: React.FC<CoachingPanelProps> = ({
const startSession = useCallback(
async (wfId: string) => {
try {
const response = await fetch(`${serverUrl || 'http://localhost:5000'}/api/executions/coaching`, {
const response = await fetch(`${serverUrl || 'http://localhost:5001'}/api/executions/coaching`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow_id: wfId }),

View File

@@ -0,0 +1,220 @@
/**
* Indicateur de Connexion API - Affichage stable de l'état de connexion
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
*
* Ce composant affiche l'état de connexion de l'API de manière non-intrusive
* et stable, sans provoquer de "sauts" de page.
*/
import React, { memo, useState, useCallback } from 'react';
import {
Box,
Chip,
Tooltip,
IconButton,
Snackbar,
Alert,
} from '@mui/material';
import {
CloudDone as OnlineIcon,
CloudOff as OfflineIcon,
Refresh as RefreshIcon,
Sync as CheckingIcon,
} from '@mui/icons-material';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
interface ConnectionIndicatorProps {
/** Afficher en mode compact (icône seule) */
compact?: boolean;
/** Position de l'indicateur */
position?: 'inline' | 'fixed';
/** Afficher le bouton de rafraîchissement */
showRefreshButton?: boolean;
/** Callback quand l'état change */
onStatusChange?: (isOnline: boolean) => void;
}
/**
* Composant d'indicateur de connexion API
* Mémorisé pour éviter les re-rendus inutiles
*/
const ConnectionIndicator: React.FC<ConnectionIndicatorProps> = memo(({
compact = false,
position = 'inline',
showRefreshButton = true,
onStatusChange,
}) => {
const { status, isOnline, isOffline, isChecking, statusMessage, forceCheck } = useConnectionStatus({
onStatusChange: (newStatus) => {
if (onStatusChange) {
onStatusChange(newStatus === 'online');
}
},
});
const [isRefreshing, setIsRefreshing] = useState(false);
const [showNotification, setShowNotification] = useState(false);
const [notificationMessage, setNotificationMessage] = useState('');
// Gestionnaire de rafraîchissement
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
try {
const result = await forceCheck();
setNotificationMessage(result ? 'Connexion rétablie' : 'API toujours hors ligne');
setShowNotification(true);
} catch (error) {
setNotificationMessage('Erreur lors de la vérification');
setShowNotification(true);
} finally {
setIsRefreshing(false);
}
}, [forceCheck]);
// Fermer la notification
const handleCloseNotification = useCallback(() => {
setShowNotification(false);
}, []);
// Déterminer l'icône et la couleur
const getIconAndColor = () => {
if (isChecking || isRefreshing) {
return {
icon: <CheckingIcon sx={{ animation: 'spin 1s linear infinite' }} />,
color: 'default' as const,
label: 'Vérification...',
};
}
if (isOnline) {
return {
icon: <OnlineIcon />,
color: 'success' as const,
label: 'API connectée',
};
}
return {
icon: <OfflineIcon />,
color: 'warning' as const,
label: 'Mode hors ligne',
};
};
const { icon, color, label } = getIconAndColor();
// Style pour l'animation de rotation
const spinKeyframes = `
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
// Rendu compact (icône seule)
if (compact) {
return (
<>
<style>{spinKeyframes}</style>
<Tooltip title={statusMessage} arrow>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
}}
>
<Chip
icon={icon}
size="small"
color={color}
variant="outlined"
sx={{
minWidth: 'auto',
'& .MuiChip-label': { display: 'none' },
'& .MuiChip-icon': { margin: 0 },
}}
/>
{showRefreshButton && isOffline && (
<IconButton
size="small"
onClick={handleRefresh}
disabled={isRefreshing}
sx={{ padding: 0.5 }}
>
<RefreshIcon fontSize="small" />
</IconButton>
)}
</Box>
</Tooltip>
</>
);
}
// Rendu complet
return (
<>
<style>{spinKeyframes}</style>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
...(position === 'fixed' && {
position: 'fixed',
bottom: 16,
right: 16,
zIndex: 1000,
}),
}}
>
<Tooltip title={`${statusMessage} - Cliquer pour rafraîchir`} arrow>
<Chip
icon={icon}
label={label}
size="small"
color={color}
variant="outlined"
onClick={handleRefresh}
sx={{ cursor: 'pointer' }}
/>
</Tooltip>
{showRefreshButton && (
<Tooltip title="Vérifier la connexion" arrow>
<IconButton
size="small"
onClick={handleRefresh}
disabled={isRefreshing}
color="primary"
>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{/* Notification de changement d'état */}
<Snackbar
open={showNotification}
autoHideDuration={3000}
onClose={handleCloseNotification}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={handleCloseNotification}
severity={isOnline ? 'success' : 'warning'}
variant="filled"
sx={{ width: '100%' }}
>
{notificationMessage}
</Alert>
</Snackbar>
</>
);
});
ConnectionIndicator.displayName = 'ConnectionIndicator';
export default ConnectionIndicator;

View File

@@ -0,0 +1,448 @@
/**
* Composant d'Aide Contextuelle - Assistance intelligente en français
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant fournit une aide contextuelle intelligente qui s'adapte
* à l'action en cours de l'utilisateur et propose des conseils pertinents.
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Paper,
Typography,
IconButton,
Collapse,
List,
ListItem,
ListItemIcon,
ListItemText,
Chip,
Button,
Divider,
} from '@mui/material';
import {
Help as HelpIcon,
Close as CloseIcon,
Lightbulb as LightbulbIcon,
Info as InfoIcon,
Warning as WarningIcon,
CheckCircle as CheckCircleIcon,
KeyboardArrowUp as ArrowUpIcon,
KeyboardArrowDown as ArrowDownIcon,
} from '@mui/icons-material';
interface ContextualHelpProps {
context: 'canvas' | 'palette' | 'properties' | 'validation' | 'execution' | 'variables';
selectedStepType?: string;
currentErrors?: string[];
isVisible?: boolean;
onClose?: () => void;
}
interface HelpTip {
id: string;
type: 'tip' | 'info' | 'warning' | 'success';
title: string;
content: string;
action?: {
label: string;
onClick: () => void;
};
}
// Conseils contextuels par zone de l'interface
const contextualTips: Record<string, HelpTip[]> = {
canvas: [
{
id: 'canvas_basics',
type: 'tip',
title: 'Premiers pas sur le canvas',
content: 'Glissez des étapes depuis la palette vers cette zone pour construire votre workflow. Utilisez la molette pour zoomer et cliquez-glissez pour vous déplacer.'
},
{
id: 'canvas_connections',
type: 'info',
title: 'Connecter les étapes',
content: 'Cliquez sur le point de sortie d\'une étape (cercle à droite) et glissez vers le point d\'entrée d\'une autre étape pour les connecter.'
},
{
id: 'canvas_selection',
type: 'tip',
title: 'Sélection multiple',
content: 'Maintenez Ctrl et cliquez sur plusieurs étapes pour les sélectionner. Vous pouvez ensuite les déplacer ensemble ou les supprimer.'
}
],
palette: [
{
id: 'palette_categories',
type: 'info',
title: 'Organisation par catégories',
content: 'Les étapes sont organisées en catégories logiques : Actions Web pour interagir avec les pages, Logique pour les conditions, etc.'
},
{
id: 'palette_search',
type: 'tip',
title: 'Recherche rapide',
content: 'Utilisez le champ de recherche pour trouver rapidement une étape par son nom ou sa description.'
},
{
id: 'palette_drag',
type: 'tip',
title: 'Glisser-déposer',
content: 'Glissez une étape depuis la palette vers le canvas pour l\'ajouter à votre workflow. L\'étape apparaîtra à l\'endroit où vous la relâchez.'
}
],
properties: [
{
id: 'properties_required',
type: 'warning',
title: 'Paramètres obligatoires',
content: 'Les paramètres marqués d\'un astérisque (*) sont obligatoires. Le workflow ne pourra pas s\'exécuter sans eux.'
},
{
id: 'properties_variables',
type: 'tip',
title: 'Utilisation des variables',
content: 'Vous pouvez utiliser des variables dans les champs texte avec la syntaxe ${nom_variable}. Cela rend vos workflows réutilisables.'
},
{
id: 'properties_visual',
type: 'info',
title: 'Sélection visuelle',
content: 'Pour les paramètres "Élément cible", utilisez le bouton "Sélectionner un élément" pour choisir visuellement sur une capture d\'écran.'
}
],
validation: [
{
id: 'validation_errors',
type: 'warning',
title: 'Correction des erreurs',
content: 'Les erreurs en rouge empêchent l\'exécution. Cliquez sur une erreur pour aller directement à l\'étape concernée.'
},
{
id: 'validation_warnings',
type: 'info',
title: 'Avertissements',
content: 'Les avertissements en orange n\'empêchent pas l\'exécution mais peuvent indiquer des problèmes potentiels.'
},
{
id: 'validation_cycles',
type: 'warning',
title: 'Éviter les boucles infinies',
content: 'Vérifiez que vos connexions ne créent pas de cycles (A → B → A) qui empêcheraient l\'exécution normale.'
}
],
execution: [
{
id: 'execution_states',
type: 'info',
title: 'États d\'exécution',
content: 'Pendant l\'exécution, les étapes changent de couleur : bleu (en cours), vert (réussie), rouge (échouée).'
},
{
id: 'execution_pause',
type: 'tip',
title: 'Contrôle de l\'exécution',
content: 'Vous pouvez mettre en pause, arrêter ou redémarrer l\'exécution à tout moment avec les boutons de contrôle.'
},
{
id: 'execution_debug',
type: 'tip',
title: 'Débogage',
content: 'En cas d\'erreur, consultez les détails dans le panneau d\'exécution pour comprendre ce qui s\'est passé.'
}
],
variables: [
{
id: 'variables_naming',
type: 'tip',
title: 'Nommage des variables',
content: 'Utilisez des noms descriptifs pour vos variables : "nom_utilisateur" plutôt que "var1". Évitez les espaces et caractères spéciaux.'
},
{
id: 'variables_types',
type: 'info',
title: 'Types de variables',
content: 'Définissez le bon type pour chaque variable (texte, nombre, booléen) pour éviter les erreurs de validation.'
},
{
id: 'variables_scope',
type: 'tip',
title: 'Portée des variables',
content: 'Les variables sont disponibles dans tout le workflow une fois créées. Elles peuvent être modifiées par les étapes d\'extraction.'
}
]
};
// Conseils spécifiques par type d'étape
const stepSpecificTips: Record<string, HelpTip[]> = {
click: [
{
id: 'click_types',
type: 'info',
title: 'Types de clic',
content: 'Utilisez le clic gauche pour la plupart des actions, le clic droit pour les menus contextuels, et le double-clic pour les sélections.'
},
{
id: 'click_timing',
type: 'tip',
title: 'Timing des clics',
content: 'Si un clic ne fonctionne pas, ajoutez une étape d\'attente avant pour laisser le temps à la page de se charger.'
}
],
type: [
{
id: 'type_clear',
type: 'tip',
title: 'Vider avant saisie',
content: 'Activez "Vider le champ d\'abord" si vous voulez remplacer le contenu existant plutôt que l\'ajouter.'
},
{
id: 'type_variables',
type: 'info',
title: 'Texte dynamique',
content: 'Utilisez ${nom_variable} pour insérer des valeurs dynamiques dans le texte à saisir.'
}
],
wait: [
{
id: 'wait_duration',
type: 'tip',
title: 'Durée d\'attente',
content: 'Utilisez des durées courtes (0.5-2 secondes) pour les interactions rapides, plus longues (3-10 secondes) pour les chargements de page.'
}
],
condition: [
{
id: 'condition_operators',
type: 'info',
title: 'Opérateurs disponibles',
content: 'Utilisez ==, !=, >, <, >=, <= pour comparer des valeurs. Exemple : ${age} >= 18 ou ${status} == "actif".'
}
]
};
/**
* Composant d'Aide Contextuelle
*/
const ContextualHelp: React.FC<ContextualHelpProps> = ({
context,
selectedStepType,
currentErrors = [],
isVisible = true,
onClose,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [currentTips, setCurrentTips] = useState<HelpTip[]>([]);
// Gestionnaire d'événements clavier pour l'aide contextuelle
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'h':
// Raccourci pour basculer l'aide (Ctrl+H)
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
setIsExpanded(prev => !prev);
}
break;
case 'Escape':
// Fermer l'aide
event.preventDefault();
setIsExpanded(false);
if (onClose) onClose();
break;
case '?':
// Raccourci pour ouvrir l'aide
event.preventDefault();
setIsExpanded(true);
break;
}
}, [onClose]);
// Mettre à jour les conseils selon le contexte
useEffect(() => {
let tips: HelpTip[] = [];
// Ajouter les conseils contextuels
if (contextualTips[context]) {
tips.push(...contextualTips[context]);
}
// Ajouter les conseils spécifiques à l'étape sélectionnée
if (selectedStepType && stepSpecificTips[selectedStepType]) {
tips.push(...stepSpecificTips[selectedStepType]);
}
// Ajouter des conseils basés sur les erreurs actuelles
if (currentErrors.length > 0) {
tips.unshift({
id: 'current_errors',
type: 'warning',
title: `${currentErrors.length} erreur(s) détectée(s)`,
content: 'Corrigez les erreurs signalées en rouge pour pouvoir exécuter votre workflow.'
});
}
setCurrentTips(tips);
}, [context, selectedStepType, currentErrors]);
// Obtenir l'icône selon le type de conseil
const getTipIcon = (type: HelpTip['type']) => {
switch (type) {
case 'tip':
return <LightbulbIcon color="primary" />;
case 'info':
return <InfoIcon color="info" />;
case 'warning':
return <WarningIcon color="warning" />;
case 'success':
return <CheckCircleIcon color="success" />;
default:
return <HelpIcon />;
}
};
// Obtenir la couleur selon le type
const getTipColor = (type: HelpTip['type']) => {
switch (type) {
case 'tip':
return 'primary';
case 'info':
return 'info';
case 'warning':
return 'warning';
case 'success':
return 'success';
default:
return 'default';
}
};
if (!isVisible || currentTips.length === 0) {
return null;
}
return (
<Paper
elevation={3}
sx={{
position: 'fixed',
bottom: 20,
right: 20,
width: 320,
maxHeight: 400,
zIndex: 1000,
overflow: 'hidden',
}}
>
{/* En-tête */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 2,
backgroundColor: 'primary.main',
color: 'primary.contrastText',
cursor: 'pointer',
}}
onClick={() => setIsExpanded(!isExpanded)}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<HelpIcon />
<Typography variant="subtitle2">
Aide contextuelle
</Typography>
<Chip
label={currentTips.length}
size="small"
sx={{
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'inherit',
}}
/>
</Box>
<Box>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
sx={{ color: 'inherit', mr: 1 }}
>
{isExpanded ? <ArrowDownIcon /> : <ArrowUpIcon />}
</IconButton>
{onClose && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
sx={{ color: 'inherit' }}
>
<CloseIcon />
</IconButton>
)}
</Box>
</Box>
{/* Contenu */}
<Collapse in={isExpanded}>
<Box
sx={{ maxHeight: 300, overflow: 'auto' }}
onKeyDown={handleKeyDown}
tabIndex={0}
>
<List dense>
{currentTips.map((tip, index) => (
<React.Fragment key={tip.id}>
<ListItem alignItems="flex-start">
<ListItemIcon sx={{ mt: 0.5 }}>
{getTipIcon(tip.type)}
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2">
{tip.title}
</Typography>
<Chip
label={tip.type === 'tip' ? 'Conseil' : tip.type === 'info' ? 'Info' : tip.type === 'warning' ? 'Attention' : 'Succès'}
size="small"
color={getTipColor(tip.type) as any}
variant="outlined"
/>
</Box>
}
secondary={
<Box>
<Typography variant="body2" color="text.secondary">
{tip.content}
</Typography>
{tip.action && (
<Button
size="small"
onClick={tip.action.onClick}
sx={{ mt: 1 }}
>
{tip.action.label}
</Button>
)}
</Box>
}
/>
</ListItem>
{index < currentTips.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
</Box>
</Collapse>
</Paper>
);
};
export default ContextualHelp;

View File

@@ -0,0 +1,359 @@
/**
* Styles CSS pour le composant DebugPanel
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*/
.debug-panel {
position: fixed;
top: 20px;
right: 20px;
width: 400px;
max-height: calc(100vh - 40px);
background-color: white;
border: 2px solid #1976d2;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 9999;
overflow: hidden;
display: flex;
flex-direction: column;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.debug-panel-header {
padding: 16px;
background-color: #1976d2;
color: white;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #1565c0;
}
.debug-panel-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
margin: 0;
}
.debug-panel-controls {
display: flex;
align-items: center;
gap: 8px;
}
.debug-panel-content {
flex: 1;
overflow: auto;
padding: 8px;
}
.debug-panel-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
background-color: #1976d2;
color: white;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
}
.debug-panel-toggle:hover {
background-color: #1565c0;
transform: scale(1.05);
}
.debug-section {
margin-bottom: 16px;
border: 1px solid #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
.debug-section-header {
padding: 12px 16px;
background-color: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
transition: background-color 0.2s ease;
}
.debug-section-header:hover {
background-color: #eeeeee;
}
.debug-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
margin: 0;
}
.debug-section-content {
padding: 16px;
background-color: white;
}
.debug-info-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.debug-info-table th,
.debug-info-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.debug-info-table th {
background-color: #f5f5f5;
font-weight: 600;
}
.debug-info-table tr:last-child td {
border-bottom: none;
}
.debug-chip {
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
text-align: center;
white-space: nowrap;
}
.debug-chip-success {
background-color: #e8f5e8;
color: #2e7d32;
border: 1px solid #c8e6c9;
}
.debug-chip-error {
background-color: #ffebee;
color: #c62828;
border: 1px solid #ffcdd2;
}
.debug-chip-warning {
background-color: #fff3e0;
color: #ef6c00;
border: 1px solid #ffcc02;
}
.debug-chip-info {
background-color: #e3f2fd;
color: #1976d2;
border: 1px solid #bbdefb;
}
.debug-chip-default {
background-color: #f5f5f5;
color: #616161;
border: 1px solid #e0e0e0;
}
.debug-detection-methods {
display: flex;
flex-direction: column;
gap: 8px;
}
.debug-detection-method {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.debug-detection-icon {
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
}
.debug-detection-icon-success {
background-color: #4caf50;
color: white;
}
.debug-detection-icon-disabled {
background-color: #bdbdbd;
color: white;
}
.debug-parameters-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.debug-parameter-chip {
padding: 2px 6px;
background-color: #e3f2fd;
color: #1976d2;
border: 1px solid #bbdefb;
border-radius: 8px;
font-size: 10px;
font-weight: 500;
}
.debug-alert {
padding: 12px;
border-radius: 4px;
margin: 8px 0;
font-size: 12px;
line-height: 1.4;
}
.debug-alert-info {
background-color: #e3f2fd;
color: #1976d2;
border: 1px solid #bbdefb;
}
.debug-alert-warning {
background-color: #fff3e0;
color: #ef6c00;
border: 1px solid #ffcc02;
}
.debug-alert-error {
background-color: #ffebee;
color: #c62828;
border: 1px solid #ffcdd2;
}
.debug-alert-success {
background-color: #e8f5e8;
color: #2e7d32;
border: 1px solid #c8e6c9;
}
.debug-auto-refresh {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: white;
}
.debug-auto-refresh input[type="checkbox"] {
margin: 0;
}
/* Animations */
@keyframes debugPanelSlideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes debugPanelSlideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
.debug-panel-enter {
animation: debugPanelSlideIn 0.3s ease-out;
}
.debug-panel-exit {
animation: debugPanelSlideOut 0.3s ease-in;
}
/* Responsive */
@media (max-width: 768px) {
.debug-panel {
width: calc(100vw - 40px);
max-width: 400px;
}
.debug-panel-toggle {
width: 48px;
height: 48px;
}
}
/* Scrollbar personnalisée */
.debug-panel-content::-webkit-scrollbar {
width: 6px;
}
.debug-panel-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.debug-panel-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.debug-panel-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Mode sombre (optionnel) */
@media (prefers-color-scheme: dark) {
.debug-panel {
background-color: #2d2d2d;
border-color: #1976d2;
color: #ffffff;
}
.debug-section-header {
background-color: #3d3d3d;
border-bottom-color: #4d4d4d;
}
.debug-section-content {
background-color: #2d2d2d;
}
.debug-info-table th {
background-color: #3d3d3d;
}
.debug-info-table th,
.debug-info-table td {
border-bottom-color: #4d4d4d;
}
}

View File

@@ -0,0 +1,547 @@
/**
* Composant DebugPanel - Visualisation des données d'étapes en temps réel
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant affiche des informations de débogage détaillées pour les étapes
* sélectionnées, permettant de diagnostiquer les problèmes de propriétés.
*/
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Alert,
Switch,
FormControlLabel,
Divider,
IconButton,
Tooltip,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
BugReport as BugReportIcon,
Visibility as VisibilityIcon,
Code as CodeIcon,
Settings as SettingsIcon,
Warning as WarningIcon,
CheckCircle as CheckCircleIcon,
Error as ErrorIcon,
} from '@mui/icons-material';
// Import des hooks d'intégration VWB
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../../hooks/useVWBStepIntegration';
// Import des types
import { Step, StepType, Variable } from '../../types';
interface DebugPanelProps {
selectedStep: Step | null;
variables: Variable[];
isVisible?: boolean;
onToggleVisibility?: (visible: boolean) => void;
}
interface StepAnalysis {
stepInfo: {
id: string;
name: string;
type: string;
hasData: boolean;
dataKeys: string[];
};
typeAnalysis: {
isStandardType: boolean;
isVWBAction: boolean;
hasConfiguration: boolean;
configurationSource: string;
};
vwbAnalysis: {
isVWBCatalogAction: boolean;
hasVWBActionId: boolean;
vwbActionId: string | null;
detectionMethods: Record<string, boolean>;
};
parametersAnalysis: {
hasParameters: boolean;
parameterCount: number;
parameterKeys: string[];
missingRequired: string[];
};
validationAnalysis: {
hasErrors: boolean;
errorCount: number;
warningCount: number;
errors: Array<{ parameter: string; message: string; severity: string }>;
};
}
/**
* Composant DebugPanel
*/
const DebugPanel: React.FC<DebugPanelProps> = ({
selectedStep,
variables,
isVisible = false,
onToggleVisibility,
}) => {
const [expanded, setExpanded] = useState<string[]>(['stepInfo']);
const [autoRefresh, setAutoRefresh] = useState(true);
// Utilisation des hooks d'intégration VWB
const { methods: vwbMethods } = useVWBStepIntegration();
const isVWBStep = useIsVWBStep(selectedStep);
const vwbActionId = useVWBActionId(selectedStep);
// Configuration des paramètres par type d'étape (copie locale pour analyse)
const stepParametersConfig: Record<string, any> = useMemo(() => ({
click: [
{ name: 'target', type: 'visual', required: true },
{ name: 'clickType', type: 'select', defaultValue: 'left' },
],
type: [
{ name: 'target', type: 'visual', required: true },
{ name: 'text', type: 'text', required: true },
{ name: 'clearFirst', type: 'boolean', defaultValue: true },
],
wait: [
{ name: 'duration', type: 'number', required: true, min: 0.1, max: 60, defaultValue: 1 },
],
condition: [
{ name: 'condition', type: 'text', required: true },
],
extract: [
{ name: 'target', type: 'visual', required: true },
{ name: 'attribute', type: 'select', defaultValue: 'text' },
],
scroll: [
{ name: 'direction', type: 'select', defaultValue: 'down' },
{ name: 'amount', type: 'number', defaultValue: 300, min: 1 },
],
navigate: [
{ name: 'url', type: 'text', required: true },
],
screenshot: [
{ name: 'filename', type: 'text' },
],
}), []);
// Analyse complète de l'étape sélectionnée
const stepAnalysis: StepAnalysis | null = useMemo(() => {
if (!selectedStep) return null;
// Analyse des informations de base
const stepInfo = {
id: selectedStep.id,
name: selectedStep.name || 'Sans nom',
type: selectedStep.type as string,
hasData: Boolean(selectedStep.data),
dataKeys: selectedStep.data ? Object.keys(selectedStep.data) : [],
};
// Analyse du type d'étape
const stepTypeString = selectedStep.type as string;
const isStandardType = stepTypeString in stepParametersConfig;
const hasConfiguration = isStandardType || Boolean(selectedStep.data?.isVWBCatalogAction);
// Méthodes de détection VWB
const detectionMethods = {
hasVWBFlag: Boolean(selectedStep.data?.isVWBCatalogAction),
hasVWBActionId: Boolean(selectedStep.data?.vwbActionId),
typeStartsWithVWB: stepTypeString.startsWith('vwb_'),
typeContainsAnchor: stepTypeString.includes('_anchor'),
typeContainsText: stepTypeString.includes('_text'),
typeContainsSecret: stepTypeString.includes('_secret'),
isKnownVWBAction: [
'click_anchor', 'type_text', 'type_secret', 'wait_for_anchor',
'extract_text', 'screenshot_evidence', 'scroll_to_anchor',
'focus_anchor', 'hotkey', 'navigate_to_url', 'browser_back',
'verify_element_exists', 'verify_text_content'
].includes(stepTypeString),
useIsVWBStepHook: isVWBStep,
};
const isVWBAction = Object.values(detectionMethods).some(method => method);
const typeAnalysis = {
isStandardType,
isVWBAction,
hasConfiguration,
configurationSource: isVWBAction ? 'VWBActionProperties' : isStandardType ? 'stepParametersConfig' : 'Aucune',
};
const vwbAnalysis = {
isVWBCatalogAction: Boolean(selectedStep.data?.isVWBCatalogAction),
hasVWBActionId: Boolean(selectedStep.data?.vwbActionId),
vwbActionId: selectedStep.data?.vwbActionId || null,
detectionMethods,
};
// Analyse des paramètres
const parameters = selectedStep.data?.parameters || {};
const parameterKeys = Object.keys(parameters);
const expectedConfig = stepParametersConfig[stepTypeString] || [];
const requiredParams = expectedConfig.filter((p: any) => p.required).map((p: any) => p.name);
const missingRequired = requiredParams.filter((param: string) => !(param in parameters));
const parametersAnalysis = {
hasParameters: parameterKeys.length > 0,
parameterCount: parameterKeys.length,
parameterKeys,
missingRequired,
};
// Analyse de validation
const validationErrors = selectedStep.validationErrors || [];
const errors = validationErrors.map(error => ({
parameter: error.parameter || 'Général',
message: error.message,
severity: error.severity || 'error',
}));
const validationAnalysis = {
hasErrors: validationErrors.length > 0,
errorCount: errors.filter(e => e.severity === 'error').length,
warningCount: errors.filter(e => e.severity === 'warning').length,
errors,
};
return {
stepInfo,
typeAnalysis,
vwbAnalysis,
parametersAnalysis,
validationAnalysis,
};
}, [selectedStep, stepParametersConfig, isVWBStep]);
// Gestion de l'expansion des accordéons
const handleAccordionChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
setExpanded(prev =>
isExpanded
? [...prev, panel]
: prev.filter(p => p !== panel)
);
};
// Auto-refresh des données
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(() => {
// Force un re-render pour mettre à jour les données en temps réel
// (les données sont déjà réactives via les props)
}, 1000);
return () => clearInterval(interval);
}, [autoRefresh]);
if (!isVisible) {
return (
<Box sx={{ position: 'fixed', top: 20, right: 20, zIndex: 9999 }}>
<Tooltip title="Ouvrir le panneau de débogage">
<IconButton
onClick={() => onToggleVisibility?.(true)}
sx={{
backgroundColor: 'primary.main',
color: 'white',
'&:hover': { backgroundColor: 'primary.dark' },
}}
>
<BugReportIcon />
</IconButton>
</Tooltip>
</Box>
);
}
return (
<Box
sx={{
position: 'fixed',
top: 20,
right: 20,
width: 400,
maxHeight: 'calc(100vh - 40px)',
backgroundColor: 'background.paper',
border: '2px solid',
borderColor: 'primary.main',
borderRadius: 2,
boxShadow: 3,
zIndex: 9999,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{/* En-tête */}
<Box
sx={{
p: 2,
backgroundColor: 'primary.main',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<BugReportIcon />
<Typography variant="h6">Debug Panel</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControlLabel
control={
<Switch
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
size="small"
sx={{ color: 'white' }}
/>
}
label="Auto"
sx={{ color: 'white', m: 0 }}
/>
<IconButton
onClick={() => onToggleVisibility?.(false)}
sx={{ color: 'white' }}
size="small"
>
<VisibilityIcon />
</IconButton>
</Box>
</Box>
{/* Contenu */}
<Box sx={{ flex: 1, overflow: 'auto', p: 1 }}>
{!selectedStep ? (
<Alert severity="info" sx={{ m: 1 }}>
Aucune étape sélectionnée
</Alert>
) : !stepAnalysis ? (
<Alert severity="error" sx={{ m: 1 }}>
Erreur d'analyse de l'étape
</Alert>
) : (
<>
{/* Informations de base */}
<Accordion
expanded={expanded.includes('stepInfo')}
onChange={handleAccordionChange('stepInfo')}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SettingsIcon fontSize="small" />
<Typography variant="subtitle2">Informations de base</Typography>
<Chip
label={stepAnalysis.stepInfo.type}
size="small"
color="primary"
variant="outlined"
/>
</Box>
</AccordionSummary>
<AccordionDetails>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableBody>
<TableRow>
<TableCell><strong>ID</strong></TableCell>
<TableCell>{stepAnalysis.stepInfo.id}</TableCell>
</TableRow>
<TableRow>
<TableCell><strong>Nom</strong></TableCell>
<TableCell>{stepAnalysis.stepInfo.name}</TableCell>
</TableRow>
<TableRow>
<TableCell><strong>Type</strong></TableCell>
<TableCell>{stepAnalysis.stepInfo.type}</TableCell>
</TableRow>
<TableRow>
<TableCell><strong>Données</strong></TableCell>
<TableCell>
{stepAnalysis.stepInfo.hasData ? (
<Chip label={`${stepAnalysis.stepInfo.dataKeys.length} clés`} size="small" color="success" />
) : (
<Chip label="Aucune" size="small" color="default" />
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</AccordionDetails>
</Accordion>
{/* Analyse du type */}
<Accordion
expanded={expanded.includes('typeAnalysis')}
onChange={handleAccordionChange('typeAnalysis')}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CodeIcon fontSize="small" />
<Typography variant="subtitle2">Analyse du type</Typography>
{stepAnalysis.typeAnalysis.hasConfiguration ? (
<CheckCircleIcon fontSize="small" color="success" />
) : (
<ErrorIcon fontSize="small" color="error" />
)}
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={stepAnalysis.typeAnalysis.isStandardType ? 'Type Standard' : 'Type Non-Standard'}
size="small"
color={stepAnalysis.typeAnalysis.isStandardType ? 'success' : 'default'}
/>
<Chip
label={stepAnalysis.typeAnalysis.isVWBAction ? 'Action VWB' : 'Non-VWB'}
size="small"
color={stepAnalysis.typeAnalysis.isVWBAction ? 'primary' : 'default'}
/>
</Box>
<Typography variant="body2">
<strong>Source de configuration :</strong> {stepAnalysis.typeAnalysis.configurationSource}
</Typography>
</Box>
</AccordionDetails>
</Accordion>
{/* Analyse VWB */}
<Accordion
expanded={expanded.includes('vwbAnalysis')}
onChange={handleAccordionChange('vwbAnalysis')}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<BugReportIcon fontSize="small" />
<Typography variant="subtitle2">Analyse VWB</Typography>
<Chip
label={`${Object.values(stepAnalysis.vwbAnalysis.detectionMethods).filter(Boolean).length}/8`}
size="small"
color="info"
/>
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{stepAnalysis.vwbAnalysis.vwbActionId && (
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
<strong>Action ID :</strong> {stepAnalysis.vwbAnalysis.vwbActionId}
</Alert>
)}
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
Méthodes de détection :
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{Object.entries(stepAnalysis.vwbAnalysis.detectionMethods).map(([method, detected]) => (
<Box key={method} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{detected ? (
<CheckCircleIcon fontSize="small" color="success" />
) : (
<ErrorIcon fontSize="small" color="disabled" />
)}
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
{method}
</Typography>
</Box>
))}
</Box>
</Box>
</AccordionDetails>
</Accordion>
{/* Analyse des paramètres */}
<Accordion
expanded={expanded.includes('parametersAnalysis')}
onChange={handleAccordionChange('parametersAnalysis')}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SettingsIcon fontSize="small" />
<Typography variant="subtitle2">Paramètres</Typography>
<Chip
label={stepAnalysis.parametersAnalysis.parameterCount}
size="small"
color={stepAnalysis.parametersAnalysis.hasParameters ? 'success' : 'default'}
/>
{stepAnalysis.parametersAnalysis.missingRequired.length > 0 && (
<WarningIcon fontSize="small" color="warning" />
)}
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{stepAnalysis.parametersAnalysis.hasParameters ? (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{stepAnalysis.parametersAnalysis.parameterKeys.map(key => (
<Chip key={key} label={key} size="small" variant="outlined" />
))}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Aucun paramètre configuré
</Typography>
)}
{stepAnalysis.parametersAnalysis.missingRequired.length > 0 && (
<Alert severity="warning" sx={{ fontSize: '0.8rem' }}>
<strong>Paramètres requis manquants :</strong>{' '}
{stepAnalysis.parametersAnalysis.missingRequired.join(', ')}
</Alert>
)}
</Box>
</AccordionDetails>
</Accordion>
{/* Analyse de validation */}
{stepAnalysis.validationAnalysis.hasErrors && (
<Accordion
expanded={expanded.includes('validationAnalysis')}
onChange={handleAccordionChange('validationAnalysis')}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningIcon fontSize="small" />
<Typography variant="subtitle2">Validation</Typography>
<Chip
label={`${stepAnalysis.validationAnalysis.errorCount} erreurs`}
size="small"
color="error"
/>
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{stepAnalysis.validationAnalysis.errors.map((error, index) => (
<Alert key={index} severity={error.severity as any} sx={{ fontSize: '0.8rem' }}>
<strong>{error.parameter} :</strong> {error.message}
</Alert>
))}
</Box>
</AccordionDetails>
</Accordion>
)}
</>
)}
</Box>
</Box>
);
};
export default DebugPanel;

View File

@@ -0,0 +1,592 @@
/**
* Composant Onglet de Documentation - Documentation interactive pour chaque outil
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant affiche la documentation contextuelle avec guides étape par étape,
* exemples visuels et exemples interactifs en français.
*/
import React, { useState, useCallback } from 'react';
import {
Box,
Typography,
Tabs,
Tab,
Card,
CardContent,
Button,
Stepper,
Step,
StepLabel,
StepContent,
Alert,
Chip,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import {
Help as HelpIcon,
PlayArrow as PlayIcon,
ExpandMore as ExpandMoreIcon,
Lightbulb as TipIcon,
Warning as WarningIcon,
CheckCircle as CheckIcon,
} from '@mui/icons-material';
// Import du composant Glossaire
import Glossary from '../Glossary';
// Import des types partagés
import { DocumentationTabProps } from '../../types';
interface DocumentationContent {
title: string;
sections: DocumentationSection[];
examples: InteractiveExample[];
glossary: GlossaryTerm[];
}
interface DocumentationSection {
id: string;
title: string;
content: string;
steps?: DocumentationStep[];
tips?: string[];
warnings?: string[];
}
interface DocumentationStep {
title: string;
description: string;
image?: string;
action?: () => void;
}
interface InteractiveExample {
id: string;
title: string;
description: string;
steps: string[];
onTry: () => void;
}
interface GlossaryTerm {
term: string;
definition: string;
}
// Contenu de documentation par outil
const documentationContent: Record<string, DocumentationContent> = {
canvas: {
title: 'Canvas - Espace de travail',
sections: [
{
id: 'overview',
title: 'Vue d\'ensemble',
content: 'Le Canvas est votre espace de travail principal pour créer des workflows visuels. Vous pouvez y glisser des étapes depuis la palette, les connecter et les organiser.',
steps: [
{
title: 'Glisser une étape',
description: 'Glissez une étape depuis la palette vers le canvas',
action: () => {
// Déclencher un tutoriel interactif pour le drag-and-drop
window.dispatchEvent(new CustomEvent('vwb:tutorial:dragDrop'));
},
},
{
title: 'Connecter les étapes',
description: 'Cliquez et glissez depuis le point de connexion d\'une étape vers une autre',
action: () => {
// Déclencher un tutoriel interactif pour les connexions
window.dispatchEvent(new CustomEvent('vwb:tutorial:connect'));
},
},
{
title: 'Sélectionner une étape',
description: 'Cliquez sur une étape pour la sélectionner et voir ses propriétés',
action: () => {
// Déclencher un tutoriel interactif pour la sélection
window.dispatchEvent(new CustomEvent('vwb:tutorial:select'));
},
},
],
tips: [
'Utilisez la grille pour aligner vos étapes proprement',
'La minimap apparaît automatiquement pour les gros workflows',
'Utilisez Ctrl+Z pour annuler une action',
'Double-cliquez sur une étape pour l\'éditer rapidement',
'Utilisez Shift+clic pour sélectionner plusieurs étapes',
],
},
{
id: 'navigation',
title: 'Navigation',
content: 'Le canvas offre plusieurs outils de navigation pour travailler efficacement avec vos workflows.',
steps: [
{
title: 'Zoom',
description: 'Utilisez la molette de la souris ou les contrôles pour zoomer',
action: () => {
// Déclencher un tutoriel interactif pour le zoom
window.dispatchEvent(new CustomEvent('vwb:tutorial:zoom'));
},
},
{
title: 'Panoramique',
description: 'Maintenez le clic droit et glissez pour déplacer la vue',
action: () => {
// Déclencher un tutoriel interactif pour le panoramique
window.dispatchEvent(new CustomEvent('vwb:tutorial:pan'));
},
},
{
title: 'Ajustement automatique',
description: 'Cliquez sur le bouton "Ajuster" pour voir tout le workflow',
action: () => {
// Déclencher un tutoriel interactif pour l'ajustement
window.dispatchEvent(new CustomEvent('vwb:tutorial:fitView'));
},
},
],
tips: [
'Utilisez la molette pour zoomer rapidement',
'La minimap permet de naviguer dans les gros workflows',
'Les raccourcis clavier accélèrent la navigation',
],
},
{
id: 'shortcuts',
title: 'Raccourcis clavier',
content: 'Maîtrisez les raccourcis clavier pour une utilisation plus efficace du canvas.',
steps: [
{
title: 'Ctrl+Z / Ctrl+Y',
description: 'Annuler / Refaire les dernières actions',
},
{
title: 'Suppr',
description: 'Supprimer les éléments sélectionnés',
},
{
title: 'Ctrl+A',
description: 'Sélectionner tous les éléments',
},
{
title: 'Ctrl+C / Ctrl+V',
description: 'Copier / Coller les éléments sélectionnés',
},
{
title: 'Espace + glisser',
description: 'Mode panoramique temporaire',
},
],
},
],
examples: [
{
id: 'first-workflow',
title: 'Créer votre premier workflow',
description: 'Apprenez à créer un workflow simple avec quelques étapes',
steps: [
'Glissez une étape "Cliquer" depuis la palette vers le canvas',
'Ajoutez une étape "Saisir du texte" en dessous',
'Connectez les deux étapes en glissant depuis le point de sortie vers l\'entrée',
'Configurez les paramètres dans le panneau de propriétés',
'Testez votre workflow avec le bouton d\'exécution',
],
onTry: () => {
// Déclencher un tutoriel interactif complet
window.dispatchEvent(new CustomEvent('vwb:tutorial:firstWorkflow'));
},
},
{
id: 'complex-workflow',
title: 'Workflow avec conditions',
description: 'Créez un workflow plus complexe avec des conditions et des boucles',
steps: [
'Créez une étape de démarrage',
'Ajoutez une condition "Si/Alors"',
'Connectez les branches "Vrai" et "Faux"',
'Ajoutez des actions différentes pour chaque branche',
'Testez les différents chemins d\'exécution',
],
onTry: () => {
// Déclencher un tutoriel pour les workflows complexes
window.dispatchEvent(new CustomEvent('vwb:tutorial:complexWorkflow'));
},
},
{
id: 'debugging-workflow',
title: 'Déboguer un workflow',
description: 'Apprenez à identifier et corriger les erreurs dans vos workflows',
steps: [
'Identifiez les étapes avec des erreurs (indicateurs rouges)',
'Vérifiez les paramètres manquants dans le panneau de propriétés',
'Corrigez les étapes déconnectées (surlignées en orange)',
'Utilisez l\'exécution pas à pas pour tester',
'Consultez les logs d\'erreur détaillés',
],
onTry: () => {
// Déclencher un tutoriel de débogage
window.dispatchEvent(new CustomEvent('vwb:tutorial:debugging'));
},
},
],
glossary: [
{ term: 'Étape', definition: 'Un élément d\'action dans votre workflow' },
{ term: 'Connexion', definition: 'Un lien entre deux étapes définissant l\'ordre d\'exécution' },
{ term: 'Minimap', definition: 'Une vue miniature du workflow pour la navigation' },
{ term: 'Point de connexion', definition: 'Zone cliquable sur une étape pour créer des liens' },
{ term: 'Grille d\'alignement', definition: 'Guide visuel pour positionner les étapes proprement' },
],
},
palette: {
title: 'Palette - Boîte à outils',
sections: [
{
id: 'categories',
title: 'Catégories d\'étapes',
content: 'La palette organise les étapes en catégories pour faciliter la recherche.',
steps: [
{
title: 'Actions Web',
description: 'Étapes pour interagir avec les pages web (cliquer, saisir, etc.)',
},
{
title: 'Logique',
description: 'Étapes de contrôle de flux (conditions, boucles)',
},
{
title: 'Données',
description: 'Étapes pour manipuler les données (extraire, transformer)',
},
{
title: 'Contrôle',
description: 'Étapes de contrôle d\'exécution (attendre, arrêter)',
},
],
},
{
id: 'search',
title: 'Recherche d\'étapes',
content: 'Utilisez la barre de recherche pour trouver rapidement une étape spécifique.',
tips: [
'Tapez le nom de l\'action que vous voulez effectuer',
'La recherche fonctionne sur les noms et descriptions',
'Les résultats sont filtrés en temps réel',
],
},
],
examples: [
{
id: 'find-step',
title: 'Trouver une étape rapidement',
description: 'Utilisez la recherche pour trouver l\'étape dont vous avez besoin',
steps: [
'Cliquez dans la barre de recherche',
'Tapez "clic" pour trouver les étapes de clic',
'Glissez l\'étape trouvée sur le canvas',
],
onTry: () => console.log('Exemple interactif: Recherche d\'étape'),
},
],
glossary: [
{ term: 'Catégorie', definition: 'Un groupe d\'étapes similaires' },
{ term: 'Tooltip', definition: 'Une infobulle explicative qui apparaît au survol' },
],
},
properties: {
title: 'Propriétés - Configuration des étapes',
sections: [
{
id: 'parameter-types',
title: 'Types de paramètres',
content: 'Chaque étape peut avoir différents types de paramètres à configurer.',
steps: [
{
title: 'Texte',
description: 'Champs de saisie libre pour du texte',
},
{
title: 'Nombre',
description: 'Champs numériques avec validation',
},
{
title: 'Booléen',
description: 'Interrupteurs pour les options vrai/faux',
},
{
title: 'Sélection',
description: 'Listes déroulantes avec options prédéfinies',
},
{
title: 'Sélection visuelle',
description: 'Boutons pour sélectionner des éléments à l\'écran',
},
],
},
{
id: 'validation',
title: 'Validation en temps réel',
content: 'Les paramètres sont validés automatiquement pendant la saisie.',
warnings: [
'Les paramètres obligatoires doivent être remplis',
'Les valeurs numériques doivent respecter les limites',
'Les sélections visuelles doivent être effectuées',
],
},
],
examples: [
{
id: 'configure-click',
title: 'Configurer une étape de clic',
description: 'Apprenez à configurer les paramètres d\'une étape de clic',
steps: [
'Sélectionnez une étape de clic sur le canvas',
'Cliquez sur "Sélectionner un élément" dans les propriétés',
'Choisissez le type de clic dans la liste déroulante',
],
onTry: () => console.log('Exemple interactif: Configuration de clic'),
},
],
glossary: [
{ term: 'Paramètre', definition: 'Une valeur configurable d\'une étape' },
{ term: 'Validation', definition: 'Vérification automatique de la validité des valeurs' },
],
},
};
/**
* Composant Onglet de Documentation
*/
const DocumentationTab: React.FC<DocumentationTabProps> = ({
toolName,
isActive,
onActivate,
}) => {
const [activeSection, setActiveSection] = useState(0);
const [activeStep, setActiveStep] = useState(0);
const content = documentationContent[toolName];
// Gestionnaire d'événements clavier pour la documentation
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'ArrowUp':
// Navigation vers la section précédente
event.preventDefault();
setActiveSection(prev => Math.max(0, prev - 1));
break;
case 'ArrowDown':
// Navigation vers la section suivante
event.preventDefault();
const maxSections = content?.sections.length || 0;
setActiveSection(prev => Math.min(maxSections - 1, prev + 1));
break;
case 'ArrowLeft':
// Navigation vers l'étape précédente
event.preventDefault();
setActiveStep(prev => Math.max(0, prev - 1));
break;
case 'ArrowRight':
// Navigation vers l'étape suivante
event.preventDefault();
const maxSteps = content?.sections[activeSection]?.steps?.length || 0;
setActiveStep(prev => Math.min(maxSteps - 1, prev + 1));
break;
case 'Home':
// Aller au début
event.preventDefault();
setActiveSection(0);
setActiveStep(0);
break;
case 'End':
// Aller à la fin
event.preventDefault();
const lastSection = (content?.sections.length || 1) - 1;
setActiveSection(lastSection);
break;
}
}, [activeSection, content]);
if (!content) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="info">
Documentation non disponible pour cet outil.
</Alert>
</Box>
);
}
return (
<Box
sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}
role="tabpanel"
aria-label={`Documentation pour ${content.title}`}
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* En-tête */}
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
<Typography variant="h6" gutterBottom>
<HelpIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
{content.title}
</Typography>
</Box>
{/* Contenu avec onglets */}
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<Tabs
value={activeSection}
onChange={(_, newValue) => setActiveSection(newValue)}
variant="scrollable"
scrollButtons="auto"
>
<Tab label="Guide" />
<Tab label="Exemples" />
<Tab label="Glossaire" />
</Tabs>
{/* Panneau Guide */}
{activeSection === 0 && (
<Box sx={{ p: 2, height: 'calc(100% - 48px)', overflow: 'auto' }}>
{content.sections.map((section, index) => (
<Accordion key={section.id} defaultExpanded={index === 0}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">{section.title}</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ mb: 2 }}>
<Typography variant="body1" paragraph>
{section.content}
</Typography>
{/* Étapes */}
{section.steps && (
<Stepper orientation="vertical" activeStep={activeStep}>
{section.steps.map((step, stepIndex) => (
<Step key={stepIndex}>
<StepLabel>{step.title}</StepLabel>
<StepContent>
<Typography variant="body2" color="text.secondary">
{step.description}
</Typography>
{step.action && (
<Button
size="small"
onClick={step.action}
sx={{ mt: 1 }}
>
Essayer
</Button>
)}
</StepContent>
</Step>
))}
</Stepper>
)}
{/* Conseils */}
{section.tips && section.tips.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
<TipIcon sx={{ mr: 1, verticalAlign: 'middle', fontSize: 'small' }} />
Conseils
</Typography>
<List dense>
{section.tips.map((tip, tipIndex) => (
<ListItem key={tipIndex}>
<ListItemIcon>
<CheckIcon color="success" fontSize="small" />
</ListItemIcon>
<ListItemText primary={tip} />
</ListItem>
))}
</List>
</Box>
)}
{/* Avertissements */}
{section.warnings && section.warnings.length > 0 && (
<Box sx={{ mt: 2 }}>
<Alert severity="warning" icon={<WarningIcon />}>
<Typography variant="subtitle2" gutterBottom>
Points d'attention
</Typography>
<List dense>
{section.warnings.map((warning, warningIndex) => (
<ListItem key={warningIndex} sx={{ py: 0 }}>
<ListItemText primary={warning} />
</ListItem>
))}
</List>
</Alert>
</Box>
)}
</Box>
</AccordionDetails>
</Accordion>
))}
</Box>
)}
{/* Panneau Exemples */}
{activeSection === 1 && (
<Box sx={{ p: 2, height: 'calc(100% - 48px)', overflow: 'auto' }}>
{content.examples.map((example) => (
<Card key={example.id} sx={{ mb: 2 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
{example.title}
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
{example.description}
</Typography>
<Typography variant="subtitle2" gutterBottom>
Étapes à suivre :
</Typography>
<List dense>
{example.steps.map((step, stepIndex) => (
<ListItem key={stepIndex}>
<ListItemIcon>
<Chip label={stepIndex + 1} size="small" color="primary" />
</ListItemIcon>
<ListItemText primary={step} />
</ListItem>
))}
</List>
<Button
variant="contained"
startIcon={<PlayIcon />}
onClick={example.onTry}
sx={{ mt: 2 }}
>
Essayer cet exemple
</Button>
</CardContent>
</Card>
))}
</Box>
)}
{/* Panneau Glossaire */}
{activeSection === 2 && (
<Box sx={{ height: 'calc(100% - 48px)', overflow: 'hidden' }}>
<Glossary />
</Box>
)}
</Box>
</Box>
);
};
export default DocumentationTab;

View File

@@ -0,0 +1,396 @@
/**
* Composant EvidenceDetail - Affichage détaillé d'une Evidence
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import React, { useState, useRef, useCallback } from 'react';
import {
Box,
Typography,
IconButton,
Paper,
Chip,
Accordion,
AccordionSummary,
AccordionDetails,
Alert,
Button,
Tooltip,
Divider
} from '@mui/material';
import {
Close as CloseIcon,
ExpandMore as ExpandMoreIcon,
ZoomIn as ZoomInIcon,
ZoomOut as ZoomOutIcon,
Fullscreen as FullscreenIcon,
Download as DownloadIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Schedule as TimeIcon,
Visibility as ConfidenceIcon
} from '@mui/icons-material';
import { VWBEvidence, EvidenceUtils } from '../../types/evidence';
import ScreenshotViewer from './ScreenshotViewer';
interface EvidenceDetailProps {
evidence: VWBEvidence;
onClose?: () => void;
showMetadata?: boolean;
}
const EvidenceDetail: React.FC<EvidenceDetailProps> = ({
evidence,
onClose,
showMetadata = true
}) => {
// État local
const [zoom, setZoom] = useState(1);
const [expandedPanels, setExpandedPanels] = useState<string[]>(['screenshot']);
const screenshotRef = useRef<HTMLImageElement>(null);
// Gestion des panneaux accordéon
const handlePanelChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
setExpandedPanels(prev =>
isExpanded
? [...prev, panel]
: prev.filter(p => p !== panel)
);
};
// Gestion du zoom
const handleZoomIn = useCallback(() => {
setZoom(prev => Math.min(prev * 1.2, 5));
}, []);
const handleZoomOut = useCallback(() => {
setZoom(prev => Math.max(prev / 1.2, 0.1));
}, []);
const handleZoomReset = useCallback(() => {
setZoom(1);
}, []);
// Téléchargement du screenshot
const handleDownloadScreenshot = useCallback(() => {
if (!evidence.screenshot_base64) return;
const link = document.createElement('a');
link.href = `data:image/png;base64,${evidence.screenshot_base64}`;
link.download = `evidence_${evidence.id}_${evidence.captured_at.replace(/[:.]/g, '-')}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [evidence]);
// Plein écran
const handleFullscreen = useCallback(() => {
if (screenshotRef.current) {
if (screenshotRef.current.requestFullscreen) {
screenshotRef.current.requestFullscreen();
}
}
}, []);
// Rendu des métadonnées
const renderMetadata = () => {
if (!showMetadata) return null;
const metadata = evidence.metadata || {};
const metadataEntries = Object.entries(metadata);
if (metadataEntries.length === 0) {
return (
<Typography variant="body2" color="text.secondary">
Aucune métadonnée disponible
</Typography>
);
}
return (
<Box className="evidence-metadata-grid">
{metadataEntries.map(([key, value]) => (
<Box key={key} className="evidence-metadata-item">
<Typography variant="caption" className="evidence-metadata-item-label">
{key}
</Typography>
<Typography variant="body2" className="evidence-metadata-item-value">
{typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)}
</Typography>
</Box>
))}
</Box>
);
};
return (
<Box className="evidence-detail" sx={{ height: '100%', overflow: 'auto' }}>
{/* En-tête */}
<Box className="evidence-detail-header">
<Box>
<Typography variant="h6" className="evidence-detail-title">
{evidence.action_name || evidence.action_id}
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
ID: {evidence.id}
</Typography>
</Box>
{onClose && (
<IconButton
onClick={onClose}
className="evidence-detail-close"
sx={{ color: '#94a3b8' }}
>
<CloseIcon />
</IconButton>
)}
</Box>
{/* Informations principales */}
<Box mb={2}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Chip
icon={evidence.success ? <SuccessIcon /> : <ErrorIcon />}
label={evidence.success ? 'SUCCÈS' : 'ERREUR'}
color={evidence.success ? 'success' : 'error'}
variant="filled"
/>
<Chip
icon={<TimeIcon />}
label={EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}
variant="outlined"
sx={{ color: '#94a3b8', borderColor: '#475569' }}
/>
{evidence.confidence_score && (
<Chip
icon={<ConfidenceIcon />}
label={EvidenceUtils.formatConfidence(evidence.confidence_score)}
variant="outlined"
sx={{ color: '#94a3b8', borderColor: '#475569' }}
/>
)}
</Box>
<Typography variant="body2" sx={{ color: '#94a3b8' }}>
<strong>Capturé le :</strong> {EvidenceUtils.formatDate(evidence.captured_at)}
</Typography>
</Box>
{/* Message d'erreur */}
{!evidence.success && evidence.error && (
<Alert severity="error" sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
{evidence.error.message}
</Typography>
{evidence.error.details && Object.keys(evidence.error.details).length > 0 && (
<Box mt={1}>
<Typography variant="caption" display="block">
Détails techniques :
</Typography>
<pre style={{ fontSize: '11px', margin: '4px 0', whiteSpace: 'pre-wrap' }}>
{JSON.stringify(evidence.error.details, null, 2)}
</pre>
</Box>
)}
</Alert>
)}
{/* Screenshot */}
{evidence.screenshot_base64 && (
<Accordion
expanded={expandedPanels.includes('screenshot')}
onChange={handlePanelChange('screenshot')}
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
<Typography variant="subtitle2">
Screenshot d'Evidence
</Typography>
</AccordionSummary>
<AccordionDetails>
{/* Contrôles du screenshot */}
<Box display="flex" gap={1} mb={2} flexWrap="wrap">
<Tooltip title="Zoom avant">
<IconButton size="small" onClick={handleZoomIn} sx={{ color: '#94a3b8' }}>
<ZoomInIcon />
</IconButton>
</Tooltip>
<Tooltip title="Zoom arrière">
<IconButton size="small" onClick={handleZoomOut} sx={{ color: '#94a3b8' }}>
<ZoomOutIcon />
</IconButton>
</Tooltip>
<Tooltip title="Taille réelle">
<Button size="small" onClick={handleZoomReset} sx={{ color: '#94a3b8' }}>
{Math.round(zoom * 100)}%
</Button>
</Tooltip>
<Tooltip title="Plein écran">
<IconButton size="small" onClick={handleFullscreen} sx={{ color: '#94a3b8' }}>
<FullscreenIcon />
</IconButton>
</Tooltip>
<Tooltip title="Télécharger">
<IconButton size="small" onClick={handleDownloadScreenshot} sx={{ color: '#94a3b8' }}>
<DownloadIcon />
</IconButton>
</Tooltip>
</Box>
{/* Visualiseur de screenshot */}
<ScreenshotViewer
screenshot={evidence.screenshot_base64}
bbox={evidence.bbox}
clickPoint={evidence.click_point}
zoom={zoom}
onZoomChange={setZoom}
maxWidth={600}
maxHeight={400}
/>
</AccordionDetails>
</Accordion>
)}
{/* Détails techniques */}
<Accordion
expanded={expandedPanels.includes('technical')}
onChange={handlePanelChange('technical')}
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
<Typography variant="subtitle2">
Détails Techniques
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="evidence-metadata-grid">
<Box className="evidence-metadata-item">
<Typography variant="caption" className="evidence-metadata-item-label">
Contrat
</Typography>
<Typography variant="body2" className="evidence-metadata-item-value">
{evidence.contract} v{evidence.version}
</Typography>
</Box>
<Box className="evidence-metadata-item">
<Typography variant="caption" className="evidence-metadata-item-label">
Action ID
</Typography>
<Typography variant="body2" className="evidence-metadata-item-value">
{evidence.action_id}
</Typography>
</Box>
<Box className="evidence-metadata-item">
<Typography variant="caption" className="evidence-metadata-item-label">
Temps d'exécution
</Typography>
<Typography variant="body2" className="evidence-metadata-item-value">
{evidence.execution_time_ms}ms
</Typography>
</Box>
{evidence.confidence_score && (
<Box className="evidence-metadata-item">
<Typography variant="caption" className="evidence-metadata-item-label">
Score de confiance
</Typography>
<Typography variant="body2" className="evidence-metadata-item-value">
{evidence.confidence_score.toFixed(3)}
</Typography>
</Box>
)}
{evidence.bbox && (
<Box className="evidence-metadata-item">
<Typography variant="caption" className="evidence-metadata-item-label">
Zone détectée
</Typography>
<Typography variant="body2" className="evidence-metadata-item-value">
{evidence.bbox.x}, {evidence.bbox.y} ({evidence.bbox.width}×{evidence.bbox.height})
</Typography>
</Box>
)}
{evidence.click_point && (
<Box className="evidence-metadata-item">
<Typography variant="caption" className="evidence-metadata-item-label">
Point de clic
</Typography>
<Typography variant="body2" className="evidence-metadata-item-value">
{evidence.click_point.x}, {evidence.click_point.y}
</Typography>
</Box>
)}
</Box>
</AccordionDetails>
</Accordion>
{/* Métadonnées personnalisées */}
{showMetadata && evidence.metadata && Object.keys(evidence.metadata).length > 0 && (
<Accordion
expanded={expandedPanels.includes('metadata')}
onChange={handlePanelChange('metadata')}
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
<Typography variant="subtitle2">
Métadonnées Personnalisées
</Typography>
</AccordionSummary>
<AccordionDetails>
{renderMetadata()}
</AccordionDetails>
</Accordion>
)}
{/* Données brutes (pour debug) */}
<Accordion
expanded={expandedPanels.includes('raw')}
onChange={handlePanelChange('raw')}
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
<Typography variant="subtitle2">
Données Brutes (Debug)
</Typography>
</AccordionSummary>
<AccordionDetails>
<Paper
sx={{
p: 2,
backgroundColor: '#1e293b',
border: '1px solid #334155',
maxHeight: 300,
overflow: 'auto'
}}
>
<pre style={{
fontSize: '11px',
margin: 0,
whiteSpace: 'pre-wrap',
color: '#e2e8f0',
fontFamily: 'monospace'
}}>
{JSON.stringify({
...evidence,
screenshot_base64: evidence.screenshot_base64 ? '[BASE64_DATA]' : null
}, null, 2)}
</pre>
</Paper>
</AccordionDetails>
</Accordion>
</Box>
);
};
export default EvidenceDetail;

View File

@@ -0,0 +1,406 @@
/**
* Composant EvidenceFilters - Filtres pour les Evidence
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import React, { useState, useMemo } from 'react';
import {
Box,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Chip,
Button,
Slider,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
SelectChangeEvent
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
Clear as ClearIcon,
FilterList as FilterIcon
} from '@mui/icons-material';
import { VWBEvidence, EvidenceFilters } from '../../types/evidence';
interface EvidenceFiltersProps {
filters: EvidenceFilters;
onFiltersChange: (filters: Partial<EvidenceFilters>) => void;
onClearFilters: () => void;
evidences: VWBEvidence[];
}
const EvidenceFiltersComponent: React.FC<EvidenceFiltersProps> = ({
filters,
onFiltersChange,
onClearFilters,
evidences
}) => {
// État local
const [expandedPanels, setExpandedPanels] = useState<string[]>(['basic']);
// Types d'actions disponibles
const availableActionTypes = useMemo(() => {
const types = new Set<string>();
evidences.forEach(evidence => {
types.add(evidence.action_name || evidence.action_id);
});
return Array.from(types).sort();
}, [evidences]);
// Plages de valeurs pour les sliders
const valueRanges = useMemo(() => {
const executionTimes = evidences.map(e => e.execution_time_ms);
const confidenceScores = evidences.filter(e => e.confidence_score !== undefined).map(e => e.confidence_score!);
return {
executionTime: {
min: Math.min(...executionTimes, 0),
max: Math.max(...executionTimes, 60000)
},
confidence: {
min: Math.min(...confidenceScores, 0),
max: Math.max(...confidenceScores, 1)
}
};
}, [evidences]);
// Gestion des panneaux accordéon
const handlePanelChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
setExpandedPanels(prev =>
isExpanded
? [...prev, panel]
: prev.filter(p => p !== panel)
);
};
// Gestion des types d'actions
const handleActionTypesChange = (event: SelectChangeEvent<string[]>) => {
const value = event.target.value;
onFiltersChange({
actionTypes: typeof value === 'string' ? value.split(',') : value
});
};
// Gestion du statut
const handleStatusChange = (event: SelectChangeEvent<string>) => {
onFiltersChange({
status: event.target.value as 'all' | 'success' | 'error'
});
};
// Gestion de la recherche textuelle
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onFiltersChange({
searchText: event.target.value
});
};
// Gestion des dates
const handleStartDateChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const date = event.target.value ? new Date(event.target.value) : undefined;
onFiltersChange({
dateRange: {
...filters.dateRange,
start: date
}
});
};
const handleEndDateChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const date = event.target.value ? new Date(event.target.value) : undefined;
onFiltersChange({
dateRange: {
...filters.dateRange,
end: date
}
});
};
// Fonction utilitaire pour formater les dates
const formatDateForInput = (date: Date | undefined): string => {
if (!date) return '';
return date.toISOString().split('T')[0];
};
// Gestion des sliders
const handleConfidenceRangeChange = (event: Event, newValue: number | number[]) => {
const [min, max] = newValue as number[];
onFiltersChange({
confidenceRange: { min, max }
});
};
const handleExecutionTimeRangeChange = (event: Event, newValue: number | number[]) => {
const [min, max] = newValue as number[];
onFiltersChange({
executionTimeRange: { min, max }
});
};
// Vérification si des filtres sont appliqués
const hasActiveFilters = (
filters.actionTypes.length > 0 ||
filters.status !== 'all' ||
filters.searchText.trim() !== '' ||
filters.dateRange.start !== undefined ||
filters.dateRange.end !== undefined ||
filters.confidenceRange.min > valueRanges.confidence.min ||
filters.confidenceRange.max < valueRanges.confidence.max ||
filters.executionTimeRange.min > valueRanges.executionTime.min ||
filters.executionTimeRange.max < valueRanges.executionTime.max
);
return (
<Box className="evidence-filters">
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Box display="flex" alignItems="center" gap={1}>
<FilterIcon sx={{ color: '#1976d2' }} />
<Typography variant="h6" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
Filtres
</Typography>
{hasActiveFilters && (
<Chip
label="Filtres actifs"
size="small"
color="primary"
variant="filled"
/>
)}
</Box>
{hasActiveFilters && (
<Button
startIcon={<ClearIcon />}
onClick={onClearFilters}
size="small"
sx={{ color: '#94a3b8' }}
>
Effacer
</Button>
)}
</Box>
{/* Filtres de base */}
<Accordion
expanded={expandedPanels.includes('basic')}
onChange={handlePanelChange('basic')}
sx={{ mb: 1, backgroundColor: '#334155', color: '#e2e8f0' }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
<Typography variant="subtitle2">Filtres de base</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="evidence-filters-row">
{/* Recherche textuelle */}
<TextField
label="Recherche"
placeholder="Rechercher dans les Evidence..."
value={filters.searchText}
onChange={handleSearchChange}
size="small"
sx={{
minWidth: 200,
'& .MuiOutlinedInput-root': {
backgroundColor: '#1e293b',
color: '#e2e8f0',
'& fieldset': { borderColor: '#475569' },
'&:hover fieldset': { borderColor: '#64748b' },
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
},
'& .MuiInputLabel-root': { color: '#94a3b8' }
}}
/>
{/* Statut */}
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel sx={{ color: '#94a3b8' }}>Statut</InputLabel>
<Select
value={filters.status}
onChange={handleStatusChange}
label="Statut"
sx={{
backgroundColor: '#1e293b',
color: '#e2e8f0',
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#475569' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#64748b' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#1976d2' }
}}
>
<MenuItem value="all">Tous</MenuItem>
<MenuItem value="success">Succès</MenuItem>
<MenuItem value="error">Erreurs</MenuItem>
</Select>
</FormControl>
{/* Types d'actions */}
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel sx={{ color: '#94a3b8' }}>Types d'actions</InputLabel>
<Select
multiple
value={filters.actionTypes}
onChange={handleActionTypesChange}
label="Types d'actions"
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => (
<Chip key={value} label={value} size="small" />
))}
</Box>
)}
sx={{
backgroundColor: '#1e293b',
color: '#e2e8f0',
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#475569' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#64748b' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#1976d2' }
}}
>
{availableActionTypes.map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
</AccordionDetails>
</Accordion>
{/* Filtres de date */}
<Accordion
expanded={expandedPanels.includes('dates')}
onChange={handlePanelChange('dates')}
sx={{ mb: 1, backgroundColor: '#334155', color: '#e2e8f0' }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
<Typography variant="subtitle2">Plage de dates</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="evidence-filters-row">
<TextField
label="Date de début"
type="date"
value={formatDateForInput(filters.dateRange.start)}
onChange={handleStartDateChange}
size="small"
InputLabelProps={{
shrink: true,
}}
sx={{
'& .MuiOutlinedInput-root': {
backgroundColor: '#1e293b',
color: '#e2e8f0',
'& fieldset': { borderColor: '#475569' },
'&:hover fieldset': { borderColor: '#64748b' },
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
},
'& .MuiInputLabel-root': { color: '#94a3b8' }
}}
/>
<TextField
label="Date de fin"
type="date"
value={formatDateForInput(filters.dateRange.end)}
onChange={handleEndDateChange}
size="small"
InputLabelProps={{
shrink: true,
}}
sx={{
'& .MuiOutlinedInput-root': {
backgroundColor: '#1e293b',
color: '#e2e8f0',
'& fieldset': { borderColor: '#475569' },
'&:hover fieldset': { borderColor: '#64748b' },
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
},
'& .MuiInputLabel-root': { color: '#94a3b8' }
}}
/>
</Box>
</AccordionDetails>
</Accordion>
{/* Filtres avancés */}
<Accordion
expanded={expandedPanels.includes('advanced')}
onChange={handlePanelChange('advanced')}
sx={{ mb: 1, backgroundColor: '#334155', color: '#e2e8f0' }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
<Typography variant="subtitle2">Filtres avancés</Typography>
</AccordionSummary>
<AccordionDetails>
<Box>
{/* Plage de confiance */}
{valueRanges.confidence.max > 0 && (
<Box mb={3}>
<Typography variant="caption" sx={{ color: '#94a3b8', mb: 1, display: 'block' }}>
Score de confiance: {Math.round(filters.confidenceRange.min * 100)}% - {Math.round(filters.confidenceRange.max * 100)}%
</Typography>
<Slider
value={[filters.confidenceRange.min, filters.confidenceRange.max]}
onChange={handleConfidenceRangeChange}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${Math.round(value * 100)}%`}
min={valueRanges.confidence.min}
max={valueRanges.confidence.max}
step={0.01}
sx={{
color: '#1976d2',
'& .MuiSlider-thumb': {
backgroundColor: '#1976d2'
},
'& .MuiSlider-track': {
backgroundColor: '#1976d2'
},
'& .MuiSlider-rail': {
backgroundColor: '#475569'
}
}}
/>
</Box>
)}
{/* Plage de temps d'exécution */}
<Box>
<Typography variant="caption" sx={{ color: '#94a3b8', mb: 1, display: 'block' }}>
Temps d'exécution: {filters.executionTimeRange.min}ms - {filters.executionTimeRange.max}ms
</Typography>
<Slider
value={[filters.executionTimeRange.min, filters.executionTimeRange.max]}
onChange={handleExecutionTimeRangeChange}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value}ms`}
min={valueRanges.executionTime.min}
max={valueRanges.executionTime.max}
step={100}
sx={{
color: '#1976d2',
'& .MuiSlider-thumb': {
backgroundColor: '#1976d2'
},
'& .MuiSlider-track': {
backgroundColor: '#1976d2'
},
'& .MuiSlider-rail': {
backgroundColor: '#475569'
}
}}
/>
</Box>
</Box>
</AccordionDetails>
</Accordion>
</Box>
);
};
export default EvidenceFiltersComponent;

View File

@@ -0,0 +1,415 @@
/**
* Composant EvidenceList - Liste des Evidence avec filtrage et tri
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import React, { useState, useMemo } from 'react';
import {
Box,
List,
ListItem,
ListItemButton,
ListItemText,
ListItemAvatar,
Avatar,
Typography,
Chip,
IconButton,
Menu,
MenuItem,
TextField,
InputAdornment,
Pagination,
FormControl,
InputLabel,
Select,
SelectChangeEvent
} from '@mui/material';
import {
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Schedule as TimeIcon,
Search as SearchIcon,
Sort as SortIcon,
FilterList as FilterIcon
} from '@mui/icons-material';
import { VWBEvidence, EvidenceFilters, EvidenceUtils } from '../../types/evidence';
interface EvidenceListProps {
evidences: VWBEvidence[];
selectedId?: string;
onSelect: (evidence: VWBEvidence) => void;
filters?: EvidenceFilters;
onFiltersChange?: (filters: Partial<EvidenceFilters>) => void;
sortBy: string;
sortOrder: 'asc' | 'desc';
onSortChange: (sortBy: string, sortOrder: 'asc' | 'desc') => void;
viewMode: 'list' | 'grid';
showFilters?: boolean;
itemsPerPage?: number;
}
const EvidenceList: React.FC<EvidenceListProps> = ({
evidences,
selectedId,
onSelect,
filters,
onFiltersChange,
sortBy,
sortOrder,
onSortChange,
viewMode = 'list',
showFilters = false,
itemsPerPage = 20
}) => {
// État local
const [currentPage, setCurrentPage] = useState(1);
const [searchText, setSearchText] = useState(filters?.searchText || '');
const [sortMenuAnchor, setSortMenuAnchor] = useState<null | HTMLElement>(null);
// Evidence paginées
const paginatedEvidences = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
return evidences.slice(startIndex, endIndex);
}, [evidences, currentPage, itemsPerPage]);
// Nombre total de pages
const totalPages = Math.ceil(evidences.length / itemsPerPage);
// Gestion de la recherche
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setSearchText(value);
setCurrentPage(1); // Retour à la première page
if (onFiltersChange) {
onFiltersChange({ searchText: value });
}
};
// Gestion du tri
const handleSortMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setSortMenuAnchor(event.currentTarget);
};
const handleSortMenuClose = () => {
setSortMenuAnchor(null);
};
const handleSortChange = (newSortBy: string) => {
const newSortOrder = sortBy === newSortBy && sortOrder === 'desc' ? 'asc' : 'desc';
onSortChange(newSortBy, newSortOrder);
handleSortMenuClose();
};
// Gestion du changement de page
const handlePageChange = (event: React.ChangeEvent<unknown>, page: number) => {
setCurrentPage(page);
};
// Rendu d'un item Evidence
const renderEvidenceItem = (evidence: VWBEvidence) => {
const isSelected = selectedId === evidence.id;
const isSuccess = evidence.success;
return (
<ListItem
key={evidence.id}
disablePadding
className={`evidence-list-item ${isSelected ? 'selected' : ''} ${isSuccess ? 'success' : 'error'}`}
sx={{
mb: 1,
borderRadius: 2,
border: isSelected ? '2px solid #1976d2' : '1px solid #334155',
backgroundColor: isSelected ? '#1e40af' : '#334155',
}}
>
<ListItemButton
selected={isSelected}
onClick={() => onSelect(evidence)}
sx={{
borderRadius: 2,
'&:hover': {
backgroundColor: isSelected ? '#1e40af' : '#475569',
transform: 'translateY(-1px)',
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.2)'
}
}}
>
<ListItemAvatar>
<Avatar
sx={{
bgcolor: isSuccess ? '#22c55e' : '#ef4444',
width: 32,
height: 32
}}
>
{isSuccess ? <SuccessIcon fontSize="small" /> : <ErrorIcon fontSize="small" />}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
{evidence.action_name || evidence.action_id}
</Typography>
}
secondary={
<Box>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block' }}>
{EvidenceUtils.formatDate(evidence.captured_at)}
</Typography>
<Box display="flex" alignItems="center" gap={1} mt={0.5}>
<Chip
icon={<TimeIcon />}
label={EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}
size="small"
variant="outlined"
sx={{
fontSize: '10px',
height: 20,
color: '#94a3b8',
borderColor: '#475569'
}}
/>
{evidence.confidence_score && (
<Chip
label={EvidenceUtils.formatConfidence(evidence.confidence_score)}
size="small"
variant="outlined"
sx={{
fontSize: '10px',
height: 20,
color: '#94a3b8',
borderColor: '#475569'
}}
/>
)}
{!isSuccess && (
<Chip
label="ERREUR"
size="small"
sx={{
fontSize: '10px',
height: 20,
backgroundColor: '#ef4444',
color: 'white'
}}
/>
)}
</Box>
</Box>
}
/>
{/* Miniature du screenshot */}
{evidence.screenshot_base64 && (
<Box ml={1}>
<img
src={`data:image/png;base64,${evidence.screenshot_base64}`}
alt={`Screenshot de ${evidence.action_name || evidence.action_id}`}
className="evidence-thumbnail"
style={{
width: 60,
height: 40,
objectFit: 'cover',
borderRadius: 4,
border: '1px solid #475569'
}}
/>
</Box>
)}
</ListItemButton>
</ListItem>
);
};
// Rendu en mode grille
const renderGridView = () => (
<Box className="evidence-grid">
{paginatedEvidences.map(evidence => {
const isSelected = selectedId === evidence.id;
const isSuccess = evidence.success;
return (
<Box
key={evidence.id}
className={`evidence-grid-item ${isSelected ? 'selected' : ''}`}
onClick={() => onSelect(evidence)}
sx={{
border: isSelected ? '2px solid #1976d2' : '1px solid #334155',
backgroundColor: isSelected ? '#1e40af' : '#334155',
cursor: 'pointer',
'&:hover': {
backgroundColor: isSelected ? '#1e40af' : '#475569',
transform: 'translateY(-2px)',
boxShadow: '0 6px 12px rgba(0, 0, 0, 0.3)'
}
}}
>
{/* En-tête */}
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
{evidence.action_name || evidence.action_id}
</Typography>
<Chip
icon={isSuccess ? <SuccessIcon /> : <ErrorIcon />}
label={isSuccess ? 'OK' : 'ERR'}
size="small"
sx={{
backgroundColor: isSuccess ? '#22c55e' : '#ef4444',
color: 'white',
fontSize: '10px',
height: 20
}}
/>
</Box>
{/* Screenshot */}
{evidence.screenshot_base64 && (
<img
src={`data:image/png;base64,${evidence.screenshot_base64}`}
alt={`Screenshot de ${evidence.action_name || evidence.action_id}`}
style={{
width: '100%',
height: 100,
objectFit: 'cover',
borderRadius: 4,
border: '1px solid #475569',
marginBottom: 8
}}
/>
)}
{/* Métadonnées */}
<Box flex={1}>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block' }}>
{EvidenceUtils.formatDate(evidence.captured_at)}
</Typography>
<Box display="flex" justifyContent="space-between" mt={1}>
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
{EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}
</Typography>
{evidence.confidence_score && (
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
{EvidenceUtils.formatConfidence(evidence.confidence_score)}
</Typography>
)}
</Box>
</Box>
</Box>
);
})}
</Box>
);
return (
<Box height="100%" display="flex" flexDirection="column">
{/* Barre d'outils */}
<Box p={2} borderBottom="1px solid #334155">
<Box display="flex" gap={2} alignItems="center" mb={showFilters ? 2 : 0}>
{/* Recherche */}
<TextField
size="small"
placeholder="Rechercher dans les Evidence..."
value={searchText}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ color: '#94a3b8' }} />
</InputAdornment>
),
}}
sx={{
flex: 1,
'& .MuiOutlinedInput-root': {
backgroundColor: '#334155',
color: '#e2e8f0',
'& fieldset': { borderColor: '#475569' },
'&:hover fieldset': { borderColor: '#64748b' },
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
}
}}
/>
{/* Bouton de tri */}
<IconButton
onClick={handleSortMenuOpen}
sx={{ color: '#94a3b8' }}
>
<SortIcon />
</IconButton>
{/* Menu de tri */}
<Menu
anchorEl={sortMenuAnchor}
open={Boolean(sortMenuAnchor)}
onClose={handleSortMenuClose}
>
<MenuItem onClick={() => handleSortChange('date')}>
Date {sortBy === 'date' && (sortOrder === 'desc' ? '↓' : '↑')}
</MenuItem>
<MenuItem onClick={() => handleSortChange('action')}>
Action {sortBy === 'action' && (sortOrder === 'desc' ? '↓' : '↑')}
</MenuItem>
<MenuItem onClick={() => handleSortChange('status')}>
Statut {sortBy === 'status' && (sortOrder === 'desc' ? '↓' : '↑')}
</MenuItem>
<MenuItem onClick={() => handleSortChange('execution_time')}>
Temps {sortBy === 'execution_time' && (sortOrder === 'desc' ? '↓' : '↑')}
</MenuItem>
<MenuItem onClick={() => handleSortChange('confidence')}>
Confiance {sortBy === 'confidence' && (sortOrder === 'desc' ? '↓' : '↑')}
</MenuItem>
</Menu>
</Box>
{/* Informations */}
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
{evidences.length} Evidence{evidences.length > 1 ? 's' : ''}
{evidences.length !== paginatedEvidences.length &&
` (${paginatedEvidences.length} affichée${paginatedEvidences.length > 1 ? 's' : ''})`
}
</Typography>
</Box>
{/* Liste ou grille */}
<Box flex={1} overflow="auto">
{viewMode === 'list' ? (
<List sx={{ p: 1 }}>
{paginatedEvidences.map(renderEvidenceItem)}
</List>
) : (
renderGridView()
)}
</Box>
{/* Pagination */}
{totalPages > 1 && (
<Box p={2} borderTop="1px solid #334155" display="flex" justifyContent="center">
<Pagination
count={totalPages}
page={currentPage}
onChange={handlePageChange}
color="primary"
size="small"
sx={{
'& .MuiPaginationItem-root': {
color: '#94a3b8',
'&.Mui-selected': {
backgroundColor: '#1976d2',
color: 'white'
}
}
}}
/>
</Box>
)}
</Box>
);
};
export default EvidenceList;

View File

@@ -0,0 +1,224 @@
/**
* Composant EvidenceStats - Affichage des statistiques des Evidence
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import React from 'react';
import { Box, Typography, Chip, LinearProgress } from '@mui/material';
import {
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Schedule as TimeIcon,
Visibility as ConfidenceIcon,
Assessment as StatsIcon
} from '@mui/icons-material';
import { EvidenceStats, EvidenceUtils } from '../../types/evidence';
interface EvidenceStatsProps {
stats: EvidenceStats;
totalEvidences: number;
filteredCount: number;
hasFilters: boolean;
}
const EvidenceStatsComponent: React.FC<EvidenceStatsProps> = ({
stats,
totalEvidences,
filteredCount,
hasFilters
}) => {
// Calcul du taux de succès
const successRate = stats.total > 0 ? (stats.successful / stats.total) * 100 : 0;
// Couleur du taux de succès
const getSuccessRateColor = (rate: number) => {
if (rate >= 90) return '#22c55e'; // Vert
if (rate >= 70) return '#f59e0b'; // Orange
return '#ef4444'; // Rouge
};
return (
<Box className="evidence-stats">
<Box display="flex" alignItems="center" gap={1} mb={2}>
<StatsIcon sx={{ color: '#1976d2' }} />
<Typography variant="h6" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
Statistiques des Evidence
</Typography>
{hasFilters && (
<Chip
label={`${filteredCount}/${totalEvidences} filtrées`}
size="small"
variant="outlined"
sx={{ color: '#94a3b8', borderColor: '#475569' }}
/>
)}
</Box>
<Box className="evidence-stats-grid">
{/* Total */}
<Box className="evidence-stat-item">
<Typography variant="h4" className="evidence-stat-value">
{stats.total}
</Typography>
<Typography variant="caption" className="evidence-stat-label">
Total
</Typography>
</Box>
{/* Réussies */}
<Box className="evidence-stat-item">
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
<SuccessIcon sx={{ color: '#22c55e', fontSize: 16 }} />
<Typography variant="h4" sx={{ color: '#22c55e', fontWeight: 700 }}>
{stats.successful}
</Typography>
</Box>
<Typography variant="caption" className="evidence-stat-label">
Réussies
</Typography>
</Box>
{/* Échouées */}
<Box className="evidence-stat-item">
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
<ErrorIcon sx={{ color: '#ef4444', fontSize: 16 }} />
<Typography variant="h4" sx={{ color: '#ef4444', fontWeight: 700 }}>
{stats.failed}
</Typography>
</Box>
<Typography variant="caption" className="evidence-stat-label">
Échouées
</Typography>
</Box>
{/* Taux de succès */}
<Box className="evidence-stat-item">
<Typography
variant="h4"
sx={{
color: getSuccessRateColor(successRate),
fontWeight: 700
}}
>
{Math.round(successRate)}%
</Typography>
<Typography variant="caption" className="evidence-stat-label">
Taux de succès
</Typography>
<LinearProgress
variant="determinate"
value={successRate}
sx={{
mt: 0.5,
height: 4,
borderRadius: 2,
backgroundColor: '#334155',
'& .MuiLinearProgress-bar': {
backgroundColor: getSuccessRateColor(successRate)
}
}}
/>
</Box>
{/* Temps moyen */}
<Box className="evidence-stat-item">
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
<TimeIcon sx={{ color: '#1976d2', fontSize: 16 }} />
<Typography variant="h4" className="evidence-stat-value">
{EvidenceUtils.formatExecutionTime(stats.averageExecutionTime)}
</Typography>
</Box>
<Typography variant="caption" className="evidence-stat-label">
Temps moyen
</Typography>
</Box>
{/* Confiance moyenne */}
{stats.averageConfidence > 0 && (
<Box className="evidence-stat-item">
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
<ConfidenceIcon sx={{ color: '#1976d2', fontSize: 16 }} />
<Typography variant="h4" className="evidence-stat-value">
{EvidenceUtils.formatConfidence(stats.averageConfidence)}
</Typography>
</Box>
<Typography variant="caption" className="evidence-stat-label">
Confiance moyenne
</Typography>
</Box>
)}
</Box>
{/* Distribution des types d'actions */}
{Object.keys(stats.actionTypeDistribution).length > 0 && (
<Box mt={2}>
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', mb: 1 }}>
Répartition par type d'action
</Typography>
<Box display="flex" flexWrap="wrap" gap={1}>
{Object.entries(stats.actionTypeDistribution)
.sort(([,a], [,b]) => b - a) // Tri par nombre décroissant
.slice(0, 6) // Limite à 6 types max
.map(([actionType, count]) => (
<Chip
key={actionType}
label={`${actionType} (${count})`}
size="small"
variant="outlined"
sx={{
color: '#94a3b8',
borderColor: '#475569',
backgroundColor: '#334155'
}}
/>
))}
</Box>
</Box>
)}
{/* Timeline récente */}
{stats.timelineData.length > 0 && (
<Box mt={2}>
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', mb: 1 }}>
Activité récente (7 derniers jours)
</Typography>
<Box display="flex" gap={1} alignItems="end" height={40}>
{stats.timelineData
.slice(-7) // 7 derniers jours
.map((day, index) => {
const height = Math.max(4, (day.count / Math.max(...stats.timelineData.map(d => d.count))) * 32);
const color = day.successRate >= 0.9 ? '#22c55e' : day.successRate >= 0.7 ? '#f59e0b' : '#ef4444';
return (
<Box
key={day.date}
sx={{
width: 20,
height: height,
backgroundColor: color,
borderRadius: '2px 2px 0 0',
opacity: 0.8,
cursor: 'pointer',
'&:hover': { opacity: 1 }
}}
title={`${day.date}: ${day.count} Evidence (${Math.round(day.successRate * 100)}% succès)`}
/>
);
})}
</Box>
<Box display="flex" justifyContent="space-between" mt={0.5}>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{stats.timelineData.slice(-7)[0]?.date}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{stats.timelineData.slice(-1)[0]?.date}
</Typography>
</Box>
</Box>
)}
</Box>
);
};
export default EvidenceStatsComponent;

View File

@@ -0,0 +1,414 @@
/**
* Styles CSS pour le composant Evidence Viewer VWB
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
.evidence-viewer {
position: relative;
background: #1e293b;
border-radius: 12px;
border: 1px solid #334155;
color: #e2e8f0;
}
.evidence-viewer-fabs {
position: relative;
}
/* Liste des Evidence */
.evidence-list {
height: 100%;
overflow-y: auto;
padding: 8px;
}
.evidence-list-item {
margin-bottom: 8px;
padding: 12px;
background: #334155;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
}
.evidence-list-item:hover {
background: #475569;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.evidence-list-item.selected {
border-color: #1976d2;
background: #1e40af;
}
.evidence-list-item.error {
border-left: 4px solid #ef4444;
}
.evidence-list-item.success {
border-left: 4px solid #22c55e;
}
/* Vue grille */
.evidence-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
padding: 12px;
height: 100%;
overflow-y: auto;
}
.evidence-grid-item {
background: #334155;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
min-height: 200px;
display: flex;
flex-direction: column;
}
.evidence-grid-item:hover {
background: #475569;
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
}
.evidence-grid-item.selected {
border-color: #1976d2;
background: #1e40af;
}
/* En-tête d'Evidence */
.evidence-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.evidence-title {
font-weight: 600;
color: #e2e8f0;
font-size: 14px;
margin: 0;
}
.evidence-status {
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.evidence-status.success {
background: #22c55e;
color: white;
}
.evidence-status.error {
background: #ef4444;
color: white;
}
/* Métadonnées d'Evidence */
.evidence-metadata {
font-size: 12px;
color: #94a3b8;
line-height: 1.4;
}
.evidence-metadata-row {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.evidence-metadata-label {
font-weight: 500;
}
.evidence-metadata-value {
color: #e2e8f0;
}
/* Screenshot miniature */
.evidence-thumbnail {
width: 100%;
height: 80px;
object-fit: cover;
border-radius: 4px;
margin: 8px 0;
border: 1px solid #475569;
}
/* Détail d'Evidence */
.evidence-detail {
height: 100%;
overflow-y: auto;
padding: 16px;
background: #1e293b;
}
.evidence-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #334155;
}
.evidence-detail-title {
font-size: 18px;
font-weight: 600;
color: #e2e8f0;
margin: 0;
}
.evidence-detail-close {
color: #94a3b8;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
}
.evidence-detail-close:hover {
background: #334155;
color: #e2e8f0;
}
/* Screenshot principal */
.evidence-screenshot {
width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: 8px;
border: 1px solid #334155;
margin: 12px 0;
background: #0f172a;
}
/* Annotations sur screenshot */
.evidence-screenshot-container {
position: relative;
display: inline-block;
margin: 12px 0;
}
.evidence-annotation {
position: absolute;
border: 2px solid #1976d2;
background: rgba(25, 118, 210, 0.2);
pointer-events: none;
}
.evidence-annotation.click-point {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ef4444;
border: 2px solid white;
transform: translate(-50%, -50%);
}
.evidence-annotation.bbox {
background: rgba(25, 118, 210, 0.1);
border: 2px dashed #1976d2;
}
/* Panneau de métadonnées */
.evidence-metadata-panel {
background: #0f172a;
border-radius: 8px;
padding: 12px;
margin: 12px 0;
}
.evidence-metadata-panel h4 {
color: #e2e8f0;
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
}
.evidence-metadata-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
font-size: 12px;
}
.evidence-metadata-item {
display: flex;
flex-direction: column;
}
.evidence-metadata-item-label {
color: #94a3b8;
font-weight: 500;
margin-bottom: 2px;
}
.evidence-metadata-item-value {
color: #e2e8f0;
font-family: monospace;
}
/* Message d'erreur */
.evidence-error {
background: #7f1d1d;
border: 1px solid #ef4444;
border-radius: 8px;
padding: 12px;
margin: 12px 0;
}
.evidence-error-title {
color: #fecaca;
font-weight: 600;
margin-bottom: 4px;
}
.evidence-error-message {
color: #fee2e2;
font-size: 14px;
line-height: 1.4;
}
/* Statistiques */
.evidence-stats {
padding: 12px 16px;
background: #0f172a;
border-bottom: 1px solid #334155;
}
.evidence-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
}
.evidence-stat-item {
text-align: center;
}
.evidence-stat-value {
font-size: 20px;
font-weight: 700;
color: #1976d2;
margin-bottom: 4px;
}
.evidence-stat-label {
font-size: 12px;
color: #94a3b8;
text-transform: uppercase;
font-weight: 500;
}
/* Filtres */
.evidence-filters {
padding: 12px 16px;
background: #0f172a;
border-bottom: 1px solid #334155;
}
.evidence-filters-row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 8px;
}
.evidence-filters-row:last-child {
margin-bottom: 0;
}
/* Responsive */
@media (max-width: 768px) {
.evidence-grid {
grid-template-columns: 1fr;
gap: 8px;
padding: 8px;
}
.evidence-metadata-grid {
grid-template-columns: 1fr;
}
.evidence-stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.evidence-filters-row {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.evidence-viewer-fabs .MuiFab-root {
position: fixed !important;
bottom: 16px !important;
}
.evidence-viewer-fabs .MuiFab-root:nth-child(1) {
right: 16px !important;
}
.evidence-viewer-fabs .MuiFab-root:nth-child(2) {
right: 80px !important;
}
.evidence-viewer-fabs .MuiFab-root:nth-child(3) {
right: 144px !important;
}
}
/* Animations */
@keyframes evidenceAppear {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.evidence-list-item,
.evidence-grid-item {
animation: evidenceAppear 0.3s ease-out;
}
/* Scrollbar personnalisée */
.evidence-list::-webkit-scrollbar,
.evidence-detail::-webkit-scrollbar {
width: 6px;
}
.evidence-list::-webkit-scrollbar-track,
.evidence-detail::-webkit-scrollbar-track {
background: #1e293b;
}
.evidence-list::-webkit-scrollbar-thumb,
.evidence-detail::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 3px;
}
.evidence-list::-webkit-scrollbar-thumb:hover,
.evidence-detail::-webkit-scrollbar-thumb:hover {
background: #64748b;
}

View File

@@ -0,0 +1,461 @@
/**
* Panneau Evidence d'Exécution - Affichage temps réel des Evidence VWB
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Ce composant affiche les Evidence générées pendant l'exécution des workflows VWB
* avec mise à jour en temps réel et navigation dans l'historique.
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
Box,
Typography,
Paper,
Tabs,
Tab,
Badge,
IconButton,
Tooltip,
Chip,
Divider,
List,
ListItem,
ListItemText,
ListItemIcon,
Collapse,
Alert,
} from '@mui/material';
import {
Visibility as EvidenceIcon,
Screenshot as ScreenshotIcon,
Timeline as TimelineIcon,
FilterList as FilterIcon,
Refresh as RefreshIcon,
ExpandMore as ExpandIcon,
ExpandLess as CollapseIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Info as InfoIcon,
} from '@mui/icons-material';
// Import des composants Evidence existants
import EvidenceList from './EvidenceList';
import EvidenceDetail from './EvidenceDetail';
import ScreenshotViewer from './ScreenshotViewer';
import EvidenceFilters from './EvidenceFilters';
// Import des types
import { Evidence, Step, StepExecutionState } from '../../types';
import { VWBExecutionResult } from '../../services/vwbExecutionService';
// Import du hook d'exécution Evidence
import { useExecutionEvidence } from '../../hooks/useExecutionEvidence';
interface ExecutionEvidencePanelProps {
/** Étape actuellement en cours d'exécution */
currentStep?: Step;
/** Résultats d'exécution VWB */
executionResults: VWBExecutionResult[];
/** Callback lors de la sélection d'une Evidence */
onEvidenceSelect?: (evidence: Evidence) => void;
/** Callback lors du changement d'étape */
onStepChange?: (stepId: string) => void;
/** Mode d'affichage compact */
compact?: boolean;
/** Hauteur maximale du panneau */
maxHeight?: number;
}
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
/**
* Composant TabPanel pour les onglets
*/
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => (
<div hidden={value !== index} style={{ height: '100%' }}>
{value === index && children}
</div>
);
/**
* Panneau principal pour l'affichage des Evidence d'exécution
*/
const ExecutionEvidencePanel: React.FC<ExecutionEvidencePanelProps> = ({
currentStep,
executionResults,
onEvidenceSelect,
onStepChange,
compact = false,
maxHeight = 600,
}) => {
// État local
const [activeTab, setActiveTab] = useState(0);
const [selectedEvidence, setSelectedEvidence] = useState<Evidence | null>(null);
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
const [showFilters, setShowFilters] = useState(false);
// Hook pour la gestion des Evidence d'exécution
const {
allEvidence,
evidenceByStep,
currentStepEvidence,
addEvidence,
clearEvidence,
getEvidenceStats,
} = useExecutionEvidence();
// Synchroniser les Evidence avec les résultats d'exécution
useEffect(() => {
executionResults.forEach(result => {
if (result.evidence && result.evidence.length > 0) {
result.evidence.forEach(evidence => {
addEvidence(result.stepId, evidence);
});
}
});
}, [executionResults, addEvidence]);
// Statistiques des Evidence
const evidenceStats = useMemo(() => getEvidenceStats(), [getEvidenceStats]);
// Gérer la sélection d'Evidence
const handleEvidenceSelect = useCallback((evidence: Evidence) => {
setSelectedEvidence(evidence);
onEvidenceSelect?.(evidence);
}, [onEvidenceSelect]);
// Gérer l'expansion des étapes
const toggleStepExpansion = useCallback((stepId: string) => {
setExpandedSteps(prev => {
const newSet = new Set(prev);
if (newSet.has(stepId)) {
newSet.delete(stepId);
} else {
newSet.add(stepId);
}
return newSet;
});
}, []);
// Gérer le changement d'onglet
const handleTabChange = useCallback((_: React.SyntheticEvent, newValue: number) => {
setActiveTab(newValue);
}, []);
// Rendu de la liste des étapes avec Evidence
const renderStepsList = useCallback(() => {
const stepsWithEvidence = executionResults.filter(result =>
result.evidence && result.evidence.length > 0
);
if (stepsWithEvidence.length === 0) {
return (
<Alert severity="info" sx={{ m: 2 }}>
Aucune Evidence générée pour le moment.
Les Evidence apparaîtront automatiquement lors de l'exécution.
</Alert>
);
}
return (
<List sx={{ width: '100%' }}>
{stepsWithEvidence.map((result) => {
const isExpanded = expandedSteps.has(result.stepId);
const stepEvidence = result.evidence || [];
return (
<React.Fragment key={result.stepId}>
<ListItem
component="div"
onClick={() => toggleStepExpansion(result.stepId)}
sx={{
cursor: "pointer",
borderLeft: `4px solid ${result.success ? '#4caf50' : '#f44336'}`,
mb: 1,
borderRadius: 1,
backgroundColor: 'background.paper',
}}
>
<ListItemIcon>
{result.success ? (
<SuccessIcon color="success" />
) : (
<ErrorIcon color="error" />
)}
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">
Étape {result.stepId}
</Typography>
<Chip
label={result.actionId}
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem' }}
/>
<Badge
badgeContent={stepEvidence.length}
color="primary"
sx={{ ml: 'auto' }}
>
<EvidenceIcon fontSize="small" />
</Badge>
</Box>
}
secondary={`Durée: ${result.duration}ms`}
/>
<IconButton size="small">
{isExpanded ? <CollapseIcon /> : <ExpandIcon />}
</IconButton>
</ListItem>
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<Box sx={{ pl: 4, pr: 2, pb: 2 }}>
<EvidenceList
evidences={stepEvidence}
selectedId={selectedEvidence?.id}
onSelect={handleEvidenceSelect}
filters={{
actionTypes: [],
status: 'all',
dateRange: {},
searchText: '',
confidenceRange: { min: 0, max: 1 },
executionTimeRange: { min: 0, max: 10000 }
}}
onFiltersChange={() => {}}
sortBy="timestamp"
sortOrder="desc"
onSortChange={() => {}}
viewMode="list"
/>
</Box>
</Collapse>
</React.Fragment>
);
})}
</List>
);
}, [executionResults, expandedSteps, selectedEvidence, handleEvidenceSelect, toggleStepExpansion]);
// Rendu de la timeline des Evidence
const renderTimeline = useCallback(() => {
const sortedEvidence = [...allEvidence].sort((a, b) =>
new Date(b.captured_at).getTime() - new Date(a.captured_at).getTime()
);
return (
<Box sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Timeline des Evidence ({sortedEvidence.length})
</Typography>
<EvidenceList
evidences={sortedEvidence}
selectedId={selectedEvidence?.id}
onSelect={handleEvidenceSelect}
filters={{
actionTypes: [],
status: 'all',
dateRange: {},
searchText: '',
confidenceRange: { min: 0, max: 1 },
executionTimeRange: { min: 0, max: 10000 }
}}
onFiltersChange={() => {}}
sortBy="timestamp"
sortOrder="desc"
onSortChange={() => {}}
viewMode="list"
/>
</Box>
);
}, [allEvidence, selectedEvidence, handleEvidenceSelect, compact]);
// Rendu de l'étape actuelle
const renderCurrentStep = useCallback(() => {
if (!currentStep) {
return (
<Alert severity="info" sx={{ m: 2 }}>
Aucune étape en cours d'exécution
</Alert>
);
}
const stepEvidence = currentStepEvidence;
return (
<Box sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Étape Actuelle: {currentStep.name}
</Typography>
<Chip
label={currentStep.type}
color="primary"
size="small"
sx={{ mb: 2 }}
/>
{stepEvidence.length > 0 ? (
<EvidenceList
evidences={stepEvidence}
selectedId={selectedEvidence?.id}
onSelect={handleEvidenceSelect}
filters={{
actionTypes: [],
status: 'all',
dateRange: {},
searchText: '',
confidenceRange: { min: 0, max: 1 },
executionTimeRange: { min: 0, max: 10000 }
}}
onFiltersChange={() => {}}
sortBy="timestamp"
sortOrder="desc"
onSortChange={() => {}}
viewMode="list"
/>
) : (
<Typography variant="body2" color="text.secondary">
Aucune Evidence générée pour cette étape
</Typography>
)}
</Box>
);
}, [currentStep, currentStepEvidence, selectedEvidence, handleEvidenceSelect, compact]);
return (
<Paper
elevation={2}
sx={{
height: compact ? 'auto' : maxHeight,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* En-tête avec statistiques */}
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h6">
Evidence d'Exécution
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
label={`${evidenceStats.total} Evidence`}
size="small"
color="primary"
/>
<Chip
label={`${evidenceStats.screenshots} Screenshots`}
size="small"
variant="outlined"
/>
<Tooltip title="Filtres">
<IconButton
size="small"
onClick={() => setShowFilters(!showFilters)}
color={showFilters ? 'primary' : 'default'}
>
<FilterIcon />
</IconButton>
</Tooltip>
<Tooltip title="Actualiser">
<IconButton size="small" onClick={() => window.location.reload()}>
<RefreshIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
{/* Filtres */}
<Collapse in={showFilters}>
<Box sx={{ mt: 2 }}>
<EvidenceFilters
evidences={allEvidence}
filters={{
actionTypes: [],
status: 'all',
dateRange: {},
searchText: '',
confidenceRange: { min: 0, max: 1 },
executionTimeRange: { min: 0, max: 10000 }
}}
onFiltersChange={(filtered) => {
// Logique de filtrage à implémenter
console.log('Evidence filtrées:', filtered);
}}
onClearFilters={() => {
console.log('Filtres effacés');
}}
/>
</Box>
</Collapse>
</Box>
{/* Onglets */}
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="fullWidth"
sx={{ borderBottom: 1, borderColor: 'divider' }}
>
<Tab
label={
<Badge badgeContent={evidenceStats.byCurrentStep} color="primary">
Étape Actuelle
</Badge>
}
icon={<InfoIcon />}
/>
<Tab
label={
<Badge badgeContent={evidenceStats.bySteps} color="primary">
Par Étapes
</Badge>
}
icon={<EvidenceIcon />}
/>
<Tab
label={
<Badge badgeContent={evidenceStats.total} color="primary">
Timeline
</Badge>
}
icon={<TimelineIcon />}
/>
</Tabs>
{/* Contenu des onglets */}
<Box sx={{ flex: 1, overflow: 'auto' }}>
<TabPanel value={activeTab} index={0}>
{renderCurrentStep()}
</TabPanel>
<TabPanel value={activeTab} index={1}>
{renderStepsList()}
</TabPanel>
<TabPanel value={activeTab} index={2}>
{renderTimeline()}
</TabPanel>
</Box>
{/* Panneau de détail Evidence sélectionnée */}
{selectedEvidence && (
<Box sx={{ borderTop: 1, borderColor: 'divider' }}>
<EvidenceDetail
evidence={selectedEvidence}
onClose={() => setSelectedEvidence(null)}
/>
</Box>
)}
</Paper>
);
};
export default ExecutionEvidencePanel;

View File

@@ -0,0 +1,282 @@
/**
* Composant ScreenshotViewer - Visualiseur de screenshots avec annotations
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Box, Paper } from '@mui/material';
import { ScreenshotViewerProps, AnnotationData } from '../../types/evidence';
const ScreenshotViewer: React.FC<ScreenshotViewerProps> = ({
screenshot,
bbox,
clickPoint,
annotations = [],
zoom = 1,
onZoomChange,
maxWidth = 800,
maxHeight = 600
}) => {
// Références
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
// État local
const [imageLoaded, setImageLoaded] = useState(false);
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
// Chargement de l'image
const handleImageLoad = useCallback(() => {
if (imageRef.current) {
setImageDimensions({
width: imageRef.current.naturalWidth,
height: imageRef.current.naturalHeight
});
setImageLoaded(true);
}
}, []);
// Gestion du zoom avec la molette
const handleWheel = useCallback((event: React.WheelEvent) => {
if (!onZoomChange) return;
event.preventDefault();
const delta = event.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(0.1, Math.min(5, zoom * delta));
onZoomChange(newZoom);
}, [zoom, onZoomChange]);
// Début du pan
const handleMouseDown = useCallback((event: React.MouseEvent) => {
if (zoom <= 1) return; // Pas de pan si pas de zoom
setIsPanning(true);
setPanStart({
x: event.clientX - panOffset.x,
y: event.clientY - panOffset.y
});
}, [zoom, panOffset]);
// Pan en cours
const handleMouseMove = useCallback((event: React.MouseEvent) => {
if (!isPanning) return;
setPanOffset({
x: event.clientX - panStart.x,
y: event.clientY - panStart.y
});
}, [isPanning, panStart]);
// Fin du pan
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
// Reset du pan quand le zoom change
useEffect(() => {
if (zoom <= 1) {
setPanOffset({ x: 0, y: 0 });
}
}, [zoom]);
// Calcul des dimensions d'affichage
const getDisplayDimensions = () => {
if (!imageLoaded) return { width: maxWidth, height: maxHeight };
const aspectRatio = imageDimensions.width / imageDimensions.height;
let displayWidth = imageDimensions.width;
let displayHeight = imageDimensions.height;
// Ajustement aux dimensions max
if (displayWidth > maxWidth) {
displayWidth = maxWidth;
displayHeight = displayWidth / aspectRatio;
}
if (displayHeight > maxHeight) {
displayHeight = maxHeight;
displayWidth = displayHeight * aspectRatio;
}
return {
width: displayWidth * zoom,
height: displayHeight * zoom
};
};
// Conversion des coordonnées d'annotation
const convertCoordinates = (coord: { x: number; y: number; width?: number; height?: number }) => {
if (!imageLoaded) return coord;
const displayDims = getDisplayDimensions();
const scaleX = displayDims.width / imageDimensions.width;
const scaleY = displayDims.height / imageDimensions.height;
return {
x: coord.x * scaleX,
y: coord.y * scaleY,
width: coord.width ? coord.width * scaleX : undefined,
height: coord.height ? coord.height * scaleY : undefined
};
};
// Rendu des annotations
const renderAnnotations = () => {
if (!imageLoaded) return null;
const allAnnotations: AnnotationData[] = [];
// Ajout de la bbox si présente
if (bbox) {
allAnnotations.push({
id: 'bbox',
type: 'bbox',
position: { x: bbox.x, y: bbox.y },
coordinates: bbox,
label: 'Zone détectée',
color: '#1976d2',
opacity: 0.3,
data: { type: 'bbox', bbox }
});
}
// Ajout du point de clic si présent
if (clickPoint) {
allAnnotations.push({
id: 'click',
type: 'click',
position: { x: clickPoint.x, y: clickPoint.y },
coordinates: clickPoint,
label: 'Point de clic',
color: '#ef4444',
opacity: 1,
data: { type: 'click', clickPoint }
});
}
// Ajout des annotations personnalisées
allAnnotations.push(...annotations);
return allAnnotations.map(annotation => {
const coords = annotation.coordinates ? convertCoordinates(annotation.coordinates) : { x: annotation.position.x, y: annotation.position.y };
if (annotation.type === 'click') {
return (
<div
key={annotation.id}
className="evidence-annotation click-point"
style={{
position: 'absolute',
left: coords.x - 6,
top: coords.y - 6,
width: 12,
height: 12,
borderRadius: '50%',
backgroundColor: annotation.color || '#ef4444',
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
zIndex: 10
}}
title={annotation.label}
/>
);
}
if (annotation.type === 'bbox' && coords.width && coords.height) {
return (
<div
key={annotation.id}
className="evidence-annotation bbox"
style={{
position: 'absolute',
left: coords.x,
top: coords.y,
width: coords.width,
height: coords.height,
border: `2px dashed ${annotation.color || '#1976d2'}`,
backgroundColor: `${annotation.color || '#1976d2'}${Math.round((annotation.opacity || 0.3) * 255).toString(16).padStart(2, '0')}`,
pointerEvents: 'none',
zIndex: 5
}}
title={annotation.label}
/>
);
}
return null;
});
};
const displayDims = getDisplayDimensions();
return (
<Paper
ref={containerRef}
sx={{
position: 'relative',
display: 'inline-block',
maxWidth: '100%',
maxHeight: maxHeight,
overflow: zoom > 1 ? 'auto' : 'hidden',
cursor: zoom > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
backgroundColor: '#0f172a',
border: '1px solid #334155'
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
className="evidence-screenshot-container"
style={{
position: 'relative',
display: 'inline-block',
transform: `translate(${panOffset.x}px, ${panOffset.y}px)`,
transition: isPanning ? 'none' : 'transform 0.1s ease'
}}
>
<img
ref={imageRef}
src={`data:image/png;base64,${screenshot}`}
alt="Screenshot Evidence"
className="evidence-screenshot"
style={{
width: displayDims.width,
height: displayDims.height,
display: 'block',
userSelect: 'none',
pointerEvents: 'none'
}}
onLoad={handleImageLoad}
onError={() => setImageLoaded(false)}
/>
{/* Annotations */}
{renderAnnotations()}
</div>
{/* Indicateur de chargement */}
{!imageLoaded && (
<Box
position="absolute"
top="50%"
left="50%"
sx={{
transform: 'translate(-50%, -50%)',
color: '#94a3b8',
fontSize: '14px'
}}
>
Chargement de l'image...
</Box>
)}
</Paper>
);
};
export default ScreenshotViewer;

View File

@@ -0,0 +1,322 @@
/**
* Composant Evidence Viewer VWB - Visualisation des preuves d'exécution
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import React, { useState, useCallback } from 'react';
import {
Box,
Paper,
Typography,
Divider,
Alert,
CircularProgress,
Fab,
Tooltip,
useTheme,
useMediaQuery
} from '@mui/material';
import {
Refresh as RefreshIcon,
GetApp as ExportIcon,
FilterList as FilterIcon,
ViewList as ListIcon,
ViewModule as GridIcon
} from '@mui/icons-material';
import { EvidenceViewerProps, VWBEvidence } from '../../types/evidence';
import { useEvidenceViewer } from '../../hooks/useEvidenceViewer';
import EvidenceList from './EvidenceList';
import EvidenceDetail from './EvidenceDetail';
import EvidenceFilters from './EvidenceFilters';
import EvidenceStats from './EvidenceStats';
import './EvidenceViewer.css';
const EvidenceViewer: React.FC<EvidenceViewerProps> = ({
evidences: externalEvidences,
selectedEvidenceId: externalSelectedId,
onEvidenceSelect,
onExport,
showFilters = true,
maxHeight = 600,
className = ''
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
// État local
const [viewMode, setViewMode] = useState<'list' | 'grid'>('list');
const [showFiltersPanel, setShowFiltersPanel] = useState(false);
const [showStatsPanel, setShowStatsPanel] = useState(true);
// Hook Evidence Viewer (utilisé seulement si pas d'Evidence externes)
const {
evidences: internalEvidences,
filteredEvidences,
selectedEvidence,
stats,
loading,
error,
filters,
sortBy,
sortOrder,
setSelectedEvidenceId: setInternalSelectedId,
setFilters,
setSorting,
refreshEvidences,
clearFilters,
exportEvidences,
hasFilters,
isServiceAvailable
} = useEvidenceViewer({
autoRefresh: !externalEvidences, // Auto-refresh seulement si pas d'Evidence externes
refreshInterval: 30000
});
// Utilisation des Evidence externes ou internes
const evidences = externalEvidences || internalEvidences;
const displayedEvidences = externalEvidences || filteredEvidences;
const currentSelectedId = externalSelectedId || selectedEvidence?.id;
// Gestion de la sélection d'Evidence
const handleEvidenceSelect = useCallback((evidence: VWBEvidence) => {
const evidenceId = evidence.id;
if (onEvidenceSelect) {
onEvidenceSelect(evidenceId);
} else {
setInternalSelectedId(evidenceId);
}
}, [onEvidenceSelect, setInternalSelectedId]);
// Gestion de l'export
const handleExport = useCallback(async (format: 'json' | 'html' | 'pdf') => {
if (onExport) {
onExport(displayedEvidences);
} else {
await exportEvidences(format);
}
}, [onExport, displayedEvidences, exportEvidences]);
// Gestion du refresh
const handleRefresh = useCallback(async () => {
if (!externalEvidences) {
await refreshEvidences();
}
}, [externalEvidences, refreshEvidences]);
// Evidence sélectionnée
const selectedEvidenceData = currentSelectedId
? evidences.find(e => e.id === currentSelectedId)
: null;
// Rendu du contenu principal
const renderContent = () => {
if (loading && !externalEvidences) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight={200}>
<CircularProgress size={40} />
<Typography variant="body2" sx={{ ml: 2 }}>
Chargement des Evidence...
</Typography>
</Box>
);
}
if (error && !externalEvidences) {
return (
<Alert severity="error" sx={{ m: 2 }}>
{error}
{!isServiceAvailable && (
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
Le service Evidence n'est pas disponible. Vérifiez que le backend VWB est démarré.
</Typography>
)}
</Alert>
);
}
if (displayedEvidences.length === 0) {
return (
<Box textAlign="center" py={4}>
<Typography variant="h6" color="text.secondary" gutterBottom>
Aucune Evidence disponible
</Typography>
<Typography variant="body2" color="text.secondary">
{hasFilters
? 'Aucune Evidence ne correspond aux filtres appliqués.'
: 'Aucune Evidence d\'exécution n\'a été trouvée.'}
</Typography>
</Box>
);
}
return (
<Box display="flex" height="100%">
{/* Liste des Evidence */}
<Box
flex={selectedEvidenceData ? (isMobile ? 0 : 1) : 1}
display={selectedEvidenceData && isMobile ? 'none' : 'block'}
>
<EvidenceList
evidences={displayedEvidences}
selectedId={currentSelectedId}
onSelect={handleEvidenceSelect}
filters={externalEvidences ? undefined : filters}
onFiltersChange={externalEvidences ? undefined : setFilters}
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={setSorting}
viewMode={viewMode}
showFilters={showFilters && showFiltersPanel}
/>
</Box>
{/* Détail de l'Evidence sélectionnée */}
{selectedEvidenceData && (
<>
<Divider orientation="vertical" flexItem />
<Box
flex={isMobile ? 1 : 1}
display={selectedEvidenceData ? 'block' : 'none'}
>
<EvidenceDetail
evidence={selectedEvidenceData}
onClose={isMobile ? () => setInternalSelectedId('') : undefined}
showMetadata={true}
/>
</Box>
</>
)}
</Box>
);
};
return (
<Paper
className={`evidence-viewer ${className}`}
sx={{
height: maxHeight,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
role="region"
aria-label="Visualiseur d'Evidence VWB"
>
{/* En-tête avec statistiques */}
{showStatsPanel && !externalEvidences && (
<>
<EvidenceStats
stats={stats}
totalEvidences={evidences.length}
filteredCount={displayedEvidences.length}
hasFilters={hasFilters}
/>
<Divider />
</>
)}
{/* Panneau de filtres */}
{showFilters && showFiltersPanel && !externalEvidences && (
<>
<EvidenceFilters
filters={filters}
onFiltersChange={setFilters}
onClearFilters={clearFilters}
evidences={evidences}
/>
<Divider />
</>
)}
{/* Contenu principal */}
<Box flex={1} overflow="hidden">
{renderContent()}
</Box>
{/* Boutons d'action flottants */}
<Box className="evidence-viewer-fabs">
{/* Bouton Refresh */}
{!externalEvidences && (
<Tooltip title="Actualiser les Evidence">
<Fab
size="small"
color="primary"
onClick={handleRefresh}
disabled={loading}
aria-label="Actualiser les Evidence"
sx={{
position: 'absolute',
bottom: 16,
right: 16,
zIndex: 1000
}}
>
<RefreshIcon />
</Fab>
</Tooltip>
)}
{/* Bouton Export */}
<Tooltip title="Exporter les Evidence">
<Fab
size="small"
color="secondary"
onClick={() => handleExport('html')}
aria-label="Exporter les Evidence"
sx={{
position: 'absolute',
bottom: 16,
right: 80,
zIndex: 1000
}}
>
<ExportIcon />
</Fab>
</Tooltip>
{/* Bouton Filtres */}
{showFilters && !externalEvidences && (
<Tooltip title={showFiltersPanel ? "Masquer les filtres" : "Afficher les filtres"}>
<Fab
size="small"
color={showFiltersPanel ? "primary" : "default"}
onClick={() => setShowFiltersPanel(!showFiltersPanel)}
aria-label={showFiltersPanel ? "Masquer les filtres" : "Afficher les filtres"}
aria-pressed={showFiltersPanel}
sx={{
position: 'absolute',
bottom: 16,
right: 144,
zIndex: 1000
}}
>
<FilterIcon />
</Fab>
</Tooltip>
)}
{/* Bouton Mode d'affichage */}
<Tooltip title={viewMode === 'list' ? "Vue grille" : "Vue liste"}>
<Fab
size="small"
color="default"
onClick={() => setViewMode(viewMode === 'list' ? 'grid' : 'list')}
aria-label={viewMode === 'list' ? "Basculer en vue grille" : "Basculer en vue liste"}
sx={{
position: 'absolute',
bottom: 16,
right: 208,
zIndex: 1000
}}
>
{viewMode === 'list' ? <GridIcon /> : <ListIcon />}
</Fab>
</Tooltip>
</Box>
</Paper>
);
};
export default EvidenceViewer;

View File

@@ -0,0 +1,243 @@
/**
* Styles CSS pour les Contrôles d'Exécution VWB
* Auteur : Dom, Alice, Kiro - 11 janvier 2026
*/
.execution-controls {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
background: #fafafa;
border-radius: 8px;
}
.execution-controls-main {
display: flex;
flex-direction: column;
gap: 12px;
}
.execution-controls-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.execution-controls-advanced {
background: #f5f5f5;
padding: 16px;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.execution-controls-settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
margin-top: 12px;
}
.execution-controls-slider-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.execution-controls-switches {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-top: 12px;
}
.execution-controls-status {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: #e3f2fd;
border-radius: 8px;
border-left: 4px solid #2196f3;
}
.execution-controls-status.paused {
background: #fff3e0;
border-left-color: #ff9800;
}
.execution-controls-status.error {
background: #ffebee;
border-left-color: #f44336;
}
.execution-controls-stats {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.execution-controls-breakpoint-indicator {
position: relative;
}
.execution-controls-breakpoint-indicator::before {
content: '';
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: #ff5722;
border-radius: 50%;
border: 2px solid white;
}
.execution-controls-save-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
min-width: 320px;
z-index: 1300;
}
.execution-controls-save-dialog-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1299;
}
/* Animations pour les états d'exécution */
@keyframes execution-pulse {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}
.execution-controls-running {
animation: execution-pulse 2s infinite;
}
.execution-controls-paused {
animation: execution-pulse 1s infinite;
}
/* Responsive design */
@media (max-width: 768px) {
.execution-controls-buttons {
flex-direction: column;
align-items: stretch;
}
.execution-controls-settings-grid {
grid-template-columns: 1fr;
}
.execution-controls-switches {
flex-direction: column;
gap: 8px;
}
.execution-controls-status {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
/* Mode sombre */
@media (prefers-color-scheme: dark) {
.execution-controls {
background: #1e1e1e;
color: #e0e0e0;
}
.execution-controls-advanced {
background: #2d2d2d;
border-color: #404040;
}
.execution-controls-save-dialog {
background: #2d2d2d;
color: #e0e0e0;
}
}
/* Indicateurs de performance */
.execution-controls-performance-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
.execution-controls-performance-indicator.fast {
background: #e8f5e8;
color: #2e7d32;
}
.execution-controls-performance-indicator.normal {
background: #fff3e0;
color: #f57c00;
}
.execution-controls-performance-indicator.slow {
background: #ffebee;
color: #d32f2f;
}
/* Transitions fluides */
.execution-controls * {
transition: all 0.2s ease-in-out;
}
.execution-controls button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
}
.execution-controls button:active {
transform: translateY(0);
}
/* Indicateurs d'état spéciaux */
.execution-controls-step-indicator {
position: relative;
display: inline-block;
}
.execution-controls-step-indicator.current::after {
content: '';
position: absolute;
top: 50%;
right: -8px;
transform: translateY(-50%);
width: 0;
height: 0;
border-left: 6px solid #2196f3;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
}
.execution-controls-step-indicator.breakpoint::before {
content: '●';
position: absolute;
top: -4px;
right: -4px;
color: #ff5722;
font-size: 12px;
line-height: 1;
}

View File

@@ -0,0 +1,746 @@
/**
* Contrôles d'Exécution VWB - Interface de contrôle avancée pour l'exécution des workflows
* Auteur : Dom, Alice, Kiro - 11 janvier 2026
*
* Ce composant fournit une interface complète de contrôle d'exécution avec :
* - Contrôles play/pause/stop
* - Mode pas-à-pas pour le débogage
* - Sauvegarde/restauration d'état d'exécution
* - Gestion avancée des breakpoints
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import {
Box,
Button,
ButtonGroup,
Card,
CardContent,
Typography,
Divider,
Switch,
FormControlLabel,
Slider,
Tooltip,
IconButton,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Chip,
Alert,
Collapse,
TextField,
InputAdornment,
} from '@mui/material';
import {
PlayArrow as PlayIcon,
Pause as PauseIcon,
Stop as StopIcon,
SkipNext as StepIcon,
Replay as ResetIcon,
Save as SaveIcon,
Restore as RestoreIcon,
Settings as SettingsIcon,
Speed as SpeedIcon,
BugReport as DebugIcon,
Timeline as TimelineIcon,
Bookmark as BookmarkIcon,
BookmarkBorder as BookmarkBorderIcon,
MoreVert as MoreIcon,
Schedule as ScheduleIcon,
Memory as MemoryIcon,
} from '@mui/icons-material';
// Import des hooks et types
import { useVWBExecution, VWBExecutionState } from '../../hooks/useVWBExecution';
import { Step, Workflow, Variable } from '../../types';
export interface ExecutionControlsProps {
workflow: Workflow;
variables: Variable[];
executionState: VWBExecutionState;
onStepStateChange?: (stepId: string, state: any) => void;
onExecutionComplete?: (success: boolean, summary: any) => void;
onEvidenceGenerated?: (stepId: string, evidence: any[]) => void;
debugMode?: boolean;
onDebugModeChange?: (enabled: boolean) => void;
className?: string;
}
export interface ExecutionSettings {
autoValidate: boolean;
generateEvidence: boolean;
retryAttempts: number;
timeout: number;
pauseOnError: boolean;
skipNonVWBSteps: boolean;
stepDelay: number;
enableBreakpoints: boolean;
}
export interface ExecutionSaveState {
id: string;
name: string;
timestamp: Date;
workflowId: string;
currentStepIndex: number;
variables: Variable[];
settings: ExecutionSettings;
results: any[];
evidence: any[];
}
/**
* Composant principal des contrôles d'exécution
*/
const ExecutionControls: React.FC<ExecutionControlsProps> = ({
workflow,
variables,
executionState,
onStepStateChange,
onExecutionComplete,
onEvidenceGenerated,
debugMode = false,
onDebugModeChange,
className,
}) => {
// États locaux
const [settings, setSettings] = useState<ExecutionSettings>({
autoValidate: true,
generateEvidence: true,
retryAttempts: 3,
timeout: 30000,
pauseOnError: false,
skipNonVWBSteps: false,
stepDelay: 0,
enableBreakpoints: false,
});
const [stepByStepMode, setStepByStepMode] = useState(false);
const [breakpoints, setBreakpoints] = useState<Set<string>>(new Set());
const [savedStates, setSavedStates] = useState<ExecutionSaveState[]>([]);
const [settingsMenuAnchor, setSettingsMenuAnchor] = useState<null | HTMLElement>(null);
const [saveStateDialogOpen, setSaveStateDialogOpen] = useState(false);
const [saveStateName, setSaveStateName] = useState('');
const [showAdvancedControls, setShowAdvancedControls] = useState(false);
// Hook d'exécution VWB avec paramètres dynamiques
const {
isRunning,
isPaused,
canStart,
canPause,
canResume,
canStop,
startExecution,
pauseExecution,
resumeExecution,
stopExecution,
resetExecution,
getExecutionSummary,
isVWBStep,
} = useVWBExecution(
workflow,
variables,
{
onStepStart: (step, index) => {
// Vérifier les breakpoints
if (settings.enableBreakpoints && breakpoints.has(step.id)) {
pauseExecution();
}
onStepStateChange?.(step.id, 'running');
},
onStepComplete: (step, result) => {
onStepStateChange?.(step.id, result.success ? 'success' : 'error');
if (result.evidence) {
onEvidenceGenerated?.(step.id, result.evidence);
}
// Mode pas-à-pas : pause après chaque étape
if (stepByStepMode && isRunning) {
setTimeout(() => pauseExecution(), 100);
}
},
onStepError: (step, error) => {
onStepStateChange?.(step.id, 'error');
},
onExecutionComplete: (success, summary) => {
onExecutionComplete?.(success, summary);
},
onEvidenceGenerated: onEvidenceGenerated,
},
{
autoValidate: settings.autoValidate,
generateEvidence: settings.generateEvidence,
retryAttempts: settings.retryAttempts,
timeout: settings.timeout,
pauseOnError: settings.pauseOnError,
skipNonVWBSteps: settings.skipNonVWBSteps,
}
);
// Charger les états sauvegardés depuis localStorage
useEffect(() => {
const saved = localStorage.getItem('vwb_execution_saved_states');
if (saved) {
try {
const states = JSON.parse(saved);
setSavedStates(states.map((state: any) => ({
...state,
timestamp: new Date(state.timestamp),
})));
} catch (error) {
console.error('Erreur lors du chargement des états sauvegardés:', error);
}
}
}, []);
// Sauvegarder les états dans localStorage
const saveSavedStates = useCallback((states: ExecutionSaveState[]) => {
try {
localStorage.setItem('vwb_execution_saved_states', JSON.stringify(states));
setSavedStates(states);
} catch (error) {
console.error('Erreur lors de la sauvegarde des états:', error);
}
}, []);
// Gestionnaire d'exécution avec délai
const handleStartExecution = useCallback(async () => {
if (settings.stepDelay > 0) {
// Implémentation du délai entre étapes (simulation)
console.log(`Démarrage avec délai de ${settings.stepDelay}ms entre les étapes`);
}
await startExecution();
}, [startExecution, settings.stepDelay]);
// Gestionnaire d'exécution pas-à-pas
const handleStepByStepExecution = useCallback(() => {
setStepByStepMode(true);
handleStartExecution();
}, [handleStartExecution]);
// Gestionnaire de reprise pas-à-pas
const handleStepByStepResume = useCallback(() => {
if (isPaused && stepByStepMode) {
resumeExecution();
}
}, [isPaused, stepByStepMode, resumeExecution]);
// Gestionnaire d'arrêt
const handleStopExecution = useCallback(() => {
setStepByStepMode(false);
stopExecution();
}, [stopExecution]);
// Gestionnaire de réinitialisation
const handleResetExecution = useCallback(() => {
setStepByStepMode(false);
setBreakpoints(new Set());
resetExecution();
}, [resetExecution]);
// Basculer un breakpoint
const toggleBreakpoint = useCallback((stepId: string) => {
setBreakpoints(prev => {
const newBreakpoints = new Set(prev);
if (newBreakpoints.has(stepId)) {
newBreakpoints.delete(stepId);
} else {
newBreakpoints.add(stepId);
}
return newBreakpoints;
});
}, []);
// Sauvegarder l'état d'exécution
const saveExecutionState = useCallback(() => {
if (!saveStateName.trim()) return;
const saveState: ExecutionSaveState = {
id: `state_${Date.now()}`,
name: saveStateName.trim(),
timestamp: new Date(),
workflowId: workflow.id,
currentStepIndex: executionState.currentStepIndex,
variables,
settings,
results: executionState.results,
evidence: executionState.evidence,
};
const newStates = [...savedStates, saveState];
saveSavedStates(newStates);
setSaveStateName('');
setSaveStateDialogOpen(false);
}, [saveStateName, workflow.id, executionState, variables, settings, savedStates, saveSavedStates]);
// Restaurer un état d'exécution
const restoreExecutionState = useCallback((saveState: ExecutionSaveState) => {
// Arrêter l'exécution en cours
if (isRunning) {
stopExecution();
}
// Restaurer les paramètres
setSettings(saveState.settings);
// Note: La restauration complète nécessiterait une intégration plus profonde
// avec le système de gestion d'état du workflow
console.log('Restauration de l\'état:', saveState);
setSettingsMenuAnchor(null);
}, [isRunning, stopExecution]);
// Supprimer un état sauvegardé
const deleteSavedState = useCallback((stateId: string) => {
const newStates = savedStates.filter(state => state.id !== stateId);
saveSavedStates(newStates);
}, [savedStates, saveSavedStates]);
// Statistiques d'exécution
const executionStats = useMemo(() => {
const vwbSteps = workflow.steps.filter(step => isVWBStep(step));
return {
totalSteps: workflow.steps.length,
vwbSteps: vwbSteps.length,
breakpointsSet: breakpoints.size,
estimatedDuration: workflow.steps.length * (settings.stepDelay + 1000), // Estimation
};
}, [workflow.steps, isVWBStep, breakpoints.size, settings.stepDelay]);
return (
<Box className={className} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Contrôles principaux */}
<Card variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" component="h3">
Contrôles d'Exécution
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip
label={`${executionStats.totalSteps} étapes`}
size="small"
icon={<TimelineIcon />}
/>
<Chip
label={`${executionStats.vwbSteps} VWB`}
size="small"
color="primary"
/>
{breakpoints.size > 0 && (
<Chip
label={`${breakpoints.size} breakpoints`}
size="small"
color="warning"
icon={<BookmarkIcon />}
/>
)}
</Box>
</Box>
{/* Boutons de contrôle principaux */}
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
<ButtonGroup variant="contained" size="large">
{/* Bouton Exécuter - toujours visible */}
<Tooltip title={canStart ? "Démarrer l'exécution complète" : "Exécution en cours ou pas d'étapes"}>
<span>
<Button
startIcon={<PlayIcon />}
onClick={() => {
console.log('🔘 [Controls] Clic sur Exécuter', { canStart, stepsLength: workflow.steps.length });
handleStartExecution();
}}
color="success"
disabled={!canStart || workflow.steps.length === 0}
>
Exécuter ({workflow.steps.length})
</Button>
</span>
</Tooltip>
{canPause && (
<Tooltip title="Mettre en pause">
<Button
startIcon={<PauseIcon />}
onClick={pauseExecution}
color="warning"
>
Pause
</Button>
</Tooltip>
)}
{canResume && (
<Tooltip title="Reprendre l'exécution">
<Button
startIcon={<PlayIcon />}
onClick={resumeExecution}
color="success"
>
Reprendre
</Button>
</Tooltip>
)}
{canStop && (
<Tooltip title="Arrêter l'exécution">
<Button
startIcon={<StopIcon />}
onClick={handleStopExecution}
color="error"
>
Arrêter
</Button>
</Tooltip>
)}
</ButtonGroup>
{/* Contrôles pas-à-pas */}
<ButtonGroup variant="outlined">
{canStart && (
<Tooltip title="Exécution pas-à-pas">
<Button
startIcon={<StepIcon />}
onClick={handleStepByStepExecution}
disabled={workflow.steps.length === 0}
>
Pas-à-pas
</Button>
</Tooltip>
)}
{stepByStepMode && isPaused && (
<Tooltip title="Étape suivante">
<Button
startIcon={<StepIcon />}
onClick={handleStepByStepResume}
color="primary"
>
Suivant
</Button>
</Tooltip>
)}
</ButtonGroup>
{/* Contrôles de gestion d'état */}
<ButtonGroup variant="outlined">
<Tooltip title="Réinitialiser">
<Button
startIcon={<ResetIcon />}
onClick={handleResetExecution}
disabled={isRunning}
>
Reset
</Button>
</Tooltip>
<Tooltip title="Sauvegarder l'état">
<Button
startIcon={<SaveIcon />}
onClick={() => setSaveStateDialogOpen(true)}
disabled={executionState.status === 'idle'}
>
Sauver
</Button>
</Tooltip>
<Tooltip title="Paramètres et états sauvegardés">
<IconButton
onClick={(e) => setSettingsMenuAnchor(e.currentTarget)}
>
<SettingsIcon />
</IconButton>
</Tooltip>
</ButtonGroup>
</Box>
{/* Mode debug et contrôles avancés */}
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<FormControlLabel
control={
<Switch
checked={debugMode}
onChange={(e) => onDebugModeChange?.(e.target.checked)}
/>
}
label="Mode Debug"
/>
<FormControlLabel
control={
<Switch
checked={stepByStepMode}
onChange={(e) => setStepByStepMode(e.target.checked)}
disabled={isRunning}
/>
}
label="Pas-à-pas"
/>
<FormControlLabel
control={
<Switch
checked={settings.enableBreakpoints}
onChange={(e) => setSettings(prev => ({ ...prev, enableBreakpoints: e.target.checked }))}
/>
}
label="Breakpoints"
/>
<Button
variant="text"
size="small"
onClick={() => setShowAdvancedControls(!showAdvancedControls)}
>
{showAdvancedControls ? 'Masquer' : 'Afficher'} les contrôles avancés
</Button>
</Box>
{/* Contrôles avancés */}
<Collapse in={showAdvancedControls}>
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
<Typography variant="subtitle2" gutterBottom>
Paramètres d'Exécution
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: 2 }}>
{/* Délai entre étapes */}
<Box>
<Typography variant="body2" gutterBottom>
Délai entre étapes: {settings.stepDelay}ms
</Typography>
<Slider
value={settings.stepDelay}
onChange={(_, value) => setSettings(prev => ({ ...prev, stepDelay: value as number }))}
min={0}
max={5000}
step={100}
disabled={isRunning}
valueLabelDisplay="auto"
/>
</Box>
{/* Timeout */}
<Box>
<Typography variant="body2" gutterBottom>
Timeout: {settings.timeout / 1000}s
</Typography>
<Slider
value={settings.timeout}
onChange={(_, value) => setSettings(prev => ({ ...prev, timeout: value as number }))}
min={5000}
max={120000}
step={5000}
disabled={isRunning}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value / 1000}s`}
/>
</Box>
{/* Tentatives de retry */}
<Box>
<Typography variant="body2" gutterBottom>
Tentatives de retry: {settings.retryAttempts}
</Typography>
<Slider
value={settings.retryAttempts}
onChange={(_, value) => setSettings(prev => ({ ...prev, retryAttempts: value as number }))}
min={0}
max={10}
step={1}
disabled={isRunning}
valueLabelDisplay="auto"
/>
</Box>
</Box>
<Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<FormControlLabel
control={
<Switch
checked={settings.autoValidate}
onChange={(e) => setSettings(prev => ({ ...prev, autoValidate: e.target.checked }))}
disabled={isRunning}
/>
}
label="Validation automatique"
/>
<FormControlLabel
control={
<Switch
checked={settings.generateEvidence}
onChange={(e) => setSettings(prev => ({ ...prev, generateEvidence: e.target.checked }))}
disabled={isRunning}
/>
}
label="Générer Evidence"
/>
<FormControlLabel
control={
<Switch
checked={settings.pauseOnError}
onChange={(e) => setSettings(prev => ({ ...prev, pauseOnError: e.target.checked }))}
disabled={isRunning}
/>
}
label="Pause sur erreur"
/>
<FormControlLabel
control={
<Switch
checked={settings.skipNonVWBSteps}
onChange={(e) => setSettings(prev => ({ ...prev, skipNonVWBSteps: e.target.checked }))}
disabled={isRunning}
/>
}
label="Ignorer étapes non-VWB"
/>
</Box>
</Box>
</Collapse>
</CardContent>
</Card>
{/* Informations d'état */}
{(isRunning || isPaused) && (
<Alert severity={isPaused ? 'warning' : 'info'}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant="body2">
{isPaused ? 'Exécution en pause' : 'Exécution en cours'}
{stepByStepMode && ' (Mode pas-à-pas)'}
</Typography>
{executionState.currentStep && (
<Typography variant="caption" color="text.secondary">
Étape actuelle: {executionState.currentStep.name}
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip
label={`${executionState.completedSteps}/${executionState.totalSteps}`}
size="small"
icon={<ScheduleIcon />}
/>
{executionState.evidence.length > 0 && (
<Chip
label={`${executionState.evidence.length} Evidence`}
size="small"
color="info"
/>
)}
</Box>
</Box>
</Alert>
)}
{/* Menu des paramètres */}
<Menu
anchorEl={settingsMenuAnchor}
open={Boolean(settingsMenuAnchor)}
onClose={() => setSettingsMenuAnchor(null)}
>
<MenuItem onClick={() => setShowAdvancedControls(!showAdvancedControls)}>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Paramètres avancés" />
</MenuItem>
<Divider />
{savedStates.length > 0 && (
<>
<MenuItem disabled>
<ListItemText primary="États sauvegardés" />
</MenuItem>
{savedStates.slice(-5).map((state) => (
<MenuItem
key={state.id}
onClick={() => restoreExecutionState(state)}
>
<ListItemIcon>
<RestoreIcon />
</ListItemIcon>
<ListItemText
primary={state.name}
secondary={state.timestamp.toLocaleString()}
/>
</MenuItem>
))}
<Divider />
</>
)}
<MenuItem onClick={() => setSaveStateDialogOpen(true)}>
<ListItemIcon>
<SaveIcon />
</ListItemIcon>
<ListItemText primary="Sauvegarder l'état actuel" />
</MenuItem>
</Menu>
{/* Dialog de sauvegarde d'état */}
{saveStateDialogOpen && (
<Box
sx={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
bgcolor: 'background.paper',
boxShadow: 24,
p: 3,
borderRadius: 2,
minWidth: 300,
zIndex: 1300,
}}
>
<Typography variant="h6" gutterBottom>
Sauvegarder l'État d'Exécution
</Typography>
<TextField
fullWidth
label="Nom de la sauvegarde"
value={saveStateName}
onChange={(e) => setSaveStateName(e.target.value)}
margin="normal"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SaveIcon />
</InputAdornment>
),
}}
/>
<Box sx={{ display: 'flex', gap: 2, mt: 2 }}>
<Button
variant="contained"
onClick={saveExecutionState}
disabled={!saveStateName.trim()}
>
Sauvegarder
</Button>
<Button
variant="outlined"
onClick={() => {
setSaveStateDialogOpen(false);
setSaveStateName('');
}}
>
Annuler
</Button>
</Box>
</Box>
)}
</Box>
);
};
export default ExecutionControls;

View File

@@ -0,0 +1,11 @@
/**
* Index des Contrôles d'Exécution VWB
* Auteur : Dom, Alice, Kiro - 11 janvier 2026
*/
export { default as ExecutionControls } from './ExecutionControls';
export type {
ExecutionControlsProps,
ExecutionSettings,
ExecutionSaveState
} from './ExecutionControls';

View File

@@ -232,8 +232,8 @@ const VWBExecutorExtension: React.FC<VWBExecutorExtensionProps> = ({
// Déterminer l'URL de l'API
const hostname = window.location.hostname;
const apiBase = (hostname === 'localhost' || hostname === '127.0.0.1')
? 'http://localhost:5003/api'
: `http://${hostname}:5003/api`;
? 'http://localhost:5001/api'
: `http://${hostname}:5000/api`;
const response = await fetch(`${apiBase}/workflows/${workflow.id}/feedback`, {
method: 'POST',
@@ -385,17 +385,24 @@ const VWBExecutorExtension: React.FC<VWBExecutorExtensionProps> = ({
</Box>
{/* Contrôles d'exécution */}
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
{canStart && (
<Button
variant="contained"
startIcon={<PlayIcon />}
onClick={startExecution}
disabled={!canExecute || workflow.steps.length === 0}
color="success"
>
Exécuter VWB
</Button>
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Bouton Exécuter - toujours visible */}
<Button
variant="contained"
startIcon={<PlayIcon />}
onClick={() => {
console.log('🔘 [VWB UI] Clic sur Exécuter VWB', { canStart, canExecute, stepsLength: workflow.steps.length });
startExecution();
}}
disabled={!canStart || !canExecute || workflow.steps.length === 0}
color="success"
>
Exécuter VWB ({workflow.steps.length} étapes)
</Button>
{/* Message d'état */}
{!canStart && executionState.status === 'running' && (
<Chip label="Exécution en cours..." color="primary" size="small" />
)}
{canPause && (

View File

@@ -484,30 +484,13 @@ const StandardExecutor: React.FC<StandardExecutorProps> = ({
}
};
// Exécuter une étape individuelle avec le nouveau client API ou en simulation locale
// Exécuter une étape individuelle avec le nouveau client API
// NOTE: On n'utilise plus isOffline comme bloqueur - on essaie toujours l'API
const executeStep = useCallback(async (step: Step): Promise<StepExecutionResult> => {
const startTime = Date.now();
const stepId = step.id;
const currentRetries = retryAttempts.get(stepId) || 0;
// Mode simulation locale si API hors ligne
if (isOffline) {
// Simuler un délai d'exécution réaliste
await new Promise(resolve => setTimeout(resolve, 300 + Math.random() * 500));
const duration = Date.now() - startTime;
// Simulation : succès pour la plupart des étapes
const simulatedSuccess = Math.random() > 0.1; // 90% de succès en simulation
return {
stepId: step.id,
success: simulatedSuccess,
duration,
output: simulatedSuccess ? { simulated: true, stepType: step.type } : undefined,
error: simulatedSuccess ? undefined : 'Erreur simulée pour démonstration',
};
}
try {
// Utiliser le nouveau client API pour l'exécution
const result = await executionApi.executeStep({
@@ -570,7 +553,7 @@ const StandardExecutor: React.FC<StandardExecutorProps> = ({
error: apiError.message || 'Erreur inconnue',
};
}
}, [executionApi, workflow.id, retryAttempts, isOffline]);
}, [executionApi, workflow.id, retryAttempts]);
// Déterminer si une étape doit être retentée
const shouldRetryStep = useCallback((error: ApiError): boolean => {

View File

@@ -0,0 +1,519 @@
/**
* Composant Glossaire - Dictionnaire des termes techniques français
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant fournit un glossaire accessible des termes techniques
* utilisés dans le Visual Workflow Builder, avec recherche et navigation.
*/
import React, { useState, useMemo } from 'react';
import {
Box,
Typography,
TextField,
List,
ListItem,
ListItemText,
Accordion,
AccordionSummary,
AccordionDetails,
InputAdornment,
Chip,
Divider,
Paper,
} from '@mui/material';
import {
Search as SearchIcon,
ExpandMore as ExpandMoreIcon,
Book as BookIcon,
} from '@mui/icons-material';
interface GlossaryTerm {
id: string;
term: string;
definition: string;
category: string;
synonyms?: string[];
relatedTerms?: string[];
examples?: string[];
}
interface GlossaryProps {
searchTerm?: string;
onTermSelect?: (termId: string) => void;
}
// Base de données des termes du glossaire
const glossaryTerms: GlossaryTerm[] = [
// Termes généraux
{
id: 'workflow',
term: 'Workflow',
definition: 'Séquence d\'étapes automatisées qui reproduit un processus métier ou une tâche répétitive. Dans RPA Vision, un workflow est composé d\'étapes connectées qui s\'exécutent dans un ordre logique.',
category: 'Général',
synonyms: ['Flux de travail', 'Processus automatisé'],
relatedTerms: ['etape', 'connexion', 'execution'],
examples: ['Workflow de saisie de données', 'Workflow de validation de formulaires']
},
{
id: 'etape',
term: 'Étape',
definition: 'Unité d\'action élémentaire dans un workflow. Chaque étape effectue une action spécifique comme cliquer, saisir du texte, ou extraire des données.',
category: 'Général',
synonyms: ['Action', 'Nœud', 'Tâche'],
relatedTerms: ['workflow', 'parametres', 'connexion'],
examples: ['Étape "Cliquer"', 'Étape "Saisir du texte"', 'Étape "Attendre"']
},
{
id: 'connexion',
term: 'Connexion',
definition: 'Lien entre deux étapes qui définit l\'ordre d\'exécution. Les connexions créent le flux logique du workflow.',
category: 'Général',
synonyms: ['Lien', 'Transition', 'Flèche'],
relatedTerms: ['etape', 'workflow', 'flux'],
examples: ['Connexion de l\'étape A vers l\'étape B']
},
{
id: 'parametres',
term: 'Paramètres',
definition: 'Valeurs de configuration d\'une étape qui définissent comment elle doit s\'exécuter. Chaque type d\'étape a ses propres paramètres.',
category: 'Configuration',
synonyms: ['Propriétés', 'Configuration', 'Options'],
relatedTerms: ['etape', 'validation', 'variables'],
examples: ['Paramètre "texte" pour une étape de saisie', 'Paramètre "durée" pour une attente']
},
// Termes techniques
{
id: 'selection-visuelle',
term: 'Sélection visuelle',
definition: 'Méthode pour choisir un élément sur une page web en cliquant directement sur une capture d\'écran, plutôt qu\'en écrivant un sélecteur CSS.',
category: 'Technique',
synonyms: ['Sélecteur visuel', 'Pointage visuel'],
relatedTerms: ['element-cible', 'capture-ecran', 'selecteur'],
examples: ['Sélectionner un bouton en cliquant dessus', 'Choisir un champ de saisie visuellement']
},
{
id: 'element-cible',
term: 'Élément cible',
definition: 'Élément d\'une page web sur lequel une étape va agir. Peut être un bouton, un champ de saisie, un lien, etc.',
category: 'Technique',
synonyms: ['Cible', 'Élément sélectionné'],
relatedTerms: ['selection-visuelle', 'selecteur', 'dom'],
examples: ['Bouton "Valider"', 'Champ "Email"', 'Lien "En savoir plus"']
},
{
id: 'variables',
term: 'Variables',
definition: 'Valeurs dynamiques qui peuvent être utilisées dans les paramètres des étapes. Permettent de rendre les workflows réutilisables et flexibles.',
category: 'Technique',
synonyms: ['Variables dynamiques', 'Placeholders'],
relatedTerms: ['parametres', 'substitution', 'dynamique'],
examples: ['${nom_utilisateur}', '${email}', '${compteur}']
},
{
id: 'validation',
term: 'Validation',
definition: 'Vérification automatique de la cohérence et de la complétude d\'un workflow avant son exécution. Détecte les erreurs et avertissements.',
category: 'Technique',
synonyms: ['Vérification', 'Contrôle qualité'],
relatedTerms: ['erreurs', 'avertissements', 'execution'],
examples: ['Validation des paramètres manquants', 'Détection de cycles']
},
// Types d'étapes
{
id: 'clic',
term: 'Clic',
definition: 'Action de cliquer sur un élément de la page web. Peut être un clic gauche, droit, ou double-clic selon les besoins.',
category: 'Actions Web',
synonyms: ['Click', 'Cliquer'],
relatedTerms: ['element-cible', 'interaction'],
examples: ['Clic sur un bouton', 'Clic droit pour menu contextuel']
},
{
id: 'saisie',
term: 'Saisie de texte',
definition: 'Action de saisir du texte dans un champ de saisie. Supporte les variables pour un contenu dynamique.',
category: 'Actions Web',
synonyms: ['Frappe', 'Écriture', 'Input'],
relatedTerms: ['variables', 'champ-saisie', 'texte'],
examples: ['Saisir un nom d\'utilisateur', 'Remplir un formulaire']
},
{
id: 'extraction',
term: 'Extraction de données',
definition: 'Action de récupérer des informations depuis un élément de la page web pour les stocker dans une variable.',
category: 'Données',
synonyms: ['Récupération', 'Capture de données'],
relatedTerms: ['variables', 'element-cible', 'donnees'],
examples: ['Extraire le prix d\'un produit', 'Récupérer un numéro de commande']
},
{
id: 'condition',
term: 'Condition',
definition: 'Structure logique qui permet d\'exécuter différentes actions selon qu\'une condition est vraie ou fausse.',
category: 'Logique',
synonyms: ['Test conditionnel', 'Branchement'],
relatedTerms: ['logique', 'variables', 'flux'],
examples: ['Si âge > 18 alors continuer', 'Si champ vide alors alerter']
},
{
id: 'attente',
term: 'Attente',
definition: 'Pause dans l\'exécution du workflow pendant une durée déterminée. Utile pour attendre le chargement d\'éléments.',
category: 'Contrôle',
synonyms: ['Pause', 'Délai', 'Wait'],
relatedTerms: ['synchronisation', 'timing'],
examples: ['Attendre 2 secondes', 'Pause après un clic']
},
// Interface utilisateur
{
id: 'canvas',
term: 'Canvas',
definition: 'Zone de travail principale où les étapes sont placées et connectées pour former le workflow visuel.',
category: 'Interface',
synonyms: ['Zone de travail', 'Espace de conception'],
relatedTerms: ['etape', 'connexion', 'workflow'],
examples: ['Glisser une étape sur le canvas', 'Zoomer sur le canvas']
},
{
id: 'palette',
term: 'Palette',
definition: 'Boîte à outils contenant tous les types d\'étapes disponibles, organisés par catégories pour faciliter la recherche.',
category: 'Interface',
synonyms: ['Boîte à outils', 'Toolbox'],
relatedTerms: ['etape', 'categories', 'drag-drop'],
examples: ['Palette d\'étapes', 'Catégorie Actions Web']
},
{
id: 'proprietes',
term: 'Panneau de propriétés',
definition: 'Interface de configuration des paramètres de l\'étape sélectionnée. S\'adapte selon le type d\'étape.',
category: 'Interface',
synonyms: ['Configuration', 'Paramètres'],
relatedTerms: ['parametres', 'etape', 'configuration'],
examples: ['Configurer le texte à saisir', 'Définir la durée d\'attente']
},
{
id: 'minimap',
term: 'Mini-carte',
definition: 'Vue d\'ensemble miniaturisée du workflow complet, permettant de naviguer rapidement dans les gros workflows.',
category: 'Interface',
synonyms: ['Vue d\'ensemble', 'Navigation'],
relatedTerms: ['canvas', 'navigation', 'workflow'],
examples: ['Cliquer sur la mini-carte pour se déplacer']
},
// Concepts avancés
{
id: 'cycle',
term: 'Cycle',
definition: 'Boucle infinie dans un workflow où les étapes se référencent mutuellement, empêchant l\'exécution normale.',
category: 'Erreurs',
synonyms: ['Boucle infinie', 'Référence circulaire'],
relatedTerms: ['validation', 'erreurs', 'connexion'],
examples: ['Étape A → Étape B → Étape A']
},
{
id: 'etape-deconnectee',
term: 'Étape déconnectée',
definition: 'Étape qui n\'est pas reliée au flux principal du workflow et qui ne sera donc pas exécutée.',
category: 'Avertissements',
synonyms: ['Étape isolée', 'Nœud orphelin'],
relatedTerms: ['connexion', 'validation', 'flux'],
examples: ['Étape sans connexion d\'entrée ni de sortie']
},
{
id: 'execution',
term: 'Exécution',
definition: 'Processus de lancement et de déroulement du workflow, étape par étape, selon l\'ordre défini par les connexions.',
category: 'Général',
synonyms: ['Lancement', 'Déroulement', 'Run'],
relatedTerms: ['workflow', 'etape', 'validation'],
examples: ['Exécution réussie', 'Exécution interrompue par erreur']
}
];
// Catégories pour l'organisation
const categories = [
'Général',
'Technique',
'Actions Web',
'Données',
'Logique',
'Contrôle',
'Interface',
'Erreurs',
'Avertissements'
];
/**
* Composant Glossaire
*/
const Glossary: React.FC<GlossaryProps> = ({
searchTerm: externalSearchTerm = '',
onTermSelect,
}) => {
const [internalSearchTerm, setInternalSearchTerm] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['Général']));
// Utiliser le terme de recherche externe ou interne
const searchTerm = externalSearchTerm || internalSearchTerm;
// Filtrer les termes selon la recherche
const filteredTerms = useMemo(() => {
if (!searchTerm.trim()) {
return glossaryTerms;
}
const lowerSearchTerm = searchTerm.toLowerCase();
return glossaryTerms.filter(term =>
term.term.toLowerCase().includes(lowerSearchTerm) ||
term.definition.toLowerCase().includes(lowerSearchTerm) ||
term.synonyms?.some(synonym => synonym.toLowerCase().includes(lowerSearchTerm)) ||
term.examples?.some(example => example.toLowerCase().includes(lowerSearchTerm))
);
}, [searchTerm]);
// Organiser les termes par catégorie
const termsByCategory = useMemo(() => {
const organized: Record<string, GlossaryTerm[]> = {};
categories.forEach(category => {
organized[category] = filteredTerms.filter(term => term.category === category);
});
return organized;
}, [filteredTerms]);
// Gestionnaire d'expansion des catégories
const handleCategoryToggle = (category: string) => {
setExpandedCategories(prev => {
const newSet = new Set(prev);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
return newSet;
});
};
// Gestionnaire de sélection de terme
const handleTermSelect = (termId: string) => {
if (onTermSelect) {
onTermSelect(termId);
}
};
// Rendu d'un terme
const renderTerm = (term: GlossaryTerm) => (
<ListItem
key={term.id}
onClick={() => handleTermSelect(term.id)}
sx={{
cursor: 'pointer',
'&:hover': {
backgroundColor: '#f5f5f5',
},
flexDirection: 'column',
alignItems: 'stretch',
py: 2,
}}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="h6" component="dt">
{term.term}
</Typography>
{term.synonyms && term.synonyms.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5 }}>
{term.synonyms.map((synonym, index) => (
<Chip
key={index}
label={synonym}
size="small"
variant="outlined"
color="primary"
/>
))}
</Box>
)}
</Box>
}
secondary={
<Box component="dd" sx={{ m: 0 }}>
<Typography variant="body2" paragraph>
{term.definition}
</Typography>
{term.examples && term.examples.length > 0 && (
<Box sx={{ mb: 1 }}>
<Typography variant="caption" fontWeight="bold" color="text.secondary">
Exemples :
</Typography>
<List dense sx={{ pl: 2 }}>
{term.examples.map((example, index) => (
<ListItem key={index} sx={{ py: 0, pl: 0 }}>
<Typography variant="caption" color="text.secondary">
{example}
</Typography>
</ListItem>
))}
</List>
</Box>
)}
{term.relatedTerms && term.relatedTerms.length > 0 && (
<Box>
<Typography variant="caption" fontWeight="bold" color="text.secondary">
Termes liés :
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
{term.relatedTerms.map((relatedId, index) => {
const relatedTerm = glossaryTerms.find(t => t.id === relatedId);
return relatedTerm ? (
<Chip
key={index}
label={relatedTerm.term}
size="small"
variant="outlined"
color="secondary"
onClick={(e) => {
e.stopPropagation();
handleTermSelect(relatedId);
}}
/>
) : null;
})}
</Box>
</Box>
)}
</Box>
}
/>
</ListItem>
);
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* En-tête */}
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<BookIcon color="primary" />
<Typography variant="h5" component="h1">
Glossaire Technique
</Typography>
</Box>
{/* Champ de recherche */}
{!externalSearchTerm && (
<TextField
fullWidth
size="small"
placeholder="Rechercher un terme..."
value={internalSearchTerm}
onChange={(e) => setInternalSearchTerm(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
},
}}
/>
)}
{/* Statistiques */}
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
{filteredTerms.length} terme(s) trouvé(s) sur {glossaryTerms.length} au total
</Typography>
</Box>
{/* Contenu du glossaire */}
<Box sx={{ flex: 1, overflow: 'auto' }}>
{searchTerm ? (
// Vue de recherche : tous les termes filtrés
<Paper sx={{ m: 2, p: 2 }}>
<Typography variant="h6" gutterBottom>
Résultats de recherche pour "{searchTerm}"
</Typography>
<List component="dl">
{filteredTerms.map(term => (
<React.Fragment key={term.id}>
{renderTerm(term)}
<Divider />
</React.Fragment>
))}
</List>
{filteredTerms.length === 0 && (
<Typography variant="body2" color="text.secondary" textAlign="center">
Aucun terme trouvé pour "{searchTerm}"
</Typography>
)}
</Paper>
) : (
// Vue par catégories
<Box>
{categories.map(category => {
const categoryTerms = termsByCategory[category];
if (categoryTerms.length === 0) return null;
return (
<Accordion
key={category}
expanded={expandedCategories.has(category)}
onChange={() => handleCategoryToggle(category)}
disableGutters
elevation={0}
sx={{
'&:before': { display: 'none' },
borderBottom: '1px solid #f0f0f0',
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
minHeight: 56,
'& .MuiAccordionSummary-content': {
alignItems: 'center',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6">{category}</Typography>
<Chip
label={categoryTerms.length}
size="small"
color="primary"
variant="outlined"
/>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
<List component="dl">
{categoryTerms.map(term => (
<React.Fragment key={term.id}>
{renderTerm(term)}
<Divider />
</React.Fragment>
))}
</List>
</AccordionDetails>
</Accordion>
);
})}
</Box>
)}
</Box>
</Box>
);
};
export default Glossary;

View File

@@ -0,0 +1,413 @@
/**
* Interactive Preview Area Styles - RPA Vision V3
*
* Styles pour la zone d'aperçu interactif avec zoom et navigation.
* Optimisé pour une expérience de visualisation fluide et intuitive.
*/
.interactive-preview-area {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9998;
background: rgba(0, 0, 0, 0.9);
display: flex;
flex-direction: column;
}
.interactive-preview-area__header {
background: #1976d2;
color: white;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
flex-shrink: 0;
}
.interactive-preview-area__title {
display: flex;
align-items: center;
gap: 12px;
font-size: 1.25rem;
font-weight: 600;
}
.interactive-preview-area__controls {
display: flex;
align-items: center;
gap: 16px;
}
.interactive-preview-area__zoom-controls {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.1);
padding: 8px 12px;
border-radius: 20px;
}
.interactive-preview-area__zoom-slider {
width: 100px;
margin: 0 8px;
}
.interactive-preview-area__zoom-label {
min-width: 40px;
text-align: center;
font-size: 0.875rem;
font-weight: 500;
}
.interactive-preview-area__action-controls {
display: flex;
gap: 8px;
}
.interactive-preview-area__content {
flex: 1;
display: flex;
overflow: hidden;
}
.interactive-preview-area__canvas-container {
flex: 1;
position: relative;
overflow: hidden;
background: #2c2c2c;
display: flex;
align-items: center;
justify-content: center;
}
.interactive-preview-area__canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: grab;
transition: cursor 0.2s ease;
}
.interactive-preview-area__canvas--dragging {
cursor: grabbing;
}
.interactive-preview-area__sidebar {
width: 320px;
background: white;
border-left: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.interactive-preview-area__sidebar-header {
padding: 16px;
border-bottom: 1px solid #e0e0e0;
background: #f8f9fa;
}
.interactive-preview-area__sidebar-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.interactive-preview-area__info-section {
margin-bottom: 24px;
}
.interactive-preview-area__info-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
margin-bottom: 12px;
color: #333;
}
.interactive-preview-area__info-content {
font-size: 0.875rem;
line-height: 1.5;
color: #666;
}
.interactive-preview-area__metadata-grid {
display: grid;
gap: 8px;
}
.interactive-preview-area__metadata-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.interactive-preview-area__metadata-label {
font-weight: 500;
color: #333;
min-width: 80px;
}
.interactive-preview-area__metadata-value {
color: #666;
text-align: right;
flex: 1;
word-break: break-word;
}
.interactive-preview-area__text-content {
background: #e3f2fd;
padding: 12px;
border-radius: 8px;
border: 1px solid #bbdefb;
font-style: italic;
color: #1565c0;
margin: 8px 0;
}
.interactive-preview-area__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 8px 0;
}
.interactive-preview-area__display-controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.interactive-preview-area__control-button {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
}
.interactive-preview-area__control-button:hover {
background: #f5f5f5;
border-color: #1976d2;
}
.interactive-preview-area__control-button--active {
background: #1976d2;
color: white;
border-color: #1976d2;
}
/* Annotations overlay */
.interactive-preview-area__annotations {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
.interactive-preview-area__annotation {
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: white;
cursor: pointer;
pointer-events: auto;
transition: transform 0.2s ease;
}
.interactive-preview-area__annotation:hover {
transform: scale(1.2);
}
.interactive-preview-area__annotation--info {
background: #2196f3;
}
.interactive-preview-area__annotation--warning {
background: #ff9800;
}
.interactive-preview-area__annotation--success {
background: #4caf50;
}
.interactive-preview-area__annotation-tooltip {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 6px 10px;
border-radius: 4px;
font-size: 10px;
white-space: nowrap;
pointer-events: none;
z-index: 20;
}
/* Target highlight animations */
.interactive-preview-area__target-highlight {
position: absolute;
border: 3px solid #4caf50;
background: rgba(76, 175, 80, 0.1);
pointer-events: none;
z-index: 5;
}
.interactive-preview-area__target-highlight--animated {
animation: interactive-preview-pulse 2s infinite;
}
@keyframes interactive-preview-pulse {
0% {
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7);
border-width: 3px;
}
50% {
box-shadow: 0 0 0 10px rgba(76, 175, 80, 0.2);
border-width: 4px;
}
100% {
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0);
border-width: 3px;
}
}
.interactive-preview-area__target-corners {
position: absolute;
pointer-events: none;
z-index: 6;
}
.interactive-preview-area__corner {
position: absolute;
width: 15px;
height: 15px;
border: 2px solid #4caf50;
}
.interactive-preview-area__corner--top-left {
top: -2px;
left: -2px;
border-right: none;
border-bottom: none;
}
.interactive-preview-area__corner--top-right {
top: -2px;
right: -2px;
border-left: none;
border-bottom: none;
}
.interactive-preview-area__corner--bottom-left {
bottom: -2px;
left: -2px;
border-right: none;
border-top: none;
}
.interactive-preview-area__corner--bottom-right {
bottom: -2px;
right: -2px;
border-left: none;
border-top: none;
}
/* Loading state */
.interactive-preview-area__loading {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
flex-direction: column;
gap: 16px;
color: white;
}
.interactive-preview-area__loading-spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid white;
border-radius: 50%;
animation: interactive-preview-spin 1s linear infinite;
}
@keyframes interactive-preview-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Responsive design */
@media (max-width: 768px) {
.interactive-preview-area__sidebar {
width: 280px;
}
.interactive-preview-area__header {
padding: 12px 16px;
}
.interactive-preview-area__zoom-controls {
padding: 6px 10px;
}
.interactive-preview-area__zoom-slider {
width: 80px;
}
.interactive-preview-area__sidebar-content {
padding: 12px;
}
}
@media (max-width: 480px) {
.interactive-preview-area__content {
flex-direction: column;
}
.interactive-preview-area__sidebar {
width: 100%;
height: 250px;
border-left: none;
border-top: 1px solid #e0e0e0;
}
.interactive-preview-area__canvas-container {
height: calc(100vh - 250px - 80px);
}
.interactive-preview-area__controls {
flex-wrap: wrap;
gap: 8px;
}
.interactive-preview-area__zoom-controls {
order: 2;
width: 100%;
justify-content: center;
}
}

View File

@@ -0,0 +1,612 @@
/**
* Composant Zone d'Aperçu Interactif - Configuration des paramètres d'étapes
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Zone d'aperçu interactif pour les captures d'écran avec zoom, navigation et annotations.
* Permet l'examen détaillé des éléments sélectionnés avec contours animés.
*
* Fonctionnalités:
* - Zoom fluide avec molette de souris
* - Navigation par glisser-déposer
* - Annotations contextuelles
* - Surbrillance de l'élément cible
* - Contrôles de navigation intuitifs
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
Box,
Typography,
IconButton,
Tooltip,
Chip,
Paper,
Slider,
Divider,
} from '@mui/material';
import {
Close as CloseIcon,
ZoomIn as ZoomInIcon,
ZoomOut as ZoomOutIcon,
CenterFocusStrong as CenterIcon,
Fullscreen as FullscreenIcon,
Info as InfoIcon,
MyLocation as TargetIcon,
} from '@mui/icons-material';
import { BoundingBox } from '../../types';
import './InteractivePreviewArea.css';
interface VisualMetadata {
element_type?: string;
relative_position?: string;
text_content?: string;
}
interface InteractivePreviewAreaProps {
open: boolean;
onClose: () => void;
screenshot: string;
boundingBox: BoundingBox;
metadata?: VisualMetadata;
}
interface ViewportState {
zoom: number;
panX: number;
panY: number;
isDragging: boolean;
dragStart: { x: number; y: number };
}
interface AnnotationPoint {
x: number;
y: number;
label: string;
type: 'info' | 'warning' | 'target';
}
const InteractivePreviewArea: React.FC<InteractivePreviewAreaProps> = ({
open,
onClose,
screenshot,
boundingBox,
metadata,
}) => {
const [viewport, setViewport] = useState<ViewportState>({
zoom: 1,
panX: 0,
panY: 0,
isDragging: false,
dragStart: { x: 0, y: 0 },
});
const [showAnnotations, setShowAnnotations] = useState(true);
const [showTargetHighlight, setShowTargetHighlight] = useState(true);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const animationRef = useRef<number | null>(null);
// Réinitialiser l'état quand le dialog s'ouvre
useEffect(() => {
if (open) {
setViewport({
zoom: 1,
panX: 0,
panY: 0,
isDragging: false,
dragStart: { x: 0, y: 0 },
});
setImageLoaded(false);
}
}, [open]);
// Charger l'image et obtenir ses dimensions
useEffect(() => {
if (open && screenshot) {
const img = new Image();
img.onload = () => {
setImageDimensions({ width: img.width, height: img.height });
setImageLoaded(true);
// Centrer sur l'élément cible
centerOnTarget();
};
img.src = `data:image/png;base64,${screenshot}`;
imageRef.current = img;
}
}, [open, screenshot]);
// Animation du contour cible
useEffect(() => {
if (showTargetHighlight && imageLoaded) {
const animate = () => {
drawCanvas();
animationRef.current = requestAnimationFrame(animate);
};
animate();
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}
}, [showTargetHighlight, imageLoaded, viewport]);
/**
* Centre la vue sur l'élément cible
*/
const centerOnTarget = useCallback(() => {
if (!imageLoaded || !containerRef.current) return;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculer la position pour centrer l'élément cible
const targetCenterX = boundingBox.x + boundingBox.width / 2;
const targetCenterY = boundingBox.y + boundingBox.height / 2;
const panX = (containerRect.width / 2) - (targetCenterX * viewport.zoom);
const panY = (containerRect.height / 2) - (targetCenterY * viewport.zoom);
setViewport(prev => ({
...prev,
panX,
panY,
}));
}, [boundingBox, viewport.zoom, imageLoaded]);
/**
* Gère le zoom avec la molette
*/
const handleWheel = useCallback((event: React.WheelEvent) => {
event.preventDefault();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const zoomFactor = event.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(0.1, Math.min(5, viewport.zoom * zoomFactor));
// Ajuster le pan pour zoomer vers la position de la souris
const zoomRatio = newZoom / viewport.zoom;
const newPanX = mouseX - (mouseX - viewport.panX) * zoomRatio;
const newPanY = mouseY - (mouseY - viewport.panY) * zoomRatio;
setViewport(prev => ({
...prev,
zoom: newZoom,
panX: newPanX,
panY: newPanY,
}));
}, [viewport]);
/**
* Démarre le glisser-déposer
*/
const handleMouseDown = useCallback((event: React.MouseEvent) => {
event.preventDefault();
setViewport(prev => ({
...prev,
isDragging: true,
dragStart: { x: event.clientX - prev.panX, y: event.clientY - prev.panY },
}));
}, []);
/**
* Gère le glisser-déposer
*/
const handleMouseMove = useCallback((event: React.MouseEvent) => {
if (!viewport.isDragging) return;
const newPanX = event.clientX - viewport.dragStart.x;
const newPanY = event.clientY - viewport.dragStart.y;
setViewport(prev => ({
...prev,
panX: newPanX,
panY: newPanY,
}));
}, [viewport.isDragging, viewport.dragStart]);
/**
* Termine le glisser-déposer
*/
const handleMouseUp = useCallback(() => {
setViewport(prev => ({
...prev,
isDragging: false,
}));
}, []);
/**
* Dessine le canvas avec l'image et les overlays
*/
const drawCanvas = useCallback(() => {
const canvas = canvasRef.current;
const image = imageRef.current;
if (!canvas || !image || !imageLoaded) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Ajuster la taille du canvas
const container = containerRef.current;
if (container) {
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
}
// Effacer le canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Dessiner l'image avec transformation
ctx.save();
ctx.translate(viewport.panX, viewport.panY);
ctx.scale(viewport.zoom, viewport.zoom);
ctx.drawImage(image, 0, 0);
ctx.restore();
// Dessiner le contour animé de l'élément cible
if (showTargetHighlight) {
drawTargetHighlight(ctx);
}
// Dessiner les annotations
if (showAnnotations) {
drawAnnotations(ctx);
}
}, [viewport, showTargetHighlight, showAnnotations, imageLoaded]);
/**
* Dessine le contour animé de l'élément cible
*/
const drawTargetHighlight = useCallback((ctx: CanvasRenderingContext2D) => {
const time = Date.now() * 0.003; // Animation lente
ctx.save();
ctx.translate(viewport.panX, viewport.panY);
ctx.scale(viewport.zoom, viewport.zoom);
// Contour principal
ctx.strokeStyle = '#4CAF50';
ctx.lineWidth = 3 / viewport.zoom;
ctx.setLineDash([10, 5]);
ctx.lineDashOffset = time * 20;
ctx.strokeRect(
boundingBox.x - 2,
boundingBox.y - 2,
boundingBox.width + 4,
boundingBox.height + 4
);
// Contour externe animé
ctx.strokeStyle = `rgba(76, 175, 80, ${0.5 + 0.3 * Math.sin(time * 2)})`;
ctx.lineWidth = 1 / viewport.zoom;
ctx.setLineDash([]);
ctx.strokeRect(
boundingBox.x - 5,
boundingBox.y - 5,
boundingBox.width + 10,
boundingBox.height + 10
);
// Points de coin
const cornerSize = 8 / viewport.zoom;
ctx.fillStyle = '#4CAF50';
const corners = [
{ x: boundingBox.x, y: boundingBox.y },
{ x: boundingBox.x + boundingBox.width, y: boundingBox.y },
{ x: boundingBox.x, y: boundingBox.y + boundingBox.height },
{ x: boundingBox.x + boundingBox.width, y: boundingBox.y + boundingBox.height },
];
corners.forEach(corner => {
ctx.fillRect(
corner.x - cornerSize / 2,
corner.y - cornerSize / 2,
cornerSize,
cornerSize
);
});
ctx.restore();
}, [viewport, boundingBox]);
/**
* Dessine les annotations
*/
const drawAnnotations = useCallback((ctx: CanvasRenderingContext2D) => {
const annotations: AnnotationPoint[] = [
{
x: boundingBox.x + boundingBox.width / 2,
y: boundingBox.y - 20,
label: metadata?.element_type || 'Élément',
type: 'target'
}
];
ctx.save();
ctx.translate(viewport.panX, viewport.panY);
ctx.scale(viewport.zoom, viewport.zoom);
annotations.forEach(annotation => {
// Bulle d'annotation
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.strokeStyle = '#4CAF50';
ctx.lineWidth = 1 / viewport.zoom;
const padding = 8 / viewport.zoom;
const fontSize = 12 / viewport.zoom;
ctx.font = `${fontSize}px Arial`;
const textWidth = ctx.measureText(annotation.label).width;
const bubbleWidth = textWidth + padding * 2;
const bubbleHeight = fontSize + padding * 2;
// Dessiner la bulle
ctx.fillRect(
annotation.x - bubbleWidth / 2,
annotation.y - bubbleHeight,
bubbleWidth,
bubbleHeight
);
ctx.strokeRect(
annotation.x - bubbleWidth / 2,
annotation.y - bubbleHeight,
bubbleWidth,
bubbleHeight
);
// Dessiner le texte
ctx.fillStyle = 'white';
ctx.textAlign = 'center';
ctx.fillText(
annotation.label,
annotation.x,
annotation.y - padding
);
});
ctx.restore();
}, [viewport, boundingBox, metadata]);
/**
* Contrôles de zoom
*/
const zoomIn = useCallback(() => {
setViewport(prev => ({
...prev,
zoom: Math.min(5, prev.zoom * 1.2),
}));
}, []);
const zoomOut = useCallback(() => {
setViewport(prev => ({
...prev,
zoom: Math.max(0.1, prev.zoom / 1.2),
}));
}, []);
const resetZoom = useCallback(() => {
setViewport(prev => ({
...prev,
zoom: 1,
panX: 0,
panY: 0,
}));
}, []);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth={false}
fullWidth
slotProps={{
paper: {
sx: {
width: '90vw',
height: '90vh',
maxWidth: 'none',
maxHeight: 'none'
}
}
}}
>
<DialogTitle>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TargetIcon />
<Typography variant="h6">Aperçu Interactif</Typography>
{metadata && (
<Chip
label={metadata.element_type}
size="small"
color="primary"
/>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title="Annotations">
<IconButton
onClick={() => setShowAnnotations(!showAnnotations)}
color={showAnnotations ? 'primary' : 'default'}
>
<InfoIcon />
</IconButton>
</Tooltip>
<Tooltip title="Surbrillance cible">
<IconButton
onClick={() => setShowTargetHighlight(!showTargetHighlight)}
color={showTargetHighlight ? 'primary' : 'default'}
>
<TargetIcon />
</IconButton>
</Tooltip>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Box>
</Box>
</DialogTitle>
<DialogContent sx={{ p: 0, display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Barre d'outils */}
<Paper sx={{ p: 1, display: 'flex', alignItems: 'center', gap: 2, borderRadius: 0 }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title="Zoom avant">
<IconButton onClick={zoomIn} size="small">
<ZoomInIcon />
</IconButton>
</Tooltip>
<Tooltip title="Zoom arrière">
<IconButton onClick={zoomOut} size="small">
<ZoomOutIcon />
</IconButton>
</Tooltip>
<Tooltip title="Centrer sur l'élément">
<IconButton onClick={centerOnTarget} size="small">
<CenterIcon />
</IconButton>
</Tooltip>
<Tooltip title="Réinitialiser">
<IconButton onClick={resetZoom} size="small">
<FullscreenIcon />
</IconButton>
</Tooltip>
</Box>
<Divider orientation="vertical" flexItem />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, minWidth: 200 }}>
<Typography variant="body2">Zoom:</Typography>
<Slider
value={viewport.zoom}
min={0.1}
max={5}
step={0.1}
onChange={(_, value) => setViewport(prev => ({ ...prev, zoom: value as number }))}
sx={{ flex: 1 }}
/>
<Typography variant="body2" sx={{ minWidth: 50 }}>
{Math.round(viewport.zoom * 100)}%
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
<Typography variant="body2" color="textSecondary">
Position: {Math.round(viewport.panX)}, {Math.round(viewport.panY)}
</Typography>
</Paper>
{/* Zone de visualisation */}
<Box
ref={containerRef}
className="preview-container"
sx={{
flex: 1,
position: 'relative',
overflow: 'hidden',
cursor: viewport.isDragging ? 'grabbing' : 'grab',
backgroundColor: '#f5f5f5'
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
}}
/>
{!imageLoaded && (
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center'
}}
>
<Typography variant="body2" color="textSecondary">
Chargement de l'image...
</Typography>
</Box>
)}
</Box>
{/* Informations sur l'élément */}
{metadata && (
<Paper sx={{ p: 2, borderRadius: 0 }}>
<Typography variant="subtitle2" gutterBottom>
Informations sur l'Élément
</Typography>
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
<Box>
<Typography variant="caption" color="textSecondary">Type:</Typography>
<Typography variant="body2">{metadata.element_type}</Typography>
</Box>
<Box>
<Typography variant="caption" color="textSecondary">Position:</Typography>
<Typography variant="body2">{metadata.relative_position}</Typography>
</Box>
<Box>
<Typography variant="caption" color="textSecondary">Taille:</Typography>
<Typography variant="body2">
{boundingBox.width} × {boundingBox.height}px
</Typography>
</Box>
{metadata.text_content && (
<Box>
<Typography variant="caption" color="textSecondary">Texte:</Typography>
<Typography variant="body2">"{metadata.text_content}"</Typography>
</Box>
)}
</Box>
</Paper>
)}
</DialogContent>
</Dialog>
);
};
export default InteractivePreviewArea;

View File

@@ -0,0 +1,212 @@
/**
* Composant Raccourcis Clavier - Aide à l'accessibilité
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant affiche la liste des raccourcis clavier disponibles
* pour améliorer l'accessibilité et l'efficacité d'utilisation.
*/
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
Box,
Divider,
} from '@mui/material';
import {
Keyboard as KeyboardIcon,
Close as CloseIcon,
} from '@mui/icons-material';
interface KeyboardShortcutsProps {
open: boolean;
onClose: () => void;
shortcuts?: Array<{
keys: string;
description: string;
}>;
}
// Raccourcis par défaut si aucun n'est fourni
const defaultShortcuts = [
// Navigation
{ category: 'Navigation', keys: 'Tab', description: 'Naviguer vers l\'étape suivante' },
{ category: 'Navigation', keys: 'Shift + Tab', description: 'Naviguer vers l\'étape précédente' },
{ category: 'Navigation', keys: '↑ ↓ ← →', description: 'Déplacer l\'étape sélectionnée' },
// Édition
{ category: 'Édition', keys: 'Suppr', description: 'Supprimer l\'étape sélectionnée' },
{ category: 'Édition', keys: 'Ctrl + C', description: 'Copier l\'étape sélectionnée' },
{ category: 'Édition', keys: 'Ctrl + V', description: 'Coller l\'étape copiée' },
{ category: 'Édition', keys: 'Ctrl + Z', description: 'Annuler la dernière action' },
{ category: 'Édition', keys: 'Ctrl + Y', description: 'Rétablir l\'action annulée' },
// Workflow
{ category: 'Workflow', keys: 'Ctrl + S', description: 'Sauvegarder le workflow' },
{ category: 'Workflow', keys: 'F5', description: 'Exécuter le workflow' },
{ category: 'Workflow', keys: 'Ctrl + Entrée', description: 'Exécuter le workflow (alternative)' },
{ category: 'Workflow', keys: 'Ctrl + A', description: 'Sélectionner toutes les étapes' },
// Affichage
{ category: 'Affichage', keys: 'Ctrl + +', description: 'Zoomer' },
{ category: 'Affichage', keys: 'Ctrl + -', description: 'Dézoomer' },
{ category: 'Affichage', keys: 'Ctrl + 0', description: 'Ajuster le zoom' },
// Aide
{ category: 'Aide', keys: 'Échap', description: 'Annuler l\'action en cours' },
{ category: 'Aide', keys: 'F1', description: 'Afficher l\'aide' },
{ category: 'Aide', keys: 'Shift + ?', description: 'Afficher les raccourcis clavier' },
];
/**
* Composant Raccourcis Clavier
*/
const KeyboardShortcuts: React.FC<KeyboardShortcutsProps> = ({
open,
onClose,
shortcuts = defaultShortcuts,
}) => {
// Organiser les raccourcis par catégorie
const shortcutsByCategory = shortcuts.reduce((acc, shortcut) => {
const category = 'category' in shortcut ? (shortcut as any).category : 'Général';
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(shortcut);
return acc;
}, {} as Record<string, typeof shortcuts>);
// Fonction pour formater les touches
const formatKeys = (keys: string) => {
return keys.split(' + ').map((key, index, array) => (
<React.Fragment key={key}>
<Chip
label={key}
size="small"
variant="outlined"
sx={{
fontFamily: 'monospace',
fontSize: '0.75rem',
height: 24,
backgroundColor: '#f5f5f5',
border: '1px solid #ccc',
}}
/>
{index < array.length - 1 && (
<Typography
component="span"
sx={{ mx: 0.5, color: 'text.secondary' }}
>
+
</Typography>
)}
</React.Fragment>
));
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
aria-labelledby="keyboard-shortcuts-title"
>
<DialogTitle id="keyboard-shortcuts-title">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<KeyboardIcon />
<Typography variant="h6">
Raccourcis Clavier
</Typography>
</Box>
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" paragraph>
Utilisez ces raccourcis clavier pour naviguer et travailler plus efficacement
dans le Visual Workflow Builder.
</Typography>
{Object.entries(shortcutsByCategory).map(([category, categoryShortcuts], categoryIndex) => (
<Box key={category} sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom color="primary">
{category}
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 'bold', width: '30%' }}>
Raccourci
</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>
Description
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{categoryShortcuts.map((shortcut, index) => (
<TableRow key={index} hover>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{formatKeys(shortcut.keys)}
</Box>
</TableCell>
<TableCell>
<Typography variant="body2">
{shortcut.description}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{categoryIndex < Object.keys(shortcutsByCategory).length - 1 && (
<Divider sx={{ mt: 2 }} />
)}
</Box>
))}
<Box sx={{ mt: 3, p: 2, backgroundColor: '#f8f9fa', borderRadius: 1 }}>
<Typography variant="subtitle2" gutterBottom>
💡 Conseils d'accessibilité
</Typography>
<Typography variant="body2" color="text.secondary">
• Les raccourcis fonctionnent uniquement quand aucun champ de saisie n'est actif<br />
Utilisez Tab pour naviguer entre les éléments de l'interface<br />
Appuyez sur Échap pour annuler une action en cours<br />
F1 ou Shift+? affiche cette aide à tout moment
</Typography>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={onClose}
startIcon={<CloseIcon />}
variant="contained"
autoFocus
>
Fermer
</Button>
</DialogActions>
</Dialog>
);
};
export default KeyboardShortcuts;

View File

@@ -0,0 +1,277 @@
/**
* Composant CatalogActionItem - Affichage spécialisé pour les actions du catalogue VisionOnly
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
*
* Ce composant gère l'affichage d'une action du catalogue dans la Palette VWB,
* avec indicateurs visuels spécialisés, tooltips enrichis, et drag & drop optimisé.
*/
import React, { memo } from 'react';
import {
ListItem,
ListItemIcon,
ListItemText,
Tooltip,
Box,
Typography,
Chip,
Badge,
} from '@mui/material';
import {
Visibility as VisionIcon,
Speed as PerformanceIcon,
CheckCircle as ValidatedIcon,
} from '@mui/icons-material';
// Import des types
import { StepTemplate } from '../../types';
import { VWBCatalogAction } from '../../types/catalog';
interface CatalogActionItemProps {
/** Template de l'étape (converti depuis l'action du catalogue) */
stepTemplate: StepTemplate;
/** Action originale du catalogue (pour métadonnées enrichies) */
catalogAction?: VWBCatalogAction;
/** Gestionnaire de début de drag */
onDragStart: (event: React.DragEvent, stepTemplate: StepTemplate) => void;
/** Indique si l'action est mise en évidence par la recherche */
isHighlighted?: boolean;
}
/**
* Composant pour afficher une action du catalogue dans la Palette
*/
const CatalogActionItem: React.FC<CatalogActionItemProps> = ({
stepTemplate,
catalogAction,
onDragStart,
isHighlighted = false,
}) => {
// Déterminer les indicateurs de qualité
const hasExamples = catalogAction?.examples && catalogAction.examples.length > 0;
const hasDocumentation = catalogAction?.documentation && catalogAction.documentation.length > 0;
const complexityLevel = catalogAction?.metadata?.complexity || 'simple';
// Couleurs selon la complexité
const complexityColors = {
simple: '#4caf50', // Vert
intermediate: '#ff9800', // Orange
advanced: '#f44336' // Rouge
};
// Icônes selon la catégorie
const getCategoryIcon = (category: string) => {
switch (category) {
case 'vision_ui': return '🖱️';
case 'control': return '⏳';
case 'data': return '📊';
case 'navigation': return '🧭';
case 'validation': return '✅';
default: return '📋';
}
};
// Construire le contenu du tooltip enrichi
const tooltipContent = (
<Box sx={{ maxWidth: 300 }}>
{/* En-tête avec nom et badges */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="subtitle2" sx={{ flex: 1 }}>
{stepTemplate.name}
</Typography>
<Chip
label="VisionOnly"
size="small"
color="primary"
sx={{ fontSize: '0.7em' }}
/>
</Box>
{/* Description */}
<Typography variant="body2" sx={{ mb: 1.5 }}>
{stepTemplate.description}
</Typography>
{/* Paramètres requis */}
{stepTemplate.requiredParameters.length > 0 && (
<Box sx={{ mb: 1 }}>
<Typography variant="caption" color="inherit" sx={{ fontWeight: 'bold' }}>
Paramètres requis :
</Typography>
<Typography variant="caption" color="inherit" sx={{ display: 'block' }}>
{stepTemplate.requiredParameters.join(', ')}
</Typography>
</Box>
)}
{/* Exemple d'utilisation */}
{hasExamples && catalogAction?.examples[0] && (
<Box sx={{ mb: 1 }}>
<Typography variant="caption" color="inherit" sx={{ fontWeight: 'bold' }}>
Exemple :
</Typography>
<Typography variant="caption" color="inherit" sx={{ display: 'block' }}>
{catalogAction.examples[0].description}
</Typography>
</Box>
)}
{/* Complexité */}
{catalogAction?.metadata?.complexity && (
<Box sx={{ mb: 1 }}>
<Typography variant="caption" color="inherit" sx={{ fontWeight: 'bold' }}>
Complexité :
</Typography>
<Chip
label={complexityLevel}
size="small"
sx={{
ml: 0.5,
fontSize: '0.65em',
height: 16,
backgroundColor: complexityColors[complexityLevel],
color: 'white'
}}
/>
</Box>
)}
{/* Durée estimée */}
{catalogAction?.metadata?.estimatedDuration && (
<Box sx={{ mb: 1 }}>
<Typography variant="caption" color="inherit">
Durée estimée : {Math.round(catalogAction.metadata.estimatedDuration / 1000)}s
</Typography>
</Box>
)}
{/* Indicateur de reconnaissance visuelle */}
<Typography variant="caption" color="inherit" sx={{
display: 'block',
mt: 1,
fontStyle: 'italic',
backgroundColor: 'rgba(33, 150, 243, 0.1)',
padding: '4px 8px',
borderRadius: '4px'
}}>
🎯 Action avec reconnaissance visuelle automatique
</Typography>
</Box>
);
return (
<Tooltip
title={tooltipContent}
placement="right"
arrow
enterDelay={300}
leaveDelay={200}
>
<ListItem
draggable
onDragStart={(e) => onDragStart(e, stepTemplate)}
sx={{
cursor: 'grab',
backgroundColor: isHighlighted ? '#e8f4fd' : '#f0f4ff',
borderLeft: '3px solid #2196f3',
borderRadius: '0 4px 4px 0',
mb: 0.5,
'&:hover': {
backgroundColor: isHighlighted ? '#d1e7dd' : '#e3f2fd',
transform: 'translateX(2px)',
transition: 'all 0.2s ease-in-out',
},
'&:active': {
cursor: 'grabbing',
transform: 'translateX(1px) scale(0.98)',
},
}}
>
{/* Icône principale avec badge de catégorie */}
<ListItemIcon sx={{ minWidth: 40 }}>
<Badge
badgeContent={getCategoryIcon(catalogAction?.category || 'vision_ui')}
sx={{
'& .MuiBadge-badge': {
fontSize: '0.6em',
minWidth: 16,
height: 16,
backgroundColor: 'transparent',
}
}}
>
<Typography sx={{ fontSize: '1.2em' }}>
{stepTemplate.icon}
</Typography>
</Badge>
</ListItemIcon>
{/* Contenu principal */}
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="body2" noWrap sx={{ flex: 1, fontWeight: 500 }}>
{stepTemplate.name}
</Typography>
{/* Indicateurs de qualité */}
<Box sx={{ display: 'flex', gap: 0.25 }}>
{hasExamples && (
<Tooltip title="Exemples disponibles">
<ValidatedIcon sx={{ fontSize: 12, color: '#4caf50' }} />
</Tooltip>
)}
{hasDocumentation && (
<Tooltip title="Documentation complète">
<VisionIcon sx={{ fontSize: 12, color: '#2196f3' }} />
</Tooltip>
)}
{catalogAction?.metadata?.estimatedDuration && catalogAction.metadata.estimatedDuration < 2000 && (
<Tooltip title="Exécution rapide">
<PerformanceIcon sx={{ fontSize: 12, color: '#ff9800' }} />
</Tooltip>
)}
</Box>
{/* Label VISION */}
<Typography
variant="caption"
sx={{
color: '#2196f3',
fontWeight: 'bold',
fontSize: '0.65em',
backgroundColor: 'rgba(33, 150, 243, 0.1)',
padding: '1px 4px',
borderRadius: '2px'
}}
>
VISION
</Typography>
</Box>
}
secondary={
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ fontSize: '0.7em' }}
>
{stepTemplate.description}
</Typography>
}
/>
</ListItem>
</Tooltip>
);
};
// Mémorisation pour optimiser les performances
export default memo(CatalogActionItem, (prevProps, nextProps) => {
return (
prevProps.stepTemplate.id === nextProps.stepTemplate.id &&
prevProps.isHighlighted === nextProps.isHighlighted &&
JSON.stringify(prevProps.catalogAction) === JSON.stringify(nextProps.catalogAction)
);
});

View File

@@ -0,0 +1,740 @@
/**
* Composant Palette d'Étapes Étendue - Boîte à outils avec catégories françaises et actions VisionOnly
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
*
* Ce composant affiche les types d'étapes disponibles organisés en catégories françaises,
* avec recherche débouncée, tooltips explicatifs, support du drag-and-drop optimisé,
* et intégration complète du catalogue d'actions VisionOnly.
*
* NOUVELLES FONCTIONNALITÉS:
* - Intégration du catalogue d'actions VisionOnly
* - Catégories dynamiques depuis l'API catalogue
* - Actions Vision UI, Contrôle, Données du catalogue
* - Recherche unifiée (actions par défaut + catalogue)
* - Indicateurs de statut du service catalogue
* - Tooltips enrichis avec exemples d'actions
*/
import React, { useState, useMemo, useCallback, memo, useEffect } from 'react';
import {
Box,
Typography,
TextField,
Accordion,
AccordionSummary,
AccordionDetails,
List,
ListItem,
ListItemIcon,
ListItemText,
Tooltip,
InputAdornment,
Chip,
Alert,
CircularProgress,
Badge,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
Search as SearchIcon,
CloudOff as OfflineIcon,
CheckCircle as OnlineIcon,
} from '@mui/icons-material';
// Import des types partagés
import { PaletteProps, StepCategory, StepTemplate } from '../../types';
// Import des types du catalogue
import {
VWBCatalogAction,
VWBActionCategory,
VWBActionCategoryInfo
} from '../../types/catalog';
// Import du service catalogue
import { catalogService } from '../../services/catalogService';
// Import des hooks d'optimisation
import { useDebounce } from '../../hooks/useDebounce';
// Import des tooltips
import { getTooltip } from '../../utils/tooltips';
// Import du hook de gestion du catalogue
import { useCatalogActions } from '../../hooks/useCatalogActions';
// Catégories par défaut désactivées - Mode 100% Visuel uniquement
// Les actions legacy basées sur sélecteurs CSS ont été supprimées
// Seules les actions VisionOnly du catalogue sont disponibles
const getDefaultCategories = (): StepCategory[] => [];
// Mapping des catégories du catalogue vers les métadonnées d'affichage (100% visuel)
const getCatalogCategoryMetadata = (categoryId: VWBActionCategory): {
name: string;
description: string;
icon: string;
color: string;
} => {
const metadata: Record<VWBActionCategory, { name: string; description: string; icon: string; color: string }> = {
vision_ui: {
name: 'Interactions Visuelles',
description: 'Cliquer, saisir, glisser-déposer sur des éléments visuels',
icon: '🖱️',
color: '#2196f3',
},
control: {
name: 'Contrôle de Flux',
description: 'Conditions, boucles et synchronisation basées sur la vision',
icon: '🔀',
color: '#ff9800',
},
data: {
name: 'Extraction de Données',
description: 'Extraire texte, tableaux, télécharger des fichiers',
icon: '📊',
color: '#4caf50',
},
intelligence: {
name: 'Intelligence IA',
description: 'Analyse et traitement intelligent par IA',
icon: '🤖',
color: '#9c27b0',
},
database: {
name: 'Base de Données',
description: 'Lire et enregistrer des données en base',
icon: '💾',
color: '#00bcd4',
},
validation: {
name: 'Validation',
description: 'Vérifier la présence et le contenu des éléments',
icon: '✅',
color: '#f44336',
},
};
return metadata[categoryId] || {
name: categoryId,
description: `Actions de type ${categoryId}`,
icon: '📋',
color: '#757575',
};
};
// Convertir une action du catalogue en StepTemplate pour compatibilité
const convertCatalogActionToStepTemplate = (action: VWBCatalogAction): StepTemplate => {
// Extraire les paramètres requis
const requiredParameters = Object.entries(action.parameters)
.filter(([_, param]) => param.required)
.map(([name, _]) => name);
// Extraire les paramètres par défaut
const defaultParameters = Object.entries(action.parameters)
.filter(([_, param]) => param.default !== undefined)
.reduce((acc, [name, param]) => {
acc[name] = param.default;
return acc;
}, {} as Record<string, any>);
return {
id: action.id,
type: action.id as any, // Utiliser l'ID comme type pour les actions du catalogue
name: action.name,
description: action.description,
icon: action.icon,
defaultParameters,
requiredParameters,
};
};
// Interface pour l'état du catalogue
interface CatalogState {
actions: VWBCatalogAction[];
categories: Array<{
id: VWBActionCategory;
name: string;
description: string;
icon: string;
actionCount: number;
}>;
isLoading: boolean;
isOnline: boolean;
error: string | null;
lastUpdate: Date | null;
}
/**
* Composant Palette pour la sélection d'étapes avec intégration du catalogue VisionOnly
*/
const Palette: React.FC<PaletteProps> = ({
categories,
searchTerm,
onSearch,
onStepDrag,
}) => {
// Utiliser les catégories par défaut si aucune n'est fournie
const effectiveCategories = categories.length > 0 ? categories : getDefaultCategories();
const [expandedCategories, setExpandedCategories] = useState<string[]>([
'actions-web',
'vision_ui', // Étendre automatiquement les actions Vision UI
]);
// Utiliser le hook de gestion du catalogue
const {
state: catalogHookState,
filteredActions: catalogActions,
actions: catalogActionMethods,
} = useCatalogActions({
autoLoad: true,
refreshInterval: 5 * 60 * 1000, // 5 minutes
});
// Utiliser l'état du hook comme état principal du catalogue
const catalogState = useMemo(() => ({
actions: catalogHookState.actions,
categories: catalogHookState.categories.map(cat => ({
id: cat.id,
name: cat.name,
description: cat.description,
icon: cat.icon,
actionCount: cat.actionCount,
})),
isLoading: catalogHookState.isLoading,
isOnline: catalogHookState.isOnline,
error: catalogHookState.error,
lastUpdate: catalogHookState.lastUpdate,
}), [catalogHookState]);
// Debouncing de la recherche pour optimiser les performances
const debouncedSearchTerm = useDebounce(searchTerm, 300);
// Gestionnaire d'événements clavier pour la palette
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'Enter':
case ' ':
// Activer l'élément focalisé
event.preventDefault();
break;
case 'ArrowUp':
case 'ArrowDown':
// Navigation verticale dans la liste
event.preventDefault();
break;
case 'Escape':
// Fermer les catégories étendues
event.preventDefault();
setExpandedCategories([]);
break;
}
}, []);
// Convertir les actions du catalogue en catégories compatibles
const catalogCategories = useMemo((): StepCategory[] => {
if (catalogState.actions.length === 0) return [];
// Grouper les actions par catégorie
const actionsByCategory = catalogState.actions.reduce((acc, action) => {
if (!acc[action.category]) {
acc[action.category] = [];
}
acc[action.category].push(action);
return acc;
}, {} as Record<VWBActionCategory, VWBCatalogAction[]>);
// Créer les catégories avec leurs actions
return Object.entries(actionsByCategory).map(([categoryId, actions]) => {
const metadata = getCatalogCategoryMetadata(categoryId as VWBActionCategory);
return {
id: `catalog_${categoryId}`,
name: metadata.name,
description: metadata.description,
icon: metadata.icon,
steps: actions.map(convertCatalogActionToStepTemplate),
};
});
}, [catalogState.actions]);
// Combiner les catégories par défaut et du catalogue
const allCategories = useMemo(() => {
return [...effectiveCategories, ...catalogCategories];
}, [effectiveCategories, catalogCategories]);
// Filtrage des étapes selon le terme de recherche débouncé
const filteredCategories = useMemo(() => {
if (!debouncedSearchTerm.trim()) {
return allCategories;
}
const searchLower = debouncedSearchTerm.toLowerCase();
return allCategories
.map((category) => ({
...category,
steps: category.steps.filter((step) =>
step.name.toLowerCase().includes(searchLower) ||
step.description.toLowerCase().includes(searchLower) ||
step.type.toLowerCase().includes(searchLower)
),
}))
.filter((category) => category.steps.length > 0);
}, [allCategories, debouncedSearchTerm]);
// Gestionnaire d'expansion des catégories
const handleCategoryToggle = (categoryId: string) => {
setExpandedCategories((prev) =>
prev.includes(categoryId)
? prev.filter((id) => id !== categoryId)
: [...prev, categoryId]
);
};
// Gestionnaire de début de drag
const handleDragStart = (event: React.DragEvent, stepTemplate: StepTemplate) => {
// Pour les actions du catalogue, utiliser un format spécial
const dragData = stepTemplate.id.startsWith('catalog_')
? `catalog:${stepTemplate.type}`
: stepTemplate.type;
event.dataTransfer.setData('application/reactflow', dragData);
event.dataTransfer.effectAllowed = 'move';
onStepDrag(stepTemplate);
};
// Gestionnaire de rechargement du catalogue
const handleReloadCatalog = useCallback(async () => {
await catalogActionMethods.reload();
}, [catalogActionMethods]);
return (
<Box
sx={{
width: 280,
height: '100%',
backgroundColor: '#ffffff',
borderRight: '1px solid #e0e0e0',
display: 'flex',
flexDirection: 'column',
}}
role="complementary"
aria-label="Palette d'étapes disponibles avec actions VisionOnly"
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* En-tête avec titre et indicateur de statut */}
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6" component="h2">
Palette d'Étapes
</Typography>
{/* Indicateur de statut du catalogue étendu */}
<Tooltip
title={
<Box>
<Typography variant="subtitle2" gutterBottom>
{catalogHookState.mode === 'dynamic' ? '🌐 Mode Dynamique' :
catalogHookState.mode === 'static' ? '📦 Mode Statique' :
'🔴 Mode Hors Ligne'}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{catalogHookState.mode === 'dynamic'
? `Connecté au service catalogue - ${catalogState.actions.length} actions disponibles`
: catalogHookState.mode === 'static'
? `Catalogue de secours actif - ${catalogState.actions.length} actions de base`
: 'Service catalogue indisponible'}
</Typography>
{catalogHookState.serviceUrl && (
<Typography variant="caption" color="inherit" sx={{ display: 'block', mb: 0.5 }}>
URL: {catalogHookState.serviceUrl}
</Typography>
)}
{catalogHookState.error && (
<Typography variant="caption" color="error" sx={{ display: 'block', mb: 0.5 }}>
Erreur: {catalogHookState.error}
</Typography>
)}
<Typography variant="caption" color="inherit" sx={{ fontStyle: 'italic' }}>
{catalogHookState.mode === 'dynamic'
? 'Cliquez pour forcer une re-détection'
: 'Cliquez pour réessayer la connexion'}
</Typography>
</Box>
}
placement="left"
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
padding: '4px 8px',
borderRadius: '4px',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.04)',
},
}}
onClick={async () => {
if (catalogHookState.mode === 'dynamic') {
await catalogActionMethods.forceUrlDetection();
} else {
await catalogActionMethods.reload();
}
}}
>
{catalogState.isLoading ? (
<CircularProgress size={16} />
) : (
<>
{/* Icône selon le mode */}
{catalogHookState.mode === 'dynamic' ? (
<Badge badgeContent={catalogState.actions.length} color="primary" max={99}>
<OnlineIcon color="success" fontSize="small" />
</Badge>
) : catalogHookState.mode === 'static' ? (
<Badge badgeContent={catalogState.actions.length} color="warning" max={99}>
<Box sx={{ fontSize: '16px' }}>📦</Box>
</Badge>
) : (
<OfflineIcon color="disabled" fontSize="small" />
)}
{/* Texte du mode */}
<Typography
variant="caption"
sx={{
fontSize: '0.7em',
fontWeight: 'bold',
color: catalogHookState.mode === 'dynamic' ? 'success.main' :
catalogHookState.mode === 'static' ? 'warning.main' :
'text.disabled'
}}
>
{catalogHookState.mode === 'dynamic' ? 'LIVE' :
catalogHookState.mode === 'static' ? 'LOCAL' :
'OFF'}
</Typography>
</>
)}
</Box>
</Tooltip>
</Box>
{/* Champ de recherche */}
<TextField
fullWidth
size="small"
placeholder="Rechercher une étape..."
value={searchTerm}
onChange={(e) => onSearch(e.target.value)}
aria-label="Rechercher dans les étapes disponibles"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
},
}}
/>
{/* Statistiques du catalogue avec mode */}
<Box sx={{ mt: 1, display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip
label={`${effectiveCategories.length} catégories par défaut`}
size="small"
variant="outlined"
color="default"
/>
{catalogState.actions.length > 0 && (
<Chip
label={`${catalogState.actions.length} actions ${catalogHookState.mode === 'static' ? 'locales' : 'VisionOnly'}`}
size="small"
variant="outlined"
color={catalogHookState.mode === 'dynamic' ? 'primary' : 'warning'}
/>
)}
{/* Bouton de réinitialisation pour les cas problématiques */}
{(catalogHookState.error || catalogHookState.mode === 'offline') && (
<Tooltip title="Réinitialiser complètement le service catalogue">
<Chip
label="🔄 Reset"
size="small"
variant="outlined"
color="error"
clickable
onClick={async () => {
await catalogActionMethods.resetService();
}}
sx={{ fontSize: '0.7em' }}
/>
</Tooltip>
)}
</Box>
{/* Message d'erreur du catalogue avec actions */}
{catalogState.error && !catalogState.isLoading && (
<Alert
severity={catalogHookState.mode === 'static' ? 'info' : 'warning'}
sx={{ mt: 1 }}
action={
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Typography
variant="caption"
sx={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={handleReloadCatalog}
>
Recharger
</Typography>
{catalogHookState.mode !== 'static' && (
<Typography
variant="caption"
sx={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={async () => {
await catalogActionMethods.forceUrlDetection();
}}
>
Re-détecter
</Typography>
)}
</Box>
}
>
{catalogHookState.mode === 'static'
? 'Mode local actif - Actions de base disponibles'
: 'Service catalogue indisponible - Mode local activé'
}
</Alert>
)}
</Box>
{/* Liste des catégories et étapes */}
<Box sx={{ flex: 1, overflow: 'auto' }}>
{filteredCategories.map((category) => {
const isCatalogCategory = category.id.startsWith('catalog_');
return (
<Accordion
key={category.id}
expanded={expandedCategories.includes(category.id)}
onChange={() => handleCategoryToggle(category.id)}
disableGutters
elevation={0}
sx={{
'&:before': { display: 'none' },
borderBottom: '1px solid #f0f0f0',
backgroundColor: isCatalogCategory ? '#f8f9ff' : 'inherit',
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
minHeight: 48,
'& .MuiAccordionSummary-content': {
alignItems: 'center',
},
}}
>
<Tooltip
title={
<Box>
<Typography variant="subtitle2" gutterBottom>
{category.name}
{isCatalogCategory && (
<Chip
label="VisionOnly"
size="small"
color="primary"
sx={{ ml: 1, fontSize: '0.7em' }}
/>
)}
</Typography>
<Typography variant="body2">
{category.description}
</Typography>
{getTooltip('category', category.id) && (
<Typography variant="caption" color="inherit" sx={{ mt: 1, display: 'block' }}>
Exemple : {getTooltip('category', category.id)?.example}
</Typography>
)}
</Box>
}
placement="right"
arrow
enterDelay={700}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
<Typography sx={{ fontSize: '1.2em' }}>{category.icon}</Typography>
<Typography variant="subtitle2" sx={{ flex: 1 }}>
{category.name}
</Typography>
<Typography variant="caption" color="text.secondary">
({category.steps.length})
</Typography>
{isCatalogCategory && (
<Chip
label="Vision"
size="small"
color="primary"
variant="outlined"
sx={{ fontSize: '0.65em', height: 18 }}
/>
)}
</Box>
</Tooltip>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
<List dense>
{category.steps.map((step) => {
const isFromCatalog = category.id.startsWith('catalog_');
return (
<Tooltip
key={step.id}
title={
<Box>
<Typography variant="subtitle2" gutterBottom>
{step.name}
{isFromCatalog && (
<Chip
label="VisionOnly"
size="small"
color="primary"
sx={{ ml: 1, fontSize: '0.7em' }}
/>
)}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{step.description}
</Typography>
{step.requiredParameters.length > 0 && (
<Typography variant="caption" color="inherit" sx={{ display: 'block', mb: 0.5 }}>
Paramètres requis : {step.requiredParameters.join(', ')}
</Typography>
)}
{getTooltip('step', step.type) && (
<>
<Typography variant="caption" color="inherit">
{getTooltip('step', step.type)?.example}
</Typography>
<br />
<Typography variant="caption" color="inherit" fontStyle="italic">
{getTooltip('step', step.type)?.shortcut}
</Typography>
</>
)}
{isFromCatalog && (
<Typography variant="caption" color="inherit" sx={{ display: 'block', mt: 1, fontStyle: 'italic' }}>
🎯 Action avec reconnaissance visuelle automatique
</Typography>
)}
</Box>
}
placement="right"
arrow
enterDelay={500}
leaveDelay={200}
>
<ListItem
draggable
onDragStart={(e) => handleDragStart(e, step)}
sx={{
cursor: 'grab',
backgroundColor: isFromCatalog ? '#f0f4ff' : 'inherit',
'&:hover': {
backgroundColor: isFromCatalog ? '#e3f2fd' : '#f5f5f5',
},
'&:active': {
cursor: 'grabbing',
},
borderLeft: isFromCatalog ? '3px solid #2196f3' : 'none',
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<Typography sx={{ fontSize: '1.1em' }}>{step.icon}</Typography>
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="body2" noWrap sx={{ flex: 1 }}>
{step.name}
</Typography>
{isFromCatalog && (
<Typography
variant="caption"
sx={{
color: '#2196f3',
fontWeight: 'bold',
fontSize: '0.65em'
}}
>
VISION
</Typography>
)}
</Box>
}
/>
</ListItem>
</Tooltip>
);
})}
</List>
</AccordionDetails>
</Accordion>
);
})}
</Box>
{/* Message si aucun résultat */}
{filteredCategories.length === 0 && !catalogState.isLoading && (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Aucune étape trouvée pour "{searchTerm}"
</Typography>
{catalogState.error && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Le catalogue VisionOnly est hors ligne
</Typography>
)}
</Box>
)}
{/* Indicateur de chargement */}
{catalogState.isLoading && (
<Box sx={{ p: 2, textAlign: 'center' }}>
<CircularProgress size={24} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Chargement du catalogue VisionOnly...
</Typography>
</Box>
)}
{/* Pied de page avec informations */}
{catalogState.lastUpdate && (
<Box sx={{ p: 1, borderTop: '1px solid #f0f0f0', backgroundColor: '#fafafa' }}>
<Typography variant="caption" color="text.secondary" align="center" display="block">
Dernière mise à jour : {catalogState.lastUpdate.toLocaleTimeString('fr-FR')}
</Typography>
</Box>
)}
</Box>
);
};
// Mémorisation du composant Palette pour éviter les re-rendus inutiles
export default memo(Palette, (prevProps, nextProps) => {
// Comparaison personnalisée pour optimiser les re-rendus
return (
JSON.stringify(prevProps.categories) === JSON.stringify(nextProps.categories) &&
prevProps.searchTerm === nextProps.searchTerm &&
prevProps.onSearch === nextProps.onSearch &&
prevProps.onStepDrag === nextProps.onStepDrag
);
});

View File

@@ -0,0 +1,509 @@
/**
* Composant de Test Debug - Propriétés d'Étapes
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant teste et affiche les informations de débogage pour
* diagnostiquer le problème des propriétés d'étapes vides.
*/
import React, { useState, useCallback, useMemo } from 'react';
import {
Box,
Typography,
Button,
Card,
CardContent,
Alert,
Accordion,
AccordionSummary,
AccordionDetails,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
BugReport as BugIcon,
CheckCircle as CheckIcon,
Error as ErrorIcon,
} from '@mui/icons-material';
// Import des types
import { Step, StepType, StepExecutionState } from '../types';
// Import des hooks VWB
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../hooks/useVWBStepIntegration';
// Configuration des paramètres (copie de PropertiesPanel)
interface ParameterConfig {
name: string;
label: string;
type: 'text' | 'number' | 'boolean' | 'select' | 'visual';
required?: boolean;
description?: string;
supportVariables?: boolean;
options?: { value: string; label: string }[];
defaultValue?: any;
min?: number;
max?: number;
}
const stepParametersConfig: Record<StepType, ParameterConfig[]> = {
click: [
{
name: 'target',
label: 'Élément cible',
type: 'visual',
required: true,
description: 'Sélectionner l\'élément à cliquer',
},
{
name: 'clickType',
label: 'Type de clic',
type: 'select',
options: [
{ value: 'left', label: 'Clic gauche' },
{ value: 'right', label: 'Clic droit' },
{ value: 'double', label: 'Double-clic' },
],
defaultValue: 'left',
},
],
type: [
{
name: 'target',
label: 'Champ de saisie',
type: 'visual',
required: true,
description: 'Sélectionner le champ où saisir le texte',
},
{
name: 'text',
label: 'Texte à saisir',
type: 'text',
required: true,
supportVariables: true,
},
{
name: 'clearFirst',
label: 'Vider le champ d\'abord',
type: 'boolean',
defaultValue: true,
},
],
wait: [
{
name: 'duration',
label: 'Durée (secondes)',
type: 'number',
required: true,
min: 0.1,
max: 60,
defaultValue: 1,
},
],
condition: [
{
name: 'condition',
label: 'Condition',
type: 'text',
required: true,
supportVariables: true,
description: 'Expression conditionnelle à évaluer',
},
],
extract: [
{
name: 'target',
label: 'Élément source',
type: 'visual',
required: true,
description: 'Sélectionner l\'élément dont extraire les données',
},
{
name: 'attribute',
label: 'Attribut à extraire',
type: 'select',
options: [
{ value: 'text', label: 'Texte' },
{ value: 'value', label: 'Valeur' },
{ value: 'href', label: 'Lien (href)' },
{ value: 'src', label: 'Source (src)' },
],
defaultValue: 'text',
},
],
scroll: [
{
name: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'up', label: 'Vers le haut' },
{ value: 'down', label: 'Vers le bas' },
{ value: 'left', label: 'Vers la gauche' },
{ value: 'right', label: 'Vers la droite' },
],
defaultValue: 'down',
},
{
name: 'amount',
label: 'Quantité (pixels)',
type: 'number',
defaultValue: 300,
min: 1,
},
],
navigate: [
{
name: 'url',
label: 'URL de destination',
type: 'text',
required: true,
supportVariables: true,
},
],
screenshot: [
{
name: 'filename',
label: 'Nom du fichier',
type: 'text',
supportVariables: true,
description: 'Nom du fichier de capture (optionnel)',
},
],
};
/**
* Composant de test pour déboguer les propriétés d'étapes
*/
const PropertiesDebugTest: React.FC = () => {
const [selectedStepType, setSelectedStepType] = useState<StepType>('click');
const [testResults, setTestResults] = useState<any[]>([]);
// Hooks VWB
const { methods: vwbMethods } = useVWBStepIntegration();
// Créer une étape de test
const createTestStep = useCallback((stepType: StepType): Step => {
return {
id: `test_step_${Date.now()}`,
type: stepType,
name: `Test ${stepType}`,
position: { x: 100, y: 100 },
data: {
label: `Test ${stepType}`,
stepType: stepType,
parameters: {},
},
executionState: StepExecutionState.IDLE,
validationErrors: [],
};
}, []);
// Tester la résolution des paramètres
const testParameterResolution = useCallback((stepType: StepType) => {
console.log(`🧪 Test de résolution pour le type: ${stepType}`);
const testStep = createTestStep(stepType);
// Test 1: Configuration directe
const directConfig = stepParametersConfig[stepType];
// Test 2: Fonction getParameterConfig simulée
const getParameterConfig = (step: Step): ParameterConfig[] => {
if (!step) return [];
console.log(`🔍 Recherche config pour type: "${step.type}"`);
console.log(`🔍 Clés disponibles:`, Object.keys(stepParametersConfig));
console.log(`🔍 Type exact match:`, stepParametersConfig[step.type] !== undefined);
return stepParametersConfig[step.type] || [];
};
const resolvedConfig = getParameterConfig(testStep);
// Test 3: Hooks VWB
const isVWBStep = useIsVWBStep(testStep);
const vwbActionId = useVWBActionId(testStep);
const result = {
stepType,
testStep,
directConfig: directConfig || null,
directConfigLength: directConfig ? directConfig.length : 0,
resolvedConfig,
resolvedConfigLength: resolvedConfig.length,
isVWBStep,
vwbActionId,
configExists: stepParametersConfig[stepType] !== undefined,
timestamp: new Date().toISOString(),
};
console.log(`✅ Résultat test ${stepType}:`, result);
setTestResults(prev => [...prev, result]);
return result;
}, [createTestStep]);
// Tester tous les types d'étapes
const testAllStepTypes = useCallback(() => {
console.log('🚀 Test de tous les types d\'étapes');
setTestResults([]);
const allTypes: StepType[] = ['click', 'type', 'wait', 'condition', 'extract', 'scroll', 'navigate', 'screenshot'];
allTypes.forEach(stepType => {
setTimeout(() => testParameterResolution(stepType), 100);
});
}, [testParameterResolution]);
// Analyser les résultats
const analysisResults = useMemo(() => {
if (testResults.length === 0) return null;
const totalTests = testResults.length;
const successfulResolutions = testResults.filter(r => r.resolvedConfigLength > 0).length;
const failedResolutions = testResults.filter(r => r.resolvedConfigLength === 0).length;
const vwbSteps = testResults.filter(r => r.isVWBStep).length;
return {
totalTests,
successfulResolutions,
failedResolutions,
vwbSteps,
successRate: (successfulResolutions / totalTests) * 100,
};
}, [testResults]);
return (
<Box sx={{ p: 3, maxWidth: 1200, margin: '0 auto' }}>
<Typography variant="h4" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<BugIcon color="primary" />
Test Debug - Propriétés d'Étapes
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Ce composant teste la résolution des propriétés d'étapes pour diagnostiquer
pourquoi les paramètres apparaissent vides dans l'interface.
</Typography>
{/* Contrôles de test */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Contrôles de Test
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Button
variant="contained"
onClick={testAllStepTypes}
startIcon={<BugIcon />}
>
Tester Tous les Types
</Button>
<Button
variant="outlined"
onClick={() => setTestResults([])}
>
Vider les Résultats
</Button>
</Box>
<Typography variant="body2" color="text.secondary">
Cliquez sur "Tester Tous les Types" pour exécuter les tests de résolution
des paramètres pour chaque type d'étape.
</Typography>
</CardContent>
</Card>
{/* Analyse des résultats */}
{analysisResults && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{analysisResults.successRate === 100 ? (
<CheckIcon color="success" />
) : (
<ErrorIcon color="error" />
)}
Analyse des Résultats
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mb: 2 }}>
<Chip
label={`Total: ${analysisResults.totalTests}`}
color="primary"
/>
<Chip
label={`Succès: ${analysisResults.successfulResolutions}`}
color="success"
/>
<Chip
label={`Échecs: ${analysisResults.failedResolutions}`}
color="error"
/>
<Chip
label={`VWB: ${analysisResults.vwbSteps}`}
color="info"
/>
<Chip
label={`Taux: ${analysisResults.successRate.toFixed(1)}%`}
color={analysisResults.successRate === 100 ? 'success' : 'warning'}
/>
</Box>
{analysisResults.failedResolutions > 0 && (
<Alert severity="error" sx={{ mt: 2 }}>
<Typography variant="body2">
{analysisResults.failedResolutions} type(s) d'étapes ne résolvent pas leurs paramètres correctement.
Cela explique pourquoi les propriétés apparaissent vides dans l'interface.
</Typography>
</Alert>
)}
</CardContent>
</Card>
)}
{/* Configuration disponible */}
<Accordion sx={{ mb: 3 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">
Configuration stepParametersConfig Disponible
</Typography>
</AccordionSummary>
<AccordionDetails>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Type d'Étape</TableCell>
<TableCell>Nombre de Paramètres</TableCell>
<TableCell>Paramètres</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(stepParametersConfig).map(([stepType, config]) => (
<TableRow key={stepType}>
<TableCell>
<Chip label={stepType} size="small" />
</TableCell>
<TableCell>{config.length}</TableCell>
<TableCell>
{config.map(param => param.name).join(', ')}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</AccordionDetails>
</Accordion>
{/* Résultats des tests */}
{testResults.length > 0 && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">
Résultats Détaillés des Tests ({testResults.length})
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{testResults.map((result, index) => (
<Card key={index} variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">
Test: {result.stepType}
</Typography>
<Chip
label={result.resolvedConfigLength > 0 ? 'SUCCÈS' : 'ÉCHEC'}
color={result.resolvedConfigLength > 0 ? 'success' : 'error'}
/>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 2 }}>
<Box>
<Typography variant="body2" color="text.secondary">
Configuration Directe
</Typography>
<Typography variant="body1">
{result.directConfigLength} paramètres
</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">
Configuration Résolue
</Typography>
<Typography variant="body1">
{result.resolvedConfigLength} paramètres
</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">
Est Action VWB
</Typography>
<Typography variant="body1">
{result.isVWBStep ? 'Oui' : 'Non'}
</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">
Config Existe
</Typography>
<Typography variant="body1">
{result.configExists ? 'Oui' : 'Non'}
</Typography>
</Box>
</Box>
{result.resolvedConfigLength === 0 && result.configExists && (
<Alert severity="error" sx={{ mt: 2 }}>
<Typography variant="body2">
🚨 PROBLÈME IDENTIFIÉ: La configuration existe ({result.directConfigLength} paramètres)
mais la résolution retourne 0 paramètres. Cela indique un problème dans la logique
de résolution ou dans le mapping des types.
</Typography>
</Alert>
)}
<Accordion sx={{ mt: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="body2">
Détails Techniques
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box component="pre" sx={{
fontSize: '0.75rem',
bgcolor: '#f5f5f5',
p: 1,
borderRadius: 1,
overflow: 'auto',
}}>
{JSON.stringify(result, null, 2)}
</Box>
</AccordionDetails>
</Accordion>
</CardContent>
</Card>
))}
</Box>
</AccordionDetails>
</Accordion>
)}
</Box>
);
};
export default PropertiesDebugTest;

View File

@@ -0,0 +1,468 @@
/**
* Composant EmptyStateMessage - Messages informatifs pour états vides
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant affiche des messages informatifs et utiles lorsqu'aucune propriété
* n'est disponible, avec distinction entre différents types d'états vides.
*/
import React, { memo, useMemo } from 'react';
import {
Box,
Typography,
Alert,
Button,
List,
ListItem,
ListItemIcon,
ListItemText,
Chip,
Paper,
} from '@mui/material';
import {
Info as InfoIcon,
Warning as WarningIcon,
Error as ErrorIcon,
HelpOutline as HelpIcon,
Refresh as RefreshIcon,
Settings as SettingsIcon,
Lightbulb as LightbulbIcon,
Search as SearchIcon,
} from '@mui/icons-material';
/**
* Types de raisons pour l'état vide
*/
export type EmptyStateReason =
| 'no-parameters'
| 'loading-error'
| 'vwb-not-found'
| 'unknown-type'
| 'resolution-failed'
| 'catalog-unavailable'
| 'network-error';
/**
* Props du composant EmptyStateMessage
*/
export interface EmptyStateMessageProps {
stepType: string;
reason: EmptyStateReason;
error?: Error;
suggestions?: string[];
onRetry?: () => void;
onRefresh?: () => void;
onHelp?: () => void;
className?: string;
}
/**
* Configuration des messages par type de raison
*/
interface EmptyStateConfig {
severity: 'info' | 'warning' | 'error';
icon: React.ReactNode;
title: string;
description: string;
actionLabel?: string;
showSuggestions: boolean;
defaultSuggestions: string[];
}
/**
* Configurations des états vides
*/
const EMPTY_STATE_CONFIGS: Record<EmptyStateReason, EmptyStateConfig> = {
'no-parameters': {
severity: 'info',
icon: <InfoIcon />,
title: 'Aucun paramètre configurable',
description: 'Cette étape fonctionne sans paramètres supplémentaires.',
showSuggestions: false,
defaultSuggestions: []
},
'loading-error': {
severity: 'error',
icon: <ErrorIcon />,
title: 'Erreur de chargement',
description: 'Impossible de charger les propriétés de cette étape.',
actionLabel: 'Réessayer',
showSuggestions: true,
defaultSuggestions: [
'Vérifiez votre connexion réseau',
'Actualisez la page',
'Contactez le support si le problème persiste'
]
},
'vwb-not-found': {
severity: 'warning',
icon: <WarningIcon />,
title: 'Action VWB non trouvée',
description: 'Cette action du catalogue Vision-Only RPA n\'est pas disponible.',
actionLabel: 'Actualiser le catalogue',
showSuggestions: true,
defaultSuggestions: [
'Utilisez une action standard équivalente',
'Vérifiez que le catalogue VWB est à jour',
'Contactez l\'administrateur pour ajouter cette action'
]
},
'unknown-type': {
severity: 'warning',
icon: <HelpIcon />,
title: 'Type d\'étape non reconnu',
description: 'Ce type d\'étape n\'est pas pris en charge par l\'éditeur.',
showSuggestions: true,
defaultSuggestions: [
'Vérifiez l\'orthographe du type d\'étape',
'Utilisez un type d\'étape standard',
'Consultez la documentation des types supportés'
]
},
'resolution-failed': {
severity: 'error',
icon: <ErrorIcon />,
title: 'Échec de résolution',
description: 'Impossible de déterminer les propriétés de cette étape.',
actionLabel: 'Réessayer',
showSuggestions: true,
defaultSuggestions: [
'Vérifiez la configuration de l\'étape',
'Redémarrez l\'éditeur de workflow',
'Vérifiez les logs pour plus de détails'
]
},
'catalog-unavailable': {
severity: 'warning',
icon: <WarningIcon />,
title: 'Catalogue indisponible',
description: 'Le catalogue d\'actions n\'est pas accessible actuellement.',
actionLabel: 'Actualiser',
showSuggestions: true,
defaultSuggestions: [
'Vérifiez la connexion au serveur',
'Utilisez des actions standard en attendant',
'Contactez l\'administrateur système'
]
},
'network-error': {
severity: 'error',
icon: <ErrorIcon />,
title: 'Erreur réseau',
description: 'Impossible de se connecter au serveur pour charger les propriétés.',
actionLabel: 'Réessayer',
showSuggestions: true,
defaultSuggestions: [
'Vérifiez votre connexion internet',
'Vérifiez que le serveur est accessible',
'Réessayez dans quelques instants'
]
}
};
/**
* Composant EmptyStateMessage
*/
const EmptyStateMessage: React.FC<EmptyStateMessageProps> = ({
stepType,
reason,
error,
suggestions = [],
onRetry,
onRefresh,
onHelp,
className
}) => {
// Configuration pour la raison donnée
const config = useMemo(() => {
return EMPTY_STATE_CONFIGS[reason] || EMPTY_STATE_CONFIGS['unknown-type'];
}, [reason]);
// Suggestions finales (personnalisées + par défaut)
const finalSuggestions = useMemo(() => {
const customSuggestions = suggestions.length > 0 ? suggestions : [];
const defaultSuggestions = config.showSuggestions ? config.defaultSuggestions : [];
return [...customSuggestions, ...defaultSuggestions];
}, [suggestions, config]);
// Gestionnaire d'action principale
const handlePrimaryAction = () => {
if (reason === 'loading-error' || reason === 'resolution-failed' || reason === 'network-error') {
onRetry?.();
} else if (reason === 'vwb-not-found' || reason === 'catalog-unavailable') {
onRefresh?.();
}
};
// Rendu des détails d'erreur
const renderErrorDetails = () => {
if (!error) return null;
return (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" gutterBottom>
Détails de l'erreur :
</Typography>
<Paper
variant="outlined"
sx={{
p: 1,
backgroundColor: 'grey.50',
fontFamily: 'monospace',
fontSize: '0.75rem',
maxHeight: 100,
overflow: 'auto'
}}
>
{error.message}
</Paper>
</Box>
);
};
// Rendu des suggestions
const renderSuggestions = () => {
if (finalSuggestions.length === 0) return null;
return (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
<LightbulbIcon sx={{ fontSize: 16, mr: 0.5 }} />
Suggestions
</Typography>
<List dense>
{finalSuggestions.map((suggestion, index) => (
<ListItem key={index} sx={{ py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 24 }}>
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
backgroundColor: 'primary.main'
}}
/>
</ListItemIcon>
<ListItemText
primary={suggestion}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItem>
))}
</List>
</Box>
);
};
// Rendu des actions
const renderActions = () => {
const actions = [];
// Action principale
if (config.actionLabel && (onRetry || onRefresh)) {
actions.push(
<Button
key="primary"
variant="contained"
size="small"
startIcon={<RefreshIcon />}
onClick={handlePrimaryAction}
sx={{ mr: 1 }}
>
{config.actionLabel}
</Button>
);
}
// Action d'aide
if (onHelp) {
actions.push(
<Button
key="help"
variant="outlined"
size="small"
startIcon={<HelpIcon />}
onClick={onHelp}
>
Aide
</Button>
);
}
if (actions.length === 0) return null;
return (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
{actions}
</Box>
);
};
// Logging pour le développement
if (process.env.NODE_ENV === 'development') {
console.log(`📭 [EmptyStateMessage] Affichage état vide:`, {
stepType,
reason,
hasError: Boolean(error),
suggestionsCount: finalSuggestions.length,
hasActions: Boolean(config.actionLabel && (onRetry || onRefresh))
});
}
return (
<Box
className={className}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 3,
textAlign: 'center',
minHeight: 200
}}
role="status"
aria-label={`État vide: ${config.title}`}
>
{/* En-tête avec icône et titre */}
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 48,
height: 48,
borderRadius: '50%',
backgroundColor: `${config.severity}.light`,
color: `${config.severity}.main`,
mb: 1,
mx: 'auto'
}}
>
{config.icon}
</Box>
<Typography variant="h6" component="h3" gutterBottom>
{config.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{config.description}
</Typography>
</Box>
{/* Informations sur l'étape */}
<Box sx={{ mb: 2 }}>
<Chip
label={`Type: ${stepType}`}
size="small"
variant="outlined"
icon={<SettingsIcon />}
/>
</Box>
{/* Alert avec sévérité appropriée */}
<Alert
severity={config.severity}
sx={{ mb: 2, width: '100%', maxWidth: 400 }}
variant="outlined"
>
<Typography variant="body2">
{reason === 'no-parameters'
? 'Cette étape est prête à être utilisée sans configuration supplémentaire.'
: 'Une intervention peut être nécessaire pour configurer cette étape.'
}
</Typography>
</Alert>
{/* Détails d'erreur */}
{renderErrorDetails()}
{/* Suggestions */}
{renderSuggestions()}
{/* Actions */}
{renderActions()}
</Box>
);
};
/**
* Hook pour générer des suggestions contextuelles
*/
export function useEmptyStateSuggestions(
stepType: string,
reason: EmptyStateReason,
error?: Error
): string[] {
return useMemo(() => {
const suggestions: string[] = [];
// Suggestions basées sur le type d'étape
if (stepType.includes('click')) {
suggestions.push('Essayez d\'utiliser l\'action "click" standard');
} else if (stepType.includes('type')) {
suggestions.push('Essayez d\'utiliser l\'action "type" standard');
} else if (stepType.includes('wait')) {
suggestions.push('Essayez d\'utiliser l\'action "wait" standard');
}
// Suggestions basées sur l'erreur
if (error) {
if (error.message.includes('network')) {
suggestions.push('Problème de réseau détecté - vérifiez la connectivité');
} else if (error.message.includes('timeout')) {
suggestions.push('Délai d\'attente dépassé - réessayez plus tard');
} else if (error.message.includes('404')) {
suggestions.push('Ressource non trouvée - vérifiez la configuration');
}
}
// Suggestions basées sur la raison
switch (reason) {
case 'vwb-not-found':
suggestions.push(`Recherchez une alternative à "${stepType}" dans le catalogue`);
break;
case 'unknown-type':
suggestions.push(`Vérifiez si "${stepType}" est correctement orthographié`);
break;
}
return suggestions;
}, [stepType, reason, error]);
}
/**
* Composant EmptyStateMessage avec suggestions automatiques
*/
export const SmartEmptyStateMessage: React.FC<Omit<EmptyStateMessageProps, 'suggestions'>> = (props) => {
const autoSuggestions = useEmptyStateSuggestions(props.stepType, props.reason, props.error);
return (
<EmptyStateMessage
{...props}
suggestions={autoSuggestions}
/>
);
};
/**
* Export du composant mémorisé
*/
export default memo(EmptyStateMessage, (prevProps, nextProps) => {
return (
prevProps.stepType === nextProps.stepType &&
prevProps.reason === nextProps.reason &&
prevProps.error?.message === nextProps.error?.message &&
JSON.stringify(prevProps.suggestions) === JSON.stringify(nextProps.suggestions)
);
});

View File

@@ -0,0 +1,515 @@
/**
* Composant LoadingState - Indicateurs de chargement élégants et informatifs
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant affiche des indicateurs de chargement avec support de l'annulation,
* messages informatifs et maintien de la réactivité de l'interface.
*/
import React, { useState, useEffect, useCallback, memo } from 'react';
import {
Box,
Typography,
CircularProgress,
LinearProgress,
Button,
Fade,
Skeleton,
Alert,
Chip,
} from '@mui/material';
import {
Cancel as CancelIcon,
Refresh as RefreshIcon,
Info as InfoIcon,
Timer as TimerIcon,
} from '@mui/icons-material';
/**
* Types de chargement
*/
export type LoadingType =
| 'resolving'
| 'loading-vwb'
| 'validating'
| 'saving'
| 'fetching-catalog'
| 'processing'
| 'generic';
/**
* Props du composant LoadingState
*/
export interface LoadingStateProps {
type?: LoadingType;
message?: string;
progress?: number;
canCancel?: boolean;
onCancel?: () => void;
timeout?: number;
showElapsedTime?: boolean;
showSkeletons?: boolean;
variant?: 'circular' | 'linear' | 'skeleton';
size?: 'small' | 'medium' | 'large';
className?: string;
}
/**
* Configuration des messages par type de chargement
*/
interface LoadingConfig {
defaultMessage: string;
icon?: React.ReactNode;
color: 'primary' | 'secondary' | 'info' | 'warning';
estimatedDuration: number; // en millisecondes
showProgress: boolean;
}
/**
* Configurations des types de chargement
*/
const LOADING_CONFIGS: Record<LoadingType, LoadingConfig> = {
'resolving': {
defaultMessage: 'Résolution des propriétés d\'étape...',
color: 'primary',
estimatedDuration: 2000,
showProgress: false
},
'loading-vwb': {
defaultMessage: 'Chargement de l\'action VWB...',
color: 'info',
estimatedDuration: 3000,
showProgress: true
},
'validating': {
defaultMessage: 'Validation des paramètres...',
color: 'secondary',
estimatedDuration: 1500,
showProgress: false
},
'saving': {
defaultMessage: 'Sauvegarde en cours...',
color: 'primary',
estimatedDuration: 2500,
showProgress: true
},
'fetching-catalog': {
defaultMessage: 'Récupération du catalogue d\'actions...',
color: 'info',
estimatedDuration: 4000,
showProgress: true
},
'processing': {
defaultMessage: 'Traitement en cours...',
color: 'primary',
estimatedDuration: 3000,
showProgress: false
},
'generic': {
defaultMessage: 'Chargement...',
color: 'primary',
estimatedDuration: 2000,
showProgress: false
}
};
/**
* Hook pour le temps écoulé
*/
function useElapsedTime(isActive: boolean): number {
const [elapsedTime, setElapsedTime] = useState(0);
useEffect(() => {
if (!isActive) {
setElapsedTime(0);
return;
}
const startTime = Date.now();
const interval = setInterval(() => {
setElapsedTime(Date.now() - startTime);
}, 100);
return () => clearInterval(interval);
}, [isActive]);
return elapsedTime;
}
/**
* Hook pour la progression estimée
*/
function useEstimatedProgress(
type: LoadingType,
elapsedTime: number,
actualProgress?: number
): number {
return React.useMemo(() => {
if (actualProgress !== undefined) {
return Math.min(100, Math.max(0, actualProgress));
}
const config = LOADING_CONFIGS[type];
const estimatedProgress = Math.min(95, (elapsedTime / config.estimatedDuration) * 100);
return estimatedProgress;
}, [type, elapsedTime, actualProgress]);
}
/**
* Composant LoadingState
*/
const LoadingState: React.FC<LoadingStateProps> = ({
type = 'generic',
message,
progress,
canCancel = false,
onCancel,
timeout,
showElapsedTime = false,
showSkeletons = false,
variant = 'circular',
size = 'medium',
className
}) => {
const [isTimedOut, setIsTimedOut] = useState(false);
const [showTimeoutWarning, setShowTimeoutWarning] = useState(false);
// Configuration pour le type de chargement
const config = LOADING_CONFIGS[type];
const displayMessage = message || config.defaultMessage;
// Temps écoulé
const elapsedTime = useElapsedTime(true);
// Progression estimée
const estimatedProgress = useEstimatedProgress(type, elapsedTime, progress);
// Gestion du timeout
useEffect(() => {
if (!timeout) return;
const warningTimeout = setTimeout(() => {
setShowTimeoutWarning(true);
}, timeout * 0.8); // Avertissement à 80% du timeout
const finalTimeout = setTimeout(() => {
setIsTimedOut(true);
}, timeout);
return () => {
clearTimeout(warningTimeout);
clearTimeout(finalTimeout);
};
}, [timeout]);
// Gestionnaire d'annulation
const handleCancel = useCallback(() => {
if (onCancel) {
onCancel();
}
}, [onCancel]);
// Formatage du temps écoulé
const formatElapsedTime = (ms: number): string => {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
};
// Tailles des composants
const getSizes = () => {
switch (size) {
case 'small':
return { circularSize: 24, spacing: 1, typography: 'body2' as const };
case 'large':
return { circularSize: 48, spacing: 3, typography: 'h6' as const };
default:
return { circularSize: 32, spacing: 2, typography: 'body1' as const };
}
};
const sizes = getSizes();
// Rendu des squelettes
const renderSkeletons = () => {
if (!showSkeletons) return null;
return (
<Box sx={{ width: '100%', mt: 2 }}>
<Skeleton variant="text" width="80%" height={24} sx={{ mb: 1 }} />
<Skeleton variant="text" width="60%" height={20} sx={{ mb: 1 }} />
<Skeleton variant="rectangular" width="100%" height={40} sx={{ borderRadius: 1 }} />
</Box>
);
};
// Rendu de l'indicateur de progression
const renderProgressIndicator = () => {
switch (variant) {
case 'linear':
return (
<LinearProgress
variant={progress !== undefined ? 'determinate' : 'indeterminate'}
value={estimatedProgress}
color={config.color}
sx={{ width: '100%', mt: 1 }}
/>
);
case 'skeleton':
return renderSkeletons();
default: // circular
return (
<CircularProgress
size={sizes.circularSize}
variant={progress !== undefined ? 'determinate' : 'indeterminate'}
value={estimatedProgress}
color={config.color}
/>
);
}
};
// Rendu des informations temporelles
const renderTimeInfo = () => {
if (!showElapsedTime && !showTimeoutWarning) return null;
return (
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{showElapsedTime && (
<Chip
icon={<TimerIcon />}
label={formatElapsedTime(elapsedTime)}
size="small"
variant="outlined"
sx={{ mr: 1 }}
/>
)}
{showTimeoutWarning && !isTimedOut && (
<Chip
icon={<InfoIcon />}
label="Opération longue"
size="small"
color="warning"
variant="outlined"
/>
)}
</Box>
);
};
// Rendu des actions
const renderActions = () => {
if (!canCancel && !isTimedOut) return null;
return (
<Box sx={{ mt: 2, display: 'flex', gap: 1, justifyContent: 'center' }}>
{canCancel && !isTimedOut && (
<Button
variant="outlined"
size="small"
startIcon={<CancelIcon />}
onClick={handleCancel}
color="secondary"
>
Annuler
</Button>
)}
{isTimedOut && (
<Button
variant="contained"
size="small"
startIcon={<RefreshIcon />}
onClick={handleCancel}
color="primary"
>
Réessayer
</Button>
)}
</Box>
);
};
// Logging pour le développement
if (process.env.NODE_ENV === 'development') {
console.log(`⏳ [LoadingState] État de chargement:`, {
type,
elapsedTime: formatElapsedTime(elapsedTime),
progress: estimatedProgress.toFixed(1) + '%',
isTimedOut,
showTimeoutWarning
});
}
// Cas de timeout
if (isTimedOut) {
return (
<Box
className={className}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: sizes.spacing,
textAlign: 'center',
minHeight: 150
}}
role="status"
aria-label="Opération expirée"
>
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="body2">
L'opération prend plus de temps que prévu
</Typography>
</Alert>
<Typography variant={sizes.typography} color="text.secondary" gutterBottom>
{displayMessage}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mb: 2 }}>
Temps écoulé: {formatElapsedTime(elapsedTime)}
</Typography>
{renderActions()}
</Box>
);
}
return (
<Fade in timeout={300}>
<Box
className={className}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: sizes.spacing,
textAlign: 'center',
minHeight: variant === 'skeleton' ? 200 : 120
}}
role="status"
aria-label={displayMessage}
aria-live="polite"
>
{/* Indicateur de progression */}
{renderProgressIndicator()}
{/* Message de chargement */}
{variant !== 'skeleton' && (
<Typography
variant={sizes.typography}
color="text.secondary"
sx={{ mt: sizes.spacing }}
>
{displayMessage}
</Typography>
)}
{/* Progression numérique */}
{progress !== undefined && variant !== 'skeleton' && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
{Math.round(estimatedProgress)}%
</Typography>
)}
{/* Informations temporelles */}
{renderTimeInfo()}
{/* Actions */}
{renderActions()}
</Box>
</Fade>
);
};
/**
* Composant LoadingState spécialisé pour la résolution d'étapes
*/
export const StepResolutionLoading: React.FC<Omit<LoadingStateProps, 'type'>> = (props) => {
return (
<LoadingState
{...props}
type="resolving"
variant="circular"
size="small"
showElapsedTime={process.env.NODE_ENV === 'development'}
/>
);
};
/**
* Composant LoadingState spécialisé pour le chargement VWB
*/
export const VWBActionLoading: React.FC<Omit<LoadingStateProps, 'type'>> = (props) => {
return (
<LoadingState
{...props}
type="loading-vwb"
variant="linear"
showElapsedTime
canCancel
/>
);
};
/**
* Composant LoadingState spécialisé pour la sauvegarde
*/
export const SavingLoading: React.FC<Omit<LoadingStateProps, 'type'>> = (props) => {
return (
<LoadingState
{...props}
type="saving"
variant="linear"
size="small"
/>
);
};
/**
* Composant LoadingState avec squelettes pour les paramètres
*/
export const ParametersSkeletonLoading: React.FC<Omit<LoadingStateProps, 'type' | 'variant'>> = (props) => {
return (
<LoadingState
{...props}
type="resolving"
variant="skeleton"
showSkeletons
/>
);
};
/**
* Export du composant mémorisé
*/
export default memo(LoadingState, (prevProps, nextProps) => {
return (
prevProps.type === nextProps.type &&
prevProps.message === nextProps.message &&
prevProps.progress === nextProps.progress &&
prevProps.canCancel === nextProps.canCancel &&
prevProps.timeout === nextProps.timeout &&
prevProps.variant === nextProps.variant &&
prevProps.size === nextProps.size
);
});

View File

@@ -0,0 +1,565 @@
/**
* Composant ParameterFieldRenderer - Rendu unifié des champs de paramètres
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant fournit un système de rendu unifié pour tous les types de champs
* de paramètres d'étapes, avec support de l'extensibilité et validation intégrée.
*/
import React, { memo, useCallback, useMemo, useState } from 'react';
import {
Box,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Switch,
FormControlLabel,
Button,
Typography,
Chip,
Alert,
} from '@mui/material';
import {
Visibility as VisibilityIcon,
Error as ErrorIcon,
CheckCircle as CheckCircleIcon,
} from '@mui/icons-material';
// Import des composants spécialisés
import VariableAutocomplete from '../VariableAutocomplete';
import RealScreenCapture from '../RealScreenCapture';
// Import des types
import { ParameterConfig } from '../../services/StepTypeResolver';
import { Variable, ValidationError, VisualSelection } from '../../types';
/**
* Props du ParameterFieldRenderer
*/
export interface ParameterFieldRendererProps {
config: ParameterConfig;
value: any;
variables: Variable[];
error?: ValidationError;
onChange: (value: any) => void;
onVisualSelection?: () => void;
disabled?: boolean;
className?: string;
}
/**
* Props des renderers spécialisés
*/
interface BaseFieldRendererProps {
config: ParameterConfig;
value: any;
error?: ValidationError;
onChange: (value: any) => void;
disabled?: boolean;
}
interface TextFieldRendererProps extends BaseFieldRendererProps {
variables: Variable[];
}
interface SelectFieldRendererProps extends BaseFieldRendererProps {}
interface NumberFieldRendererProps extends BaseFieldRendererProps {}
interface BooleanFieldRendererProps extends BaseFieldRendererProps {}
interface VisualFieldRendererProps extends BaseFieldRendererProps {
onVisualSelection?: () => void;
}
/**
* Renderer pour les champs de texte
*/
const TextFieldRenderer: React.FC<TextFieldRendererProps> = memo(({
config,
value,
variables,
error,
onChange,
disabled = false
}) => {
const hasError = Boolean(error);
// Utiliser l'autocomplétion des variables si supporté
if (config.supportVariables) {
return (
<VariableAutocomplete
label={config.label}
value={value || ''}
onChange={onChange}
variables={variables}
error={hasError}
helperText={hasError ? error!.message : config.description}
required={config.required}
disabled={disabled}
placeholder={config.placeholder}
multiline={config.multiline}
/>
);
}
// Champ de texte standard
return (
<TextField
fullWidth
label={config.label}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
error={hasError}
helperText={hasError ? error!.message : config.description}
required={config.required}
disabled={disabled}
placeholder={config.placeholder}
multiline={config.multiline}
rows={config.multiline ? 3 : 1}
/>
);
});
TextFieldRenderer.displayName = 'TextFieldRenderer';
/**
* Renderer pour les champs numériques
*/
const NumberFieldRenderer: React.FC<NumberFieldRendererProps> = memo(({
config,
value,
error,
onChange,
disabled = false
}) => {
const hasError = Boolean(error);
const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const numValue = Number(event.target.value);
onChange(isNaN(numValue) ? '' : numValue);
}, [onChange]);
return (
<TextField
fullWidth
type="number"
label={config.label}
value={value ?? ''}
onChange={handleChange}
error={hasError}
helperText={hasError ? error!.message : config.description}
required={config.required}
disabled={disabled}
placeholder={config.placeholder}
slotProps={{
htmlInput: {
min: config.min,
max: config.max,
step: config.step || 0.1,
}
}}
/>
);
});
NumberFieldRenderer.displayName = 'NumberFieldRenderer';
/**
* Renderer pour les champs booléens (switch)
*/
const BooleanFieldRenderer: React.FC<BooleanFieldRendererProps> = memo(({
config,
value,
error,
onChange,
disabled = false
}) => {
const hasError = Boolean(error);
return (
<Box>
<FormControlLabel
control={
<Switch
checked={Boolean(value)}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
color={hasError ? 'error' : 'primary'}
/>
}
label={config.label}
/>
{config.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{config.description}
</Typography>
)}
{hasError && (
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 0.5 }}>
<ErrorIcon sx={{ fontSize: 14, mr: 0.5, verticalAlign: 'middle' }} />
{error!.message}
</Typography>
)}
</Box>
);
});
BooleanFieldRenderer.displayName = 'BooleanFieldRenderer';
/**
* Renderer pour les champs de sélection
*/
const SelectFieldRenderer: React.FC<SelectFieldRendererProps> = memo(({
config,
value,
error,
onChange,
disabled = false
}) => {
const hasError = Boolean(error);
return (
<FormControl fullWidth error={hasError} disabled={disabled}>
<InputLabel required={config.required}>{config.label}</InputLabel>
<Select
value={value || ''}
onChange={(e) => onChange(e.target.value)}
label={config.label}
>
{config.options?.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
{(hasError || config.description) && (
<Typography
variant="caption"
color={hasError ? 'error' : 'text.secondary'}
sx={{ mt: 0.5, display: 'block' }}
>
{hasError ? error!.message : config.description}
</Typography>
)}
</FormControl>
);
});
SelectFieldRenderer.displayName = 'SelectFieldRenderer';
/**
* Renderer pour les champs de sélection visuelle
*/
const VisualFieldRenderer: React.FC<VisualFieldRendererProps> = memo(({
config,
value,
error,
onChange,
onVisualSelection,
disabled = false
}) => {
const hasError = Boolean(error);
const hasSelection = Boolean(value);
const [isScreenCaptureOpen, setIsScreenCaptureOpen] = useState(false);
const handleVisualSelection = useCallback(() => {
if (!disabled) {
setIsScreenCaptureOpen(true);
}
}, [disabled]);
const handleElementSelected = useCallback((selection: VisualSelection) => {
onChange(selection);
setIsScreenCaptureOpen(false);
// Notifier le parent si nécessaire
if (onVisualSelection) {
onVisualSelection();
}
}, [onChange, onVisualSelection]);
const handleClearSelection = useCallback(() => {
onChange(null);
}, [onChange]);
return (
<Box>
<Button
fullWidth
variant="outlined"
startIcon={<VisibilityIcon />}
onClick={handleVisualSelection}
color={hasError ? 'error' : hasSelection ? 'success' : 'primary'}
disabled={disabled}
sx={{ mb: 1 }}
>
{hasSelection ? 'Modifier la sélection' : config.label}
</Button>
{hasSelection && (
<Box sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Chip
label="Élément sélectionné"
size="small"
color="success"
icon={<CheckCircleIcon />}
onDelete={disabled ? undefined : handleClearSelection}
/>
{value?.boundingBox && (
<Typography variant="caption" color="text.secondary">
({Math.round(value.boundingBox.x)}px, {Math.round(value.boundingBox.y)}px) -
{Math.round(value.boundingBox.width)}×{Math.round(value.boundingBox.height)}px
</Typography>
)}
</Box>
{/* Aperçu de la sélection visuelle */}
{value?.screenshot && (
<Box
sx={{
position: 'relative',
width: '100%',
maxHeight: 150,
overflow: 'hidden',
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
mb: 1,
}}
>
<img
src={value.screenshot}
alt="Aperçu de la sélection"
style={{
width: '100%',
height: 'auto',
maxHeight: 150,
objectFit: 'contain',
display: 'block',
}}
/>
{/* Indicateur de la zone sélectionnée */}
{value.boundingBox && (
<Box
sx={{
position: 'absolute',
left: `${(value.boundingBox.x / 1920) * 100}%`,
top: `${(value.boundingBox.y / 1080) * 100}%`,
width: `${Math.max((value.boundingBox.width / 1920) * 100, 2)}%`,
height: `${Math.max((value.boundingBox.height / 1080) * 100, 2)}%`,
border: '2px solid #4caf50',
backgroundColor: 'rgba(76, 175, 80, 0.2)',
pointerEvents: 'none',
}}
/>
)}
</Box>
)}
</Box>
)}
{config.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{config.description}
</Typography>
)}
{hasError && (
<Alert severity="error" sx={{ mt: 1 }}>
{error!.message}
</Alert>
)}
{/* Composant de capture d'écran */}
<RealScreenCapture
isOpen={isScreenCaptureOpen}
onClose={() => setIsScreenCaptureOpen(false)}
onElementSelected={handleElementSelected}
stepId={`field_${config.name}`}
/>
</Box>
);
});
VisualFieldRenderer.displayName = 'VisualFieldRenderer';
/**
* Map des renderers par type de champ
*/
const FIELD_RENDERERS = {
text: TextFieldRenderer,
number: NumberFieldRenderer,
boolean: BooleanFieldRenderer,
select: SelectFieldRenderer,
visual: VisualFieldRenderer,
} as const;
/**
* Type pour les renderers de champs
*/
export type FieldRendererType = keyof typeof FIELD_RENDERERS;
/**
* Interface pour l'enregistrement de renderers personnalisés
*/
export interface CustomFieldRenderer {
type: string;
component: React.ComponentType<any>;
displayName: string;
}
/**
* Registre des renderers personnalisés
*/
class FieldRendererRegistry {
private customRenderers = new Map<string, CustomFieldRenderer>();
/**
* Enregistre un renderer personnalisé
*/
register(renderer: CustomFieldRenderer): void {
this.customRenderers.set(renderer.type, renderer);
console.log(`📝 [ParameterFieldRenderer] Renderer personnalisé enregistré: ${renderer.type}`);
}
/**
* Obtient un renderer par type
*/
getRenderer(type: string): React.ComponentType<any> | null {
// Vérifier d'abord les renderers personnalisés
const customRenderer = this.customRenderers.get(type);
if (customRenderer) {
return customRenderer.component;
}
// Vérifier les renderers standard
if (type in FIELD_RENDERERS) {
return FIELD_RENDERERS[type as FieldRendererType];
}
return null;
}
/**
* Liste tous les types de renderers disponibles
*/
getAvailableTypes(): string[] {
const standardTypes = Object.keys(FIELD_RENDERERS);
const customTypes = Array.from(this.customRenderers.keys());
return [...standardTypes, ...customTypes];
}
}
/**
* Instance singleton du registre
*/
export const fieldRendererRegistry = new FieldRendererRegistry();
/**
* Composant principal ParameterFieldRenderer
*/
const ParameterFieldRenderer: React.FC<ParameterFieldRendererProps> = ({
config,
value,
variables,
error,
onChange,
onVisualSelection,
disabled = false,
className
}) => {
// Obtenir le renderer approprié
const RendererComponent = useMemo(() => {
const renderer = fieldRendererRegistry.getRenderer(config.type);
if (!renderer) {
console.warn(`⚠️ [ParameterFieldRenderer] Renderer non trouvé pour le type: ${config.type}`);
return null;
}
return renderer;
}, [config.type]);
// Props communes pour tous les renderers
const commonProps = useMemo(() => ({
config,
value,
error,
onChange,
disabled
}), [config, value, error, onChange, disabled]);
// Props spécifiques selon le type
const specificProps = useMemo(() => {
switch (config.type) {
case 'text':
return { variables };
case 'visual':
return { onVisualSelection };
default:
return {};
}
}, [config.type, variables, onVisualSelection]);
// Logging pour le développement
if (process.env.NODE_ENV === 'development') {
console.log(`🎨 [ParameterFieldRenderer] Rendu du champ:`, {
name: config.name,
type: config.type,
hasValue: value !== undefined && value !== null && value !== '',
hasError: Boolean(error),
disabled
});
}
if (!RendererComponent) {
return (
<Alert severity="error" className={className}>
<Typography variant="body2">
Type de champ non supporté: <code>{config.type}</code>
</Typography>
<Typography variant="caption" color="text.secondary">
Types disponibles: {fieldRendererRegistry.getAvailableTypes().join(', ')}
</Typography>
</Alert>
);
}
return (
<Box className={className} data-field-type={config.type} data-field-name={config.name}>
<RendererComponent
{...commonProps}
{...specificProps}
/>
</Box>
);
};
/**
* Export du composant mémorisé
*/
export default memo(ParameterFieldRenderer, (prevProps, nextProps) => {
return (
prevProps.config.name === nextProps.config.name &&
prevProps.config.type === nextProps.config.type &&
prevProps.value === nextProps.value &&
prevProps.error?.message === nextProps.error?.message &&
prevProps.disabled === nextProps.disabled &&
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables)
);
});
/**
* Exports utilitaires
*/
export {
TextFieldRenderer,
NumberFieldRenderer,
BooleanFieldRenderer,
SelectFieldRenderer,
VisualFieldRenderer,
FIELD_RENDERERS
};

View File

@@ -0,0 +1,526 @@
/**
* Composant StandardParametersEditor - Éditeur de paramètres pour étapes standard
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant fournit une interface complète pour éditer les paramètres des étapes
* standard avec validation en temps réel, sauvegarde automatique et gestion d'état.
*/
import React, { useState, useCallback, useEffect, useMemo, memo } from 'react';
import {
Box,
Typography,
Alert,
Divider,
Chip,
LinearProgress,
Collapse,
IconButton,
} from '@mui/material';
import {
CheckCircle as CheckCircleIcon,
Error as ErrorIcon,
Warning as WarningIcon,
Info as InfoIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
} from '@mui/icons-material';
// Import des composants
import ParameterFieldRenderer from './ParameterFieldRenderer';
// Import des types
import { ParameterConfig } from '../../services/StepTypeResolver';
import { Variable, ValidationError } from '../../types';
/**
* Props du StandardParametersEditor
*/
export interface StandardParametersEditorProps {
stepType: string;
parameterConfigs: ParameterConfig[];
parameters: Record<string, any>;
variables: Variable[];
onParameterChange: (paramName: string, value: any) => void;
onValidationChange: (errors: ValidationError[]) => void;
onVisualSelection?: () => void;
disabled?: boolean;
showValidationSummary?: boolean;
groupParameters?: boolean;
className?: string;
}
/**
* État de validation d'un paramètre
*/
interface ParameterValidationState {
isValid: boolean;
errors: ValidationError[];
warnings: ValidationError[];
infos: ValidationError[];
}
/**
* Résultat de validation globale
*/
interface ValidationSummary {
isValid: boolean;
totalErrors: number;
totalWarnings: number;
totalInfos: number;
parameterStates: Record<string, ParameterValidationState>;
}
/**
* Groupe de paramètres
*/
interface ParameterGroup {
name: string;
label: string;
parameters: ParameterConfig[];
expanded: boolean;
}
/**
* Hook pour la validation en temps réel
*/
function useParameterValidation(
parameterConfigs: ParameterConfig[],
parameters: Record<string, any>
): ValidationSummary {
return useMemo(() => {
const parameterStates: Record<string, ParameterValidationState> = {};
let totalErrors = 0;
let totalWarnings = 0;
let totalInfos = 0;
for (const config of parameterConfigs) {
const value = parameters[config.name];
const errors: ValidationError[] = [];
const warnings: ValidationError[] = [];
const infos: ValidationError[] = [];
// Validation de base - champ requis
if (config.required && (value === undefined || value === null || value === '')) {
errors.push({
parameter: config.name,
message: `Le champ "${config.label}" est requis`,
severity: 'error',
code: 'REQUIRED_FIELD'
});
}
// Validation spécifique par type
if (value !== undefined && value !== null && value !== '') {
switch (config.type) {
case 'number':
const numValue = Number(value);
if (isNaN(numValue)) {
errors.push({
parameter: config.name,
message: `"${config.label}" doit être un nombre valide`,
severity: 'error',
code: 'INVALID_NUMBER'
});
} else {
if (config.min !== undefined && numValue < config.min) {
errors.push({
parameter: config.name,
message: `"${config.label}" doit être supérieur ou égal à ${config.min}`,
severity: 'error',
code: 'MIN_VALUE'
});
}
if (config.max !== undefined && numValue > config.max) {
errors.push({
parameter: config.name,
message: `"${config.label}" doit être inférieur ou égal à ${config.max}`,
severity: 'error',
code: 'MAX_VALUE'
});
}
}
break;
case 'select':
if (config.options && !config.options.some(opt => opt.value === value)) {
errors.push({
parameter: config.name,
message: `"${config.label}" doit être une des options disponibles`,
severity: 'error',
code: 'INVALID_OPTION'
});
}
break;
case 'text':
if (typeof value !== 'string') {
errors.push({
parameter: config.name,
message: `"${config.label}" doit être du texte`,
severity: 'error',
code: 'INVALID_TEXT'
});
} else {
// Validation des variables si supportées
if (config.supportVariables) {
const variablePattern = /\$\{([^}]+)\}/g;
let match;
while ((match = variablePattern.exec(value)) !== null) {
const varName = match[1];
// Note: Dans une implémentation complète, on vérifierait si la variable existe
infos.push({
parameter: config.name,
message: `Variable utilisée: ${varName}`,
severity: 'info',
code: 'VARIABLE_USAGE'
});
}
}
}
break;
case 'visual':
if (typeof value !== 'object' || !value.selector) {
warnings.push({
parameter: config.name,
message: `"${config.label}" nécessite une sélection visuelle valide`,
severity: 'warning',
code: 'INCOMPLETE_VISUAL_SELECTION'
});
}
break;
}
}
// Validation conditionnelle
if (config.conditional) {
// Implémentation future pour les règles conditionnelles
}
parameterStates[config.name] = {
isValid: errors.length === 0,
errors,
warnings,
infos
};
totalErrors += errors.length;
totalWarnings += warnings.length;
totalInfos += infos.length;
}
return {
isValid: totalErrors === 0,
totalErrors,
totalWarnings,
totalInfos,
parameterStates
};
}, [parameterConfigs, parameters]);
}
/**
* Hook pour le groupement des paramètres
*/
function useParameterGroups(
parameterConfigs: ParameterConfig[],
groupParameters: boolean
): ParameterGroup[] {
return useMemo(() => {
if (!groupParameters) {
return [{
name: 'default',
label: 'Paramètres',
parameters: parameterConfigs,
expanded: true
}];
}
// Grouper par propriété 'group' ou par type
const groups: Record<string, ParameterConfig[]> = {};
for (const config of parameterConfigs) {
const groupName = config.group || config.type;
if (!groups[groupName]) {
groups[groupName] = [];
}
groups[groupName].push(config);
}
// Convertir en tableau de groupes
return Object.entries(groups).map(([name, parameters]) => ({
name,
label: name === 'default' ? 'Paramètres' :
name === 'text' ? 'Champs de texte' :
name === 'number' ? 'Champs numériques' :
name === 'boolean' ? 'Options' :
name === 'select' ? 'Sélections' :
name === 'visual' ? 'Sélections visuelles' :
name.charAt(0).toUpperCase() + name.slice(1),
parameters: parameters.sort((a, b) => (a.order || 0) - (b.order || 0)),
expanded: true
}));
}, [parameterConfigs, groupParameters]);
}
/**
* Composant StandardParametersEditor
*/
const StandardParametersEditor: React.FC<StandardParametersEditorProps> = ({
stepType,
parameterConfigs,
parameters,
variables,
onParameterChange,
onValidationChange,
onVisualSelection,
disabled = false,
showValidationSummary = true,
groupParameters = false,
className
}) => {
// État local pour les groupes expandus
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
// Validation en temps réel
const validationSummary = useParameterValidation(parameterConfigs, parameters);
// Groupement des paramètres
const parameterGroups = useParameterGroups(parameterConfigs, groupParameters);
// Effet pour notifier les changements de validation
useEffect(() => {
const allErrors: ValidationError[] = [];
Object.values(validationSummary.parameterStates).forEach(state => {
allErrors.push(...state.errors, ...state.warnings);
});
onValidationChange(allErrors);
}, [validationSummary, onValidationChange]);
// Gestionnaire de changement de paramètre avec validation
const handleParameterChange = useCallback((paramName: string, value: any) => {
console.log(`📝 [StandardParametersEditor] Changement paramètre:`, {
stepType,
paramName,
value,
type: typeof value
});
onParameterChange(paramName, value);
}, [stepType, onParameterChange]);
// Gestionnaire de toggle de groupe
const handleGroupToggle = useCallback((groupName: string) => {
setExpandedGroups(prev => ({
...prev,
[groupName]: !prev[groupName]
}));
}, []);
// Obtenir l'état de validation pour un paramètre
const getParameterValidation = useCallback((paramName: string): ValidationError | undefined => {
const state = validationSummary.parameterStates[paramName];
if (!state) return undefined;
// Retourner la première erreur, sinon le premier warning
return state.errors[0] || state.warnings[0] || undefined;
}, [validationSummary]);
// Rendu du résumé de validation
const renderValidationSummary = () => {
if (!showValidationSummary) return null;
const { isValid, totalErrors, totalWarnings, totalInfos } = validationSummary;
if (totalErrors === 0 && totalWarnings === 0 && totalInfos === 0) {
return (
<Alert severity="success" icon={<CheckCircleIcon />} sx={{ mb: 2 }}>
<Typography variant="body2">
Tous les paramètres sont correctement configurés
</Typography>
</Alert>
);
}
return (
<Box sx={{ mb: 2 }}>
{totalErrors > 0 && (
<Alert severity="error" icon={<ErrorIcon />} sx={{ mb: 1 }}>
<Typography variant="body2">
{totalErrors} erreur{totalErrors > 1 ? 's' : ''} de validation
</Typography>
</Alert>
)}
{totalWarnings > 0 && (
<Alert severity="warning" icon={<WarningIcon />} sx={{ mb: 1 }}>
<Typography variant="body2">
{totalWarnings} avertissement{totalWarnings > 1 ? 's' : ''}
</Typography>
</Alert>
)}
{totalInfos > 0 && (
<Alert severity="info" icon={<InfoIcon />}>
<Typography variant="body2">
{totalInfos} information{totalInfos > 1 ? 's' : ''}
</Typography>
</Alert>
)}
</Box>
);
};
// Rendu d'un groupe de paramètres
const renderParameterGroup = (group: ParameterGroup) => {
const isExpanded = expandedGroups[group.name] ?? group.expanded;
return (
<Box key={group.name} sx={{ mb: 2 }}>
{parameterGroups.length > 1 && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
mb: 1,
p: 1,
borderRadius: 1,
'&:hover': { backgroundColor: 'action.hover' }
}}
onClick={() => handleGroupToggle(group.name)}
>
<Typography variant="subtitle2" sx={{ flex: 1 }}>
{group.label} ({group.parameters.length})
</Typography>
<IconButton size="small">
{isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
)}
<Collapse in={isExpanded}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{group.parameters.map((config) => {
const value = parameters[config.name];
const error = getParameterValidation(config.name);
return (
<Box key={config.name}>
<ParameterFieldRenderer
config={config}
value={value}
variables={variables}
error={error}
onChange={(newValue) => handleParameterChange(config.name, newValue)}
onVisualSelection={config.type === 'visual' ? onVisualSelection : undefined}
disabled={disabled}
/>
</Box>
);
})}
</Box>
</Collapse>
</Box>
);
};
// Rendu des variables disponibles
const renderVariablesSection = () => {
if (variables.length === 0) return null;
const hasVariableSupport = parameterConfigs.some(config => config.supportVariables);
if (!hasVariableSupport) return null;
return (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" gutterBottom>
Variables disponibles
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{variables.map((variable) => (
<Chip
key={variable.id}
label={`\${${variable.name}}`}
size="small"
variant="outlined"
onClick={() => {
// Copier dans le presse-papiers
navigator.clipboard.writeText(`\${${variable.name}}`);
}}
sx={{ cursor: 'pointer' }}
/>
))}
</Box>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Cliquez sur une variable pour la copier dans le presse-papiers
</Typography>
</>
);
};
// Logging pour le développement
if (process.env.NODE_ENV === 'development') {
console.log(`🎛️ [StandardParametersEditor] Rendu:`, {
stepType,
parameterCount: parameterConfigs.length,
groupCount: parameterGroups.length,
validationState: {
isValid: validationSummary.isValid,
errors: validationSummary.totalErrors,
warnings: validationSummary.totalWarnings
},
disabled
});
}
// Cas où aucun paramètre n'est configuré
if (parameterConfigs.length === 0) {
return (
<Box className={className}>
<Typography variant="body2" color="text.secondary" textAlign="center" sx={{ py: 4 }}>
Cette étape n'a pas de paramètres configurables.
</Typography>
</Box>
);
}
return (
<Box className={className} data-step-type={stepType}>
{/* Résumé de validation */}
{renderValidationSummary()}
{/* Indicateur de progression si validation en cours */}
{disabled && (
<LinearProgress sx={{ mb: 2 }} />
)}
{/* Groupes de paramètres */}
{parameterGroups.map(renderParameterGroup)}
{/* Section des variables */}
{renderVariablesSection()}
</Box>
);
};
/**
* Export du composant mémorisé
*/
export default memo(StandardParametersEditor, (prevProps, nextProps) => {
return (
prevProps.stepType === nextProps.stepType &&
JSON.stringify(prevProps.parameterConfigs) === JSON.stringify(nextProps.parameterConfigs) &&
JSON.stringify(prevProps.parameters) === JSON.stringify(nextProps.parameters) &&
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables) &&
prevProps.disabled === nextProps.disabled &&
prevProps.showValidationSummary === nextProps.showValidationSummary &&
prevProps.groupParameters === nextProps.groupParameters
);
});

View File

@@ -0,0 +1,633 @@
/**
* Composant Panneau de Propriétés - Configuration des paramètres d'étapes
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant affiche et permet la modification des paramètres d'une étape sélectionnée,
* avec validation en temps réel et adaptation selon le type de paramètre.
*
* Version refactorisée utilisant le nouveau StepTypeResolver pour une résolution
* unifiée et robuste des propriétés d'étapes.
*/
import React, { useState, useCallback, useMemo, memo } from 'react';
import {
Box,
Typography,
Button,
Alert,
Divider,
Chip,
CircularProgress,
} from '@mui/material';
import {
Visibility as VisibilityIcon,
Error as ErrorIcon,
BugReport as BugReportIcon,
} from '@mui/icons-material';
// Import des composants
import VisualSelector from '../VisualSelector';
import VariableAutocomplete from '../VariableAutocomplete';
import VWBActionProperties from './VWBActionProperties';
import DebugPanel from '../DebugPanel';
// Import des nouveaux composants de l'interface complète
import StandardParametersEditor from './StandardParametersEditor';
import EmptyStateMessage from './EmptyStateMessage';
import LoadingState, { StepResolutionLoading, VWBActionLoading } from './LoadingState';
// Import du nouveau système de résolution
import { useStepTypeResolver } from '../../hooks/useStepTypeResolver';
import { ParameterConfig, StepTypeResolutionResult } from '../../services/StepTypeResolver';
// Import des hooks d'intégration VWB (pour compatibilité)
import { useVWBStepIntegration, useVWBActionId } from '../../hooks/useVWBStepIntegration';
// Import des types du catalogue VWB
import { VWBCatalogAction, VWBActionValidationResult } from '../../types/catalog';
// Import des hooks d'auto-sauvegarde
import { useStepParametersAutoSave } from '../../hooks/useAutoSave';
// Import des types partagés
import {
PropertiesPanelProps,
ValidationError,
VisualSelection,
Variable,
} from '../../types';
// Types pour l'état d'affichage
interface DisplayState {
type: 'loading' | 'empty' | 'vwb-properties' | 'standard-parameters';
subtype?: 'resolving' | 'loading-vwb';
reason?: 'resolution-failed' | 'vwb-not-found' | 'no-parameters' | 'unknown-type';
error?: Error;
}
/**
* Composant Panneau de Propriétés
*/
const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
selectedStep,
variables,
onParameterChange,
onVisualSelection,
}) => {
const [localParameters, setLocalParameters] = useState<Record<string, any>>({});
const [isVisualSelectorOpen, setIsVisualSelectorOpen] = useState(false);
const [isDebugPanelVisible, setIsDebugPanelVisible] = useState(false);
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([]);
// Utilisation du nouveau système de résolution unifié
const stepResolver = useStepTypeResolver(selectedStep || null, {
autoResolve: true,
enableLogging: process.env.NODE_ENV === 'development',
onResolutionComplete: (result) => {
console.log('✅ [PropertiesPanel] Résolution terminée:', {
stepType: result.stepType,
isVWBAction: result.isVWBAction,
parameterCount: result.parameterConfig.length,
source: result.resolutionSource
});
},
onResolutionError: (error) => {
console.error('❌ [PropertiesPanel] Erreur de résolution:', error);
}
});
// Hooks d'intégration VWB (pour compatibilité et chargement des actions)
const { methods: vwbMethods } = useVWBStepIntegration();
const vwbActionId = useVWBActionId(selectedStep || null);
// État dérivé du résolveur
const {
result: resolutionResult,
isLoading: isResolving,
error: resolutionError,
resolveStep,
resolveStepSync,
invalidateCache: invalidateResolverCache,
hasParameterConfig,
parameterCount,
isStandardType,
resolutionSource
} = stepResolver;
// Déterminer si c'est une action VWB
// Utiliser vwbActionId comme indicateur principal car le hook useVWBActionId fait une détection robuste
const isVWBCatalogAction = useMemo(() => {
// Si vwbActionId est défini, c'est une action VWB (détection via hook)
if (vwbActionId) {
return true;
}
// Fallback vers le résultat du résolveur
return resolutionResult?.isVWBAction || false;
}, [resolutionResult, vwbActionId]);
// Obtenir la configuration des paramètres
const parameterConfigs = useMemo(() => {
if (!resolutionResult) return [];
return resolutionResult.parameterConfig || [];
}, [resolutionResult]);
// Charger l'action VWB si nécessaire
const [vwbAction, setVwbAction] = useState<VWBCatalogAction | null>(null);
const [isLoadingVWBAction, setIsLoadingVWBAction] = useState(false);
const [vwbActionError, setVwbActionError] = useState<Error | null>(null);
// Hook d'auto-sauvegarde pour les paramètres
const autoSave = useStepParametersAutoSave(
selectedStep?.id || '',
onParameterChange,
{
debounceMs: 800,
enableLogging: process.env.NODE_ENV === 'development',
onSaveStart: () => console.log('💾 [PropertiesPanel] Début sauvegarde auto'),
onSaveSuccess: () => console.log('✅ [PropertiesPanel] Sauvegarde auto réussie'),
onSaveError: (error) => console.error('❌ [PropertiesPanel] Erreur sauvegarde auto:', error)
}
);
// Méthode pour résoudre manuellement une étape
const handleManualResolveStep = useCallback(async (): Promise<StepTypeResolutionResult | null> => {
if (!selectedStep) return null;
try {
console.log('🔄 [PropertiesPanel] Résolution manuelle de l\'étape:', selectedStep.id);
const result: StepTypeResolutionResult = await resolveStep(selectedStep, { enableCache: false });
console.log('✅ [PropertiesPanel] Résolution manuelle terminée:', result);
return result;
} catch (error) {
console.error('❌ [PropertiesPanel] Erreur résolution manuelle:', error);
return null;
}
}, [selectedStep, resolveStep]);
// Méthode pour invalider le cache et recharger
const handleRefreshResolution = useCallback(() => {
console.log('🔄 [PropertiesPanel] Actualisation de la résolution');
invalidateResolverCache();
if (selectedStep) {
handleManualResolveStep();
}
}, [invalidateResolverCache, selectedStep, handleManualResolveStep]);
// Charger l'action VWB basé sur vwbActionId (pas sur isVWBCatalogAction du résolveur)
React.useEffect(() => {
const loadVWBAction = async () => {
// Charger si vwbActionId est défini (le hook useVWBActionId fait déjà la détection)
if (!vwbActionId) {
setVwbAction(null);
setVwbActionError(null);
return;
}
console.log('🔄 [PropertiesPanel] Chargement action VWB:', vwbActionId);
setIsLoadingVWBAction(true);
setVwbActionError(null);
try {
// Utiliser le hook d'intégration pour charger l'action
const action = await vwbMethods.loadVWBAction(vwbActionId);
setVwbAction(action);
if (action) {
console.log('✅ [PropertiesPanel] Action VWB chargée:', {
actionId: vwbActionId,
actionName: action.name,
parametersCount: Object.keys(action.parameters || {}).length
});
} else {
console.warn('⚠️ [PropertiesPanel] Action VWB non trouvée:', vwbActionId);
}
} catch (error) {
const errorObj = error instanceof Error ? error : new Error(String(error));
console.error('❌ [PropertiesPanel] Erreur chargement action VWB:', errorObj);
setVwbAction(null);
setVwbActionError(errorObj);
} finally {
setIsLoadingVWBAction(false);
}
};
loadVWBAction();
}, [vwbActionId, vwbMethods]);
// Gestionnaire d'événements clavier pour le panneau de propriétés
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'Enter':
// Activer l'élément focalisé
if (event.target instanceof HTMLButtonElement) {
event.preventDefault();
event.target.click();
}
break;
case 'Escape':
// Fermer les dialogues ouverts
event.preventDefault();
setIsVisualSelectorOpen(false);
break;
case 'Tab':
// Navigation entre les champs (comportement par défaut)
break;
}
}, []);
// Synchroniser les paramètres locaux avec l'étape sélectionnée
// IMPORTANT: Copie profonde pour éviter les mutations qui affectent d'autres étapes
React.useEffect(() => {
if (selectedStep) {
// Copie profonde pour isoler les paramètres locaux du workflow
const paramsCopy = JSON.parse(JSON.stringify(selectedStep.data?.parameters || {}));
setLocalParameters(paramsCopy);
} else {
setLocalParameters({});
}
}, [selectedStep]);
// Gestionnaire de changement de paramètre VWB (avec auto-sauvegarde)
const handleVWBParameterChange = useCallback((paramName: string, value: any) => {
// Copie profonde de la valeur si c'est un objet (ex: visual_anchor)
const valueCopy = value && typeof value === 'object'
? JSON.parse(JSON.stringify(value))
: value;
const newParameters = { ...localParameters, [paramName]: valueCopy };
setLocalParameters(newParameters);
if (selectedStep) {
// Déclencher la sauvegarde automatique (fix: était manquant pour les actions VWB)
autoSave.triggerSave(newParameters);
// Envoyer aussi une copie au parent pour éviter les références partagées
onParameterChange(selectedStep.id, paramName, valueCopy);
}
}, [localParameters, selectedStep, onParameterChange, autoSave]);
// Gestionnaire de validation VWB
const handleVWBValidationChange = useCallback((validation: VWBActionValidationResult) => {
// Mettre à jour les erreurs de validation de l'étape
if (selectedStep) {
// Notifier le parent des erreurs de validation
if (validation.errors.length > 0) {
console.error('Erreurs de validation VWB:', validation.errors.map(e => e.message).join(', '));
}
}
}, [selectedStep]);
// Gestionnaire de changement de paramètre avec auto-sauvegarde
const handleParameterChange = useCallback((paramName: string, value: any) => {
// Copie profonde de la valeur si c'est un objet
const valueCopy = value && typeof value === 'object'
? JSON.parse(JSON.stringify(value))
: value;
const newParameters = { ...localParameters, [paramName]: valueCopy };
setLocalParameters(newParameters);
// Déclencher la sauvegarde automatique
autoSave.triggerSave(newParameters);
if (selectedStep) {
onParameterChange(selectedStep.id, paramName, valueCopy);
}
}, [localParameters, selectedStep, onParameterChange, autoSave]);
// Gestionnaire de sélection visuelle
const handleVisualSelection = useCallback(() => {
if (selectedStep) {
setIsVisualSelectorOpen(true);
}
}, [selectedStep]);
// Gestionnaire de confirmation de sélection visuelle
const handleElementSelected = useCallback((selection: VisualSelection) => {
if (selectedStep) {
// Stocker la sélection visuelle dans les paramètres
handleParameterChange('target', selection);
onVisualSelection(selectedStep.id);
}
}, [selectedStep, handleParameterChange, onVisualSelection]);
// Gestionnaire de validation des paramètres
const handleValidationChange = useCallback((errors: ValidationError[]) => {
setValidationErrors(errors);
}, []);
// Déterminer l'état d'affichage et le type de contenu
const getDisplayState = useCallback((): DisplayState => {
// Cas de chargement de résolution
if (isResolving) {
return { type: 'loading', subtype: 'resolving' };
}
// Cas d'erreur de résolution
if (resolutionError) {
return {
type: 'empty',
reason: 'resolution-failed' as const,
error: resolutionError
};
}
// Cas d'action VWB
if (isVWBCatalogAction) {
if (isLoadingVWBAction) {
return { type: 'loading', subtype: 'loading-vwb' };
}
if (vwbActionError) {
return {
type: 'empty',
reason: 'vwb-not-found' as const,
error: vwbActionError
};
}
if (vwbAction) {
return { type: 'vwb-properties' };
}
return {
type: 'empty',
reason: 'vwb-not-found' as const
};
}
// Cas d'étapes standard
if (parameterConfigs.length === 0) {
return {
type: 'empty',
reason: resolutionResult?.isStandardType ? 'no-parameters' as const : 'unknown-type' as const
};
}
return { type: 'standard-parameters' };
}, [
isResolving,
resolutionError,
isVWBCatalogAction,
isLoadingVWBAction,
vwbActionError,
vwbAction,
parameterConfigs.length,
resolutionResult
]);
// Debug logging pour diagnostic
React.useEffect(() => {
if (selectedStep) {
console.log('🔍 [PropertiesPanel] État actuel:', {
stepId: selectedStep.id,
stepType: selectedStep.type,
stepData: selectedStep.data,
isVWBCatalogAction,
vwbActionId,
vwbAction: vwbAction ? vwbAction.name : null,
isLoadingVWBAction,
vwbActionError: vwbActionError?.message,
displayState: getDisplayState(),
resolutionResult: resolutionResult ? {
isVWBAction: resolutionResult.isVWBAction,
isStandardType: resolutionResult.isStandardType,
parameterCount: resolutionResult.parameterConfig?.length
} : null
});
}
}, [selectedStep, isVWBCatalogAction, vwbActionId, vwbAction, isLoadingVWBAction, vwbActionError, resolutionResult, getDisplayState]);
if (!selectedStep) {
return (
<Box
sx={{
width: 320,
height: '100%',
backgroundColor: '#ffffff',
borderLeft: '1px solid #e0e0e0',
p: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
role="complementary"
aria-label="Panneau de propriétés - Aucune étape sélectionnée"
>
<Typography variant="body2" color="text.secondary" textAlign="center">
Sélectionnez une étape pour configurer ses propriétés
</Typography>
</Box>
);
}
const hasErrors = (selectedStep.validationErrors || []).some(error => error.severity === 'error');
return (
<Box
sx={{
width: 320,
height: '100%',
backgroundColor: '#ffffff',
borderLeft: '1px solid #e0e0e0',
display: 'flex',
flexDirection: 'column',
}}
role="complementary"
aria-label={`Propriétés de l'étape ${selectedStep.name}`}
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* En-tête */}
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
<Typography variant="h6" component="h3" gutterBottom>
Propriétés de l'étape
</Typography>
<Typography variant="body2" color="text.secondary">
{selectedStep.name} ({selectedStep.type})
</Typography>
{/* Indicateur de résolution */}
{isResolving && (
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
<CircularProgress size={16} sx={{ mr: 1 }} />
<Typography variant="caption" color="text.secondary">
Résolution des propriétés...
</Typography>
</Box>
)}
{resolutionError && (
<Alert severity="warning" sx={{ mt: 1 }}>
Erreur de résolution des propriétés
</Alert>
)}
</Box>
{/* Alertes d'erreur globales */}
{hasErrors && (
<Box sx={{ p: 2 }}>
<Alert severity="error" icon={<ErrorIcon />}>
Cette étape contient des erreurs qui empêchent l'exécution du workflow.
</Alert>
</Box>
)}
{/* Paramètres - Rendu conditionnel selon l'état */}
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
{(() => {
const displayState = getDisplayState();
switch (displayState.type) {
case 'loading':
if (displayState.subtype === 'resolving') {
return (
<StepResolutionLoading
message="Résolution des propriétés d'étape..."
canCancel={false}
/>
);
} else if (displayState.subtype === 'loading-vwb') {
return (
<VWBActionLoading
message="Chargement de l'action VWB..."
canCancel={true}
onCancel={() => {
setIsLoadingVWBAction(false);
setVwbActionError(new Error('Chargement annulé par l\'utilisateur'));
}}
/>
);
}
break;
case 'empty':
return (
<EmptyStateMessage
stepType={selectedStep.type}
reason={displayState.reason || 'unknown-type'}
error={displayState.error}
onRetry={displayState.reason === 'resolution-failed' ? handleRefreshResolution : undefined}
onRefresh={displayState.reason === 'vwb-not-found' ? handleRefreshResolution : undefined}
/>
);
case 'vwb-properties':
return (
<VWBActionProperties
key={`vwb-props-${selectedStep.id}`}
action={vwbAction!}
parameters={localParameters}
variables={variables as Variable[]}
onParameterChange={handleVWBParameterChange}
onValidationChange={handleVWBValidationChange}
/>
);
case 'standard-parameters':
return (
<StandardParametersEditor
stepType={selectedStep.type}
parameterConfigs={parameterConfigs}
parameters={localParameters}
variables={variables as Variable[]}
onParameterChange={handleParameterChange}
onValidationChange={handleValidationChange}
onVisualSelection={handleVisualSelection}
disabled={autoSave.saveState.isSaving}
showValidationSummary={true}
groupParameters={parameterConfigs.length > 5}
/>
);
default:
return (
<EmptyStateMessage
stepType={selectedStep.type}
reason="unknown-type"
onRetry={handleRefreshResolution}
/>
);
}
})()}
{/* Indicateur de sauvegarde */}
{autoSave.saveState.isSaving && (
<Box sx={{ mt: 2 }}>
<Alert severity="info" sx={{ display: 'flex', alignItems: 'center' }}>
<CircularProgress size={16} sx={{ mr: 1 }} />
<Typography variant="body2">
Sauvegarde en cours...
</Typography>
</Alert>
</Box>
)}
{/* Erreur de sauvegarde */}
{autoSave.saveState.error && (
<Box sx={{ mt: 2 }}>
<Alert
severity="error"
action={
<Button
size="small"
onClick={() => autoSave.resetError()}
>
Ignorer
</Button>
}
>
<Typography variant="body2">
Erreur de sauvegarde: {autoSave.saveState.error.message}
</Typography>
</Alert>
</Box>
)}
</Box>
{/* Panneau de debug en mode développement */}
{process.env.NODE_ENV === 'development' && (
<Box sx={{ borderTop: '1px solid #e0e0e0', p: 1 }}>
<Button
size="small"
startIcon={<BugReportIcon />}
onClick={() => setIsDebugPanelVisible(!isDebugPanelVisible)}
variant="text"
>
Debug Panel
</Button>
</Box>
)}
{/* Composant VisualSelector */}
{selectedStep && (
<VisualSelector
isOpen={isVisualSelectorOpen}
stepId={selectedStep.id}
onClose={() => setIsVisualSelectorOpen(false)}
onElementSelected={handleElementSelected}
/>
)}
{/* Composant DebugPanel (mode développement) */}
{process.env.NODE_ENV === 'development' && (
<DebugPanel
selectedStep={selectedStep}
variables={variables as Variable[]}
isVisible={isDebugPanelVisible}
onToggleVisibility={setIsDebugPanelVisible}
/>
)}
</Box>
);
};
// Mémorisation du composant PropertiesPanel pour éviter les re-rendus inutiles
export default memo(PropertiesPanel, (prevProps, nextProps) => {
return (
prevProps.selectedStep?.id === nextProps.selectedStep?.id &&
JSON.stringify(prevProps.selectedStep?.data) === JSON.stringify(nextProps.selectedStep?.data) &&
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables) &&
prevProps.onParameterChange === nextProps.onParameterChange &&
prevProps.onVisualSelection === nextProps.onVisualSelection
);
});

View File

@@ -0,0 +1,26 @@
/* Styles pour le Composant de Capture d'Écran Réelle */
/* Auteur : Dom, Alice, Kiro - 8 janvier 2026 */
.real-screen-capture {
padding: 24px;
}
.real-screen-capture__screenshot {
max-width: 100%;
height: auto;
cursor: crosshair;
border: 1px solid #ddd;
border-radius: 8px;
}
.real-screen-capture__element-overlay {
position: absolute;
border: 2px solid #ff4444;
background-color: rgba(255, 68, 68, 0.1);
cursor: pointer;
transition: background-color 0.2s ease;
}
.real-screen-capture__element-overlay:hover {
background-color: rgba(255, 68, 68, 0.2);
}

View File

@@ -0,0 +1,424 @@
/**
* Composant RealScreenCapture - Capture d'écran en temps réel pour sélection visuelle
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant fournit une interface de capture d'écran en temps réel pour permettre
* aux utilisateurs de sélectionner visuellement des éléments sur l'écran.
*/
import React, { useState, useCallback, useEffect, memo } from 'react';
import {
Box,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Typography,
Alert,
CircularProgress,
IconButton,
Tooltip,
} from '@mui/material';
import {
CameraAlt as CameraIcon,
Close as CloseIcon,
Refresh as RefreshIcon,
Fullscreen as FullscreenIcon,
CropFree as SelectIcon,
} from '@mui/icons-material';
// Import des services
import { realScreenCaptureService } from '../../services/realScreenCaptureService';
// Import des types
import { VisualSelection } from '../../types';
/**
* Props du composant RealScreenCapture
*/
export interface RealScreenCaptureProps {
isOpen: boolean;
onClose: () => void;
onElementSelected: (selection: VisualSelection) => void;
stepId?: string;
className?: string;
}
/**
* État de capture
*/
interface CaptureState {
isCapturing: boolean;
isSelecting: boolean;
screenshot: string | null;
error: Error | null;
selectedElement: VisualSelection | null;
}
/**
* Composant RealScreenCapture
*/
const RealScreenCapture: React.FC<RealScreenCaptureProps> = ({
isOpen,
onClose,
onElementSelected,
stepId,
className
}) => {
// État de capture
const [captureState, setCaptureState] = useState<CaptureState>({
isCapturing: false,
isSelecting: false,
screenshot: null,
error: null,
selectedElement: null
});
/**
* Démarre la capture d'écran
*/
const startCapture = useCallback(async () => {
setCaptureState(prev => ({
...prev,
isCapturing: true,
error: null,
screenshot: null
}));
try {
console.log('📸 [RealScreenCapture] Début de capture d\'écran');
// Utiliser le service de capture d'écran réelle
const response = await realScreenCaptureService.captureWithElements(0, false);
if (response?.success && response.screenshot) {
// Le backend peut retourner soit une data URI complète, soit juste le base64
const screenshotData = response.screenshot.startsWith('data:')
? response.screenshot
: `data:image/png;base64,${response.screenshot}`;
setCaptureState(prev => ({
...prev,
isCapturing: false,
screenshot: screenshotData,
isSelecting: true
}));
console.log('✅ [RealScreenCapture] Capture d\'écran réussie');
} else {
throw new Error(response?.error || 'Échec de la capture d\'écran');
}
} catch (error) {
const errorObj = error instanceof Error ? error : new Error(String(error));
console.error('❌ [RealScreenCapture] Erreur de capture:', errorObj);
setCaptureState(prev => ({
...prev,
isCapturing: false,
error: errorObj
}));
}
}, []);
/**
* Gère la sélection d'un élément sur la capture
*/
const handleElementSelection = useCallback((event: React.MouseEvent<HTMLImageElement>) => {
if (!captureState.isSelecting || !captureState.screenshot) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Calculer les coordonnées relatives
const relativeX = x / rect.width;
const relativeY = y / rect.height;
const selection: VisualSelection = {
id: `selection_${Date.now()}`,
screenshot: captureState.screenshot,
boundingBox: {
x: Math.round(relativeX * 100) / 100,
y: Math.round(relativeY * 100) / 100,
width: 0.01, // Point de clic, taille minimale
height: 0.01
},
description: `Point de clic (${relativeX.toFixed(3)}, ${relativeY.toFixed(3)})`,
metadata: {
capture_method: 'real_screen_capture',
capture_timestamp: new Date().toISOString(),
screen_resolution: {
width: rect.width,
height: rect.height
}
}
};
setCaptureState(prev => ({
...prev,
selectedElement: selection,
isSelecting: false
}));
console.log('🎯 [RealScreenCapture] Élément sélectionné:', selection);
}, [captureState.isSelecting, captureState.screenshot, stepId]);
/**
* Confirme la sélection
*/
const confirmSelection = useCallback(() => {
if (captureState.selectedElement) {
onElementSelected(captureState.selectedElement);
handleClose();
}
}, [captureState.selectedElement, onElementSelected]);
/**
* Ferme le dialogue et remet à zéro l'état
*/
const handleClose = useCallback(() => {
setCaptureState({
isCapturing: false,
isSelecting: false,
screenshot: null,
error: null,
selectedElement: null
});
onClose();
}, [onClose]);
/**
* Recommence la capture
*/
const restartCapture = useCallback(() => {
setCaptureState(prev => ({
...prev,
screenshot: null,
selectedElement: null,
error: null,
isSelecting: false
}));
startCapture();
}, [startCapture]);
/**
* Effet pour démarrer automatiquement la capture à l'ouverture
*/
useEffect(() => {
if (isOpen && !captureState.screenshot && !captureState.isCapturing) {
startCapture();
}
}, [isOpen, captureState.screenshot, captureState.isCapturing, startCapture]);
/**
* Rendu de l'état de capture
*/
const renderCaptureState = () => {
if (captureState.isCapturing) {
return (
<Box sx={{ textAlign: 'center', py: 4 }}>
<CircularProgress size={48} sx={{ mb: 2 }} />
<Typography variant="h6" gutterBottom>
Capture d'écran en cours...
</Typography>
<Typography variant="body2" color="text.secondary">
Veuillez patienter pendant la capture de votre écran
</Typography>
</Box>
);
}
if (captureState.error) {
return (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Alert severity="error" sx={{ mb: 2 }}>
<Typography variant="body2">
Erreur de capture: {captureState.error.message}
</Typography>
</Alert>
<Button
variant="contained"
startIcon={<RefreshIcon />}
onClick={startCapture}
>
Réessayer
</Button>
</Box>
);
}
if (!captureState.screenshot) {
return (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Button
variant="contained"
size="large"
startIcon={<CameraIcon />}
onClick={startCapture}
>
Capturer l'écran
</Button>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Cliquez pour prendre une capture d'écran et sélectionner un élément
</Typography>
</Box>
);
}
return null;
};
/**
* Rendu de l'interface de sélection
*/
const renderSelectionInterface = () => {
if (!captureState.screenshot) {
return null;
}
return (
<Box sx={{ position: 'relative' }}>
{/* Instructions */}
<Alert
severity={captureState.isSelecting ? "info" : "success"}
sx={{ mb: 2 }}
>
<Typography variant="body2">
{captureState.isSelecting
? "Cliquez sur l'élément que vous souhaitez sélectionner"
: "Élément sélectionné ! Confirmez votre choix ou recommencez."
}
</Typography>
</Alert>
{/* Image de capture avec sélection */}
<Box
sx={{
position: 'relative',
border: '2px solid',
borderColor: captureState.isSelecting ? 'primary.main' : 'success.main',
borderRadius: 1,
overflow: 'hidden',
cursor: captureState.isSelecting ? 'crosshair' : 'default'
}}
>
<img
src={captureState.screenshot}
alt="Capture d'écran pour sélection"
style={{
width: '100%',
height: 'auto',
maxHeight: '60vh',
objectFit: 'contain'
}}
onClick={handleElementSelection}
/>
{/* Indicateur de sélection */}
{captureState.selectedElement && (
<Box
sx={{
position: 'absolute',
left: `${captureState.selectedElement.boundingBox.x * 100}%`,
top: `${captureState.selectedElement.boundingBox.y * 100}%`,
width: 20,
height: 20,
borderRadius: '50%',
backgroundColor: 'success.main',
border: '3px solid white',
transform: 'translate(-50%, -50%)',
boxShadow: 2,
zIndex: 1
}}
/>
)}
</Box>
{/* Informations de sélection */}
{captureState.selectedElement && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Coordonnées sélectionnées:
</Typography>
<Typography variant="body2" color="text.secondary">
X: {(captureState.selectedElement.boundingBox.x * 100).toFixed(1)}%,
Y: {(captureState.selectedElement.boundingBox.y * 100).toFixed(1)}%
</Typography>
</Box>
)}
</Box>
);
};
return (
<Dialog
open={isOpen}
onClose={handleClose}
maxWidth="lg"
fullWidth
className={className}
PaperProps={{
sx: { minHeight: '60vh' }
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<SelectIcon sx={{ mr: 1 }} />
<Typography variant="h6">
Sélection visuelle d'élément
</Typography>
</Box>
<IconButton onClick={handleClose} size="small">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
{renderCaptureState()}
{renderSelectionInterface()}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleClose} color="inherit">
Annuler
</Button>
{captureState.screenshot && (
<Tooltip title="Prendre une nouvelle capture">
<Button
startIcon={<RefreshIcon />}
onClick={restartCapture}
color="primary"
>
Nouvelle capture
</Button>
</Tooltip>
)}
{captureState.selectedElement && (
<Button
variant="contained"
onClick={confirmSelection}
startIcon={<SelectIcon />}
>
Confirmer la sélection
</Button>
)}
</DialogActions>
</Dialog>
);
};
/**
* Export du composant mémorisé
*/
export default memo(RealScreenCapture, (prevProps, nextProps) => {
return (
prevProps.isOpen === nextProps.isOpen &&
prevProps.stepId === nextProps.stepId
);
});

View File

@@ -0,0 +1,224 @@
/**
* Composant de Test - Chargement du Catalogue VWB
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Composant de test pour diagnostiquer le chargement du catalogue d'actions VisionOnly
*/
import React, { useEffect, useState } from 'react';
import { Box, Typography, Alert, CircularProgress, List, ListItem, ListItemText, Chip } from '@mui/material';
import { catalogService } from '../services/catalogService';
import { useCatalogActions } from '../hooks/useCatalogActions';
import { VWBCatalogAction } from '../types/catalog';
const TestCatalogLoader: React.FC = () => {
const [directServiceTest, setDirectServiceTest] = useState<{
loading: boolean;
actions: VWBCatalogAction[];
error: string | null;
}>({
loading: true,
actions: [],
error: null,
});
// Test direct du service
useEffect(() => {
const testDirectService = async () => {
try {
console.log('🧪 Test direct du catalogService...');
const result = await catalogService.getActions();
console.log('✅ Service direct réussi:', result);
setDirectServiceTest({
loading: false,
actions: result.actions as VWBCatalogAction[],
error: null,
});
} catch (error) {
console.error('❌ Service direct échoué:', error);
setDirectServiceTest({
loading: false,
actions: [],
error: error instanceof Error ? error.message : 'Erreur inconnue',
});
}
};
testDirectService();
}, []);
// Test du hook
const {
state: hookState,
filteredActions: hookActions,
stats: hookStats,
actions: hookMethods,
} = useCatalogActions({
autoLoad: true,
});
return (
<Box sx={{ p: 3, maxWidth: 800 }}>
<Typography variant="h4" gutterBottom>
🧪 Test de Chargement du Catalogue VWB
</Typography>
{/* Test du service direct */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
1. Test Direct du Service catalogService
</Typography>
{directServiceTest.loading ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<CircularProgress size={20} />
<Typography>Chargement...</Typography>
</Box>
) : directServiceTest.error ? (
<Alert severity="error">
Erreur service direct: {directServiceTest.error}
</Alert>
) : (
<Alert severity="success">
Service direct réussi: {directServiceTest.actions.length} actions chargées
</Alert>
)}
{directServiceTest.actions.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Actions chargées par le service direct:
</Typography>
<List dense>
{directServiceTest.actions.slice(0, 3).map((action) => (
<ListItem key={action.id}>
<ListItemText
primary={`${action.icon} ${action.name}`}
secondary={`${action.category} - ${action.description.substring(0, 60)}...`}
/>
</ListItem>
))}
</List>
</Box>
)}
</Box>
{/* Test du hook */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
2. Test du Hook useCatalogActions
</Typography>
{hookState.isLoading ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<CircularProgress size={20} />
<Typography>Hook en cours de chargement...</Typography>
</Box>
) : hookState.error ? (
<Alert severity="error">
Erreur hook: {hookState.error}
</Alert>
) : (
<Alert severity="success">
Hook réussi: {hookState.actions.length} actions, {hookState.categories.length} catégories
</Alert>
)}
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label={`Actions: ${hookState.actions.length}`} color="primary" />
<Chip label={`Catégories: ${hookState.categories.length}`} color="secondary" />
<Chip label={`En ligne: ${hookState.isOnline ? 'Oui' : 'Non'}`} color={hookState.isOnline ? 'success' : 'error'} />
<Chip label={`Dernière MAJ: ${hookState.lastUpdate?.toLocaleTimeString() || 'Jamais'}`} />
</Box>
{hookActions.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Actions filtrées par le hook:
</Typography>
<List dense>
{hookActions.slice(0, 3).map((action) => (
<ListItem key={action.id}>
<ListItemText
primary={`${action.icon} ${action.name}`}
secondary={`${action.category} - ${action.description.substring(0, 60)}...`}
/>
</ListItem>
))}
</List>
</Box>
)}
</Box>
{/* Statistiques */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
3. Statistiques du Hook
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label={`Total: ${hookStats.totalActions}`} />
<Chip label={`Complexité: ${hookStats.averageComplexity}`} />
<Chip label={`Statut: ${hookStats.onlineStatus ? 'En ligne' : 'Hors ligne'}`} />
</Box>
{Object.keys(hookStats.actionsByCategory).length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Actions par catégorie:
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{Object.entries(hookStats.actionsByCategory).map(([category, count]) => (
<Chip key={category} label={`${category}: ${count}`} variant="outlined" />
))}
</Box>
</Box>
)}
</Box>
{/* Actions du hook */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
4. Actions Disponibles du Hook
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<button onClick={hookMethods.reload}>🔄 Recharger</button>
<button onClick={hookMethods.clearCache}>🗑 Vider Cache</button>
<button onClick={hookMethods.checkHealth}> Vérifier Santé</button>
</Box>
</Box>
{/* Diagnostic */}
<Box>
<Typography variant="h6" gutterBottom>
5. Diagnostic
</Typography>
{directServiceTest.actions.length > 0 && hookState.actions.length > 0 ? (
<Alert severity="success">
Tout fonctionne correctement ! Le service et le hook chargent les actions.
Le problème peut être dans l'affichage de la Palette.
</Alert>
) : directServiceTest.actions.length > 0 && hookState.actions.length === 0 ? (
<Alert severity="warning">
Le service fonctionne mais le hook ne charge pas les actions.
Problème dans le hook useCatalogActions.
</Alert>
) : directServiceTest.actions.length === 0 ? (
<Alert severity="error">
Le service ne charge pas les actions.
Problème de communication avec le backend.
</Alert>
) : (
<Alert severity="info">
🔄 Tests en cours...
</Alert>
)}
</Box>
</Box>
);
};
export default TestCatalogLoader;

View File

@@ -0,0 +1,428 @@
/**
* Composant de Test Properties Panel - Validation de l'affichage des propriétés VWB
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Ce composant teste l'affichage des propriétés d'étapes VWB pour identifier
* et corriger les problèmes d'intégration.
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
Card,
CardContent,
Alert,
Divider,
Chip,
List,
ListItem,
ListItemText,
ListItemIcon,
} from '@mui/material';
import {
PlayArrow as PlayIcon,
CheckCircle as CheckIcon,
Error as ErrorIcon,
Info as InfoIcon,
} from '@mui/icons-material';
// Import des composants à tester
import PropertiesPanel from './PropertiesPanel';
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../hooks/useVWBStepIntegration';
import { catalogService } from '../services/catalogService';
// Import des types
import { Step, StepExecutionState, Variable, VariableType } from '../types';
import { VWBCatalogAction } from '../types/catalog';
interface TestResult {
test: string;
success: boolean;
message: string;
details?: any;
}
/**
* Composant de test pour les propriétés VWB
*/
const TestPropertiesPanel: React.FC = () => {
const [testResults, setTestResults] = useState<TestResult[]>([]);
const [isRunning, setIsRunning] = useState(false);
const [testStep, setTestStep] = useState<Step | null>(null);
const [catalogActions, setCatalogActions] = useState<VWBCatalogAction[]>([]);
const [selectedAction, setSelectedAction] = useState<string>('click_anchor');
// Hooks VWB
const { methods: vwbMethods } = useVWBStepIntegration();
const isVWBStep = useIsVWBStep(testStep);
const vwbActionId = useVWBActionId(testStep);
// Variables de test
const testVariables: Variable[] = [
{
id: 'var1',
name: 'username',
value: 'test@example.com',
type: 'text' as VariableType,
description: 'Nom d\'utilisateur de test',
},
{
id: 'var2',
name: 'password',
value: '********',
type: 'text' as VariableType,
description: 'Mot de passe de test',
},
];
// Charger les actions du catalogue au démarrage
useEffect(() => {
const loadCatalogActions = async () => {
try {
const { actions } = await catalogService.getActions();
setCatalogActions(actions);
console.log('Actions du catalogue chargées:', actions.length);
} catch (error) {
console.error('Erreur chargement catalogue:', error);
}
};
loadCatalogActions();
}, []);
/**
* Créer une étape VWB de test
*/
const createTestVWBStep = async (actionId: string): Promise<Step | null> => {
try {
// Utiliser le hook d'intégration pour créer l'étape
const step = await vwbMethods.createVWBStep(actionId, { x: 100, y: 100 });
if (step) {
console.log('Étape VWB créée:', step);
return step;
} else {
throw new Error('Impossible de créer l\'étape VWB');
}
} catch (error) {
console.error('Erreur création étape VWB:', error);
return null;
}
};
/**
* Exécuter tous les tests
*/
const runAllTests = async () => {
setIsRunning(true);
setTestResults([]);
const results: TestResult[] = [];
try {
// Test 1: Chargement du catalogue
results.push({
test: 'Chargement du catalogue',
success: catalogActions.length > 0,
message: catalogActions.length > 0
? `${catalogActions.length} actions chargées`
: 'Aucune action chargée',
details: { actionCount: catalogActions.length }
});
// Test 2: Création d'étape VWB
const testStep = await createTestVWBStep(selectedAction);
const stepCreated = testStep !== null;
results.push({
test: 'Création d\'étape VWB',
success: stepCreated,
message: stepCreated
? `Étape ${selectedAction} créée avec succès`
: `Échec création étape ${selectedAction}`,
details: testStep
});
if (stepCreated && testStep) {
setTestStep(testStep);
// Test 3: Détection VWB
const isDetectedAsVWB = testStep.data.isVWBCatalogAction === true;
results.push({
test: 'Détection étape VWB',
success: isDetectedAsVWB,
message: isDetectedAsVWB
? 'Étape correctement détectée comme VWB'
: 'Étape non détectée comme VWB',
details: {
isVWBCatalogAction: testStep.data.isVWBCatalogAction,
vwbActionId: testStep.data.vwbActionId
}
});
// Test 4: Hook de détection
const hookDetection = useIsVWBStep(testStep);
results.push({
test: 'Hook de détection VWB',
success: hookDetection,
message: hookDetection
? 'Hook useIsVWBStep fonctionne'
: 'Hook useIsVWBStep défaillant',
details: { hookResult: hookDetection }
});
// Test 5: Récupération ID action
const actionIdFromHook = useVWBActionId(testStep);
results.push({
test: 'Récupération ID action VWB',
success: actionIdFromHook === selectedAction,
message: actionIdFromHook === selectedAction
? `ID action correct: ${actionIdFromHook}`
: `ID action incorrect: ${actionIdFromHook} (attendu: ${selectedAction})`,
details: {
expected: selectedAction,
actual: actionIdFromHook
}
});
// Test 6: Chargement détails action
try {
const actionDetails = await catalogService.getActionDetails(selectedAction);
const actionLoaded = actionDetails !== null;
results.push({
test: 'Chargement détails action',
success: actionLoaded,
message: actionLoaded
? `Détails action ${selectedAction} chargés`
: `Échec chargement détails ${selectedAction}`,
details: actionDetails
});
} catch (error) {
results.push({
test: 'Chargement détails action',
success: false,
message: `Erreur: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
details: { error }
});
}
// Test 7: Validation étape
try {
const isValid = await vwbMethods.validateVWBStep(testStep);
results.push({
test: 'Validation étape VWB',
success: true, // Le test réussit si la validation s'exécute
message: isValid
? 'Étape valide'
: 'Étape invalide (normal pour étape vide)',
details: { isValid }
});
} catch (error) {
results.push({
test: 'Validation étape VWB',
success: false,
message: `Erreur validation: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
details: { error }
});
}
}
} catch (error) {
results.push({
test: 'Erreur générale',
success: false,
message: `Erreur: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
details: { error }
});
}
setTestResults(results);
setIsRunning(false);
};
/**
* Gestionnaire de changement de paramètre (pour le test)
*/
const handleParameterChange = (stepId: string, paramName: string, value: any) => {
console.log('Changement paramètre:', { stepId, paramName, value });
if (testStep && testStep.id === stepId) {
const updatedStep = {
...testStep,
data: {
...testStep.data,
parameters: {
...testStep.data.parameters,
[paramName]: value
}
}
};
setTestStep(updatedStep);
}
};
/**
* Gestionnaire de sélection visuelle (pour le test)
*/
const handleVisualSelection = (stepId: string) => {
console.log('Sélection visuelle pour étape:', stepId);
};
// Calculer le statut global des tests
const allTestsPassed = testResults.length > 0 && testResults.every(r => r.success);
const hasFailures = testResults.some(r => !r.success);
return (
<Box sx={{ p: 3, maxWidth: 1200, margin: '0 auto' }}>
<Typography variant="h4" gutterBottom>
Test des Propriétés d'Étapes VWB
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Ce composant teste l'affichage des propriétés d'étapes VWB pour identifier
et corriger les problèmes d'intégration.
</Typography>
{/* Contrôles de test */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Configuration du Test
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 2 }}>
<Typography variant="body2">Action à tester:</Typography>
{catalogActions.map(action => (
<Chip
key={action.id}
label={action.name}
variant={selectedAction === action.id ? 'filled' : 'outlined'}
onClick={() => setSelectedAction(action.id)}
size="small"
/>
))}
</Box>
<Button
variant="contained"
startIcon={<PlayIcon />}
onClick={runAllTests}
disabled={isRunning || catalogActions.length === 0}
size="large"
>
{isRunning ? 'Tests en cours...' : 'Exécuter les Tests'}
</Button>
</CardContent>
</Card>
{/* Résultats des tests */}
{testResults.length > 0 && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Résultats des Tests
</Typography>
{/* Statut global */}
<Alert
severity={allTestsPassed ? 'success' : hasFailures ? 'error' : 'info'}
sx={{ mb: 2 }}
>
{allTestsPassed
? '🎉 Tous les tests sont réussis ! Les propriétés VWB devraient s\'afficher correctement.'
: hasFailures
? '❌ Certains tests ont échoué. Les propriétés VWB pourraient ne pas s\'afficher.'
: ' Tests en cours d\'exécution...'}
</Alert>
{/* Liste des résultats */}
<List>
{testResults.map((result, index) => (
<ListItem key={index}>
<ListItemIcon>
{result.success ? (
<CheckIcon color="success" />
) : (
<ErrorIcon color="error" />
)}
</ListItemIcon>
<ListItemText
primary={result.test}
secondary={result.message}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
)}
{/* Aperçu du Properties Panel */}
{testStep && (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Aperçu du Properties Panel
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Voici comment le Properties Panel devrait afficher les propriétés de l'étape VWB :
</Typography>
{/* Informations sur l'étape */}
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Informations sur l'étape :
</Typography>
<Chip label={`Type: ${testStep.type}`} size="small" sx={{ mr: 1 }} />
<Chip label={`VWB: ${isVWBStep ? 'Oui' : 'Non'}`} size="small" sx={{ mr: 1 }} />
<Chip label={`Action ID: ${vwbActionId || 'N/A'}`} size="small" />
</Box>
<Divider sx={{ my: 2 }} />
{/* Properties Panel en action */}
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1 }}>
<PropertiesPanel
selectedStep={testStep}
variables={testVariables}
onParameterChange={handleParameterChange}
onVisualSelection={handleVisualSelection}
/>
</Box>
</CardContent>
</Card>
)}
{/* Instructions */}
<Card sx={{ mt: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Instructions
</Typography>
<Typography variant="body2" paragraph>
1. <strong>Exécuter les tests</strong> : Cliquez sur "Exécuter les Tests" pour vérifier l'intégration VWB
</Typography>
<Typography variant="body2" paragraph>
2. <strong>Vérifier les résultats</strong> : Tous les tests doivent être verts pour un fonctionnement correct
</Typography>
<Typography variant="body2" paragraph>
3. <strong>Tester l'interface</strong> : L'aperçu du Properties Panel montre comment les propriétés s'affichent
</Typography>
<Typography variant="body2" paragraph>
4. <strong>Déboguer si nécessaire</strong> : Si des tests échouent, vérifiez les détails dans la console
</Typography>
</CardContent>
</Card>
</Box>
);
};
export default TestPropertiesPanel;

View File

@@ -0,0 +1,220 @@
/**
* Composant Test Intégration VWB - Validation de l'affichage des propriétés
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
Card,
CardContent,
Alert,
List,
ListItem,
ListItemText,
ListItemIcon,
Divider,
} from '@mui/material';
import {
CheckCircle as CheckIcon,
Error as ErrorIcon,
PlayArrow as PlayIcon,
} from '@mui/icons-material';
// Import des composants à tester
import PropertiesPanel from './PropertiesPanel';
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../hooks/useVWBStepIntegration';
import { catalogService } from '../services/catalogService';
// Import des types
import { Step, StepExecutionState, Variable, VariableType } from '../types';
const VWBIntegrationTest: React.FC = () => {
const [testStep, setTestStep] = useState<Step | null>(null);
const [testResults, setTestResults] = useState<Array<{test: string, success: boolean, message: string}>>([]);
const [isRunning, setIsRunning] = useState(false);
// Hooks VWB
const { methods: vwbMethods } = useVWBStepIntegration();
const isVWBStep = useIsVWBStep(testStep);
const vwbActionId = useVWBActionId(testStep);
// Variables de test
const testVariables: Variable[] = [
{
id: 'var1',
name: 'test_var',
value: 'test_value',
type: 'text' as VariableType,
description: 'Variable de test',
},
];
const runIntegrationTest = async () => {
setIsRunning(true);
setTestResults([]);
const results: Array<{test: string, success: boolean, message: string}> = [];
try {
// Test 1: Chargement du catalogue
const { actions } = await catalogService.getActions();
results.push({
test: 'Chargement catalogue',
success: actions.length > 0,
message: `${actions.length} actions chargées`
});
// Test 2: Création d'étape VWB
if (actions.length > 0) {
const firstAction = actions[0];
const step = await vwbMethods.createVWBStep(firstAction.id, { x: 100, y: 100 });
if (step) {
setTestStep(step);
results.push({
test: 'Création étape VWB',
success: true,
message: `Étape ${firstAction.id} créée`
});
// Test 3: Détection VWB
const isDetected = step.data.isVWBCatalogAction === true;
results.push({
test: 'Détection VWB',
success: isDetected,
message: isDetected ? 'Étape détectée comme VWB' : 'Étape non détectée'
});
// Test 4: Hook de détection
const hookResult = useIsVWBStep(step);
results.push({
test: 'Hook détection',
success: hookResult,
message: hookResult ? 'Hook fonctionne' : 'Hook défaillant'
});
} else {
results.push({
test: 'Création étape VWB',
success: false,
message: 'Échec création étape'
});
}
}
} catch (error) {
results.push({
test: 'Erreur générale',
success: false,
message: error instanceof Error ? error.message : 'Erreur inconnue'
});
}
setTestResults(results);
setIsRunning(false);
};
const handleParameterChange = (stepId: string, paramName: string, value: any) => {
console.log('Changement paramètre:', { stepId, paramName, value });
};
const handleVisualSelection = (stepId: string) => {
console.log('Sélection visuelle:', stepId);
};
const allTestsPassed = testResults.length > 0 && testResults.every(r => r.success);
return (
<Box sx={{ p: 3, maxWidth: 1200, margin: '0 auto' }}>
<Typography variant="h4" gutterBottom>
Test d'Intégration VWB
</Typography>
<Card sx={{ mb: 3 }}>
<CardContent>
<Button
variant="contained"
startIcon={<PlayIcon />}
onClick={runIntegrationTest}
disabled={isRunning}
size="large"
>
{isRunning ? 'Tests en cours...' : 'Exécuter les Tests'}
</Button>
</CardContent>
</Card>
{testResults.length > 0 && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Résultats des Tests
</Typography>
<Alert severity={allTestsPassed ? 'success' : 'error'} sx={{ mb: 2 }}>
{allTestsPassed
? 'Tous les tests réussis ! L\'intégration VWB fonctionne.'
: 'Certains tests ont échoué. Vérifiez l\'intégration.'}
</Alert>
<List>
{testResults.map((result, index) => (
<ListItem key={index}>
<ListItemIcon>
{result.success ? (
<CheckIcon color="success" />
) : (
<ErrorIcon color="error" />
)}
</ListItemIcon>
<ListItemText
primary={result.test}
secondary={result.message}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
)}
{testStep && (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Properties Panel Test
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Étape VWB: {testStep.type} (ID: {testStep.id})
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Détection VWB: {isVWBStep ? 'Oui' : 'Non'}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Action ID: {vwbActionId || 'N/A'}
</Typography>
<Divider sx={{ my: 2 }} />
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1 }}>
<PropertiesPanel
selectedStep={testStep}
variables={testVariables}
onParameterChange={handleParameterChange}
onVisualSelection={handleVisualSelection}
/>
</Box>
</CardContent>
</Card>
)}
</Box>
);
};
export default VWBIntegrationTest;

View File

@@ -0,0 +1,522 @@
/**
* Composant Validateur - Validation et feedback visuel pour les workflows
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant fournit la validation en temps réel des workflows avec
* indicateurs visuels d'erreur, détection de cycles et prévention d'exécution.
*/
import React, { useMemo, useCallback } from 'react';
import {
Box,
Alert,
AlertTitle,
List,
ListItem,
ListItemIcon,
ListItemText,
Chip,
Typography,
Collapse,
IconButton,
} from '@mui/material';
import {
Error as ErrorIcon,
Warning as WarningIcon,
Info as InfoIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
Block as BlockIcon,
Link as LinkIcon,
Loop as LoopIcon,
} from '@mui/icons-material';
// Import des types partagés
import {
Workflow,
Step,
WorkflowConnection,
ValidationError,
Variable,
} from '../../types';
interface ValidatorProps {
workflow: Workflow;
variables: Variable[];
onStepHighlight?: (stepId: string, highlight: boolean) => void;
}
interface ValidationResult {
isValid: boolean;
errors: ValidationIssue[];
warnings: ValidationIssue[];
canExecute: boolean;
}
interface ValidationIssue {
id: string;
type: 'error' | 'warning' | 'info';
category: 'missing_parameter' | 'disconnected_step' | 'cycle_detected' | 'invalid_reference' | 'execution_blocked';
stepId?: string;
message: string;
description?: string;
severity: 'critical' | 'high' | 'medium' | 'low';
}
// Fonctions utilitaires (déclarées avant leur utilisation)
// Obtenir les paramètres requis pour un type d'étape
const getRequiredParameters = (stepType: string): string[] => {
const parameterMap: Record<string, string[]> = {
click: ['target'],
type: ['target', 'text'],
wait: ['duration'],
condition: ['condition'],
extract: ['target', 'attribute'],
navigate: ['url'],
scroll: ['direction'],
screenshot: [],
};
return parameterMap[stepType] || [];
};
// Extraire les références de variables d'un texte
const extractVariableReferences = (text: string): string[] => {
const pattern = /\$\{([^}]+)\}/g;
const matches: string[] = [];
let match;
while ((match = pattern.exec(text)) !== null) {
matches.push(match[1]);
}
return matches;
};
// Validation des paramètres d'une étape
const validateStepParameters = (step: Step, variables: Variable[]): ValidationIssue[] => {
const issues: ValidationIssue[] = [];
// Récupérer les paramètres requis selon le type d'étape
const requiredParams = getRequiredParameters(step.type);
requiredParams.forEach(paramName => {
const paramValue = step.data.parameters?.[paramName];
if (paramValue === undefined || paramValue === null || paramValue === '') {
issues.push({
id: `missing_param_${step.id}_${paramName}`,
type: 'error',
category: 'missing_parameter',
stepId: step.id,
message: `Paramètre "${paramName}" manquant`,
description: `L'étape "${step.name}" nécessite le paramètre "${paramName}"`,
severity: 'high',
});
}
});
return issues;
};
// Trouver les étapes déconnectées
const findDisconnectedSteps = (steps: Step[], connections: WorkflowConnection[]): string[] => {
if (steps.length <= 1) return [];
const connectedSteps = new Set<string>();
// Ajouter toutes les étapes connectées
connections.forEach(conn => {
connectedSteps.add(conn.source);
connectedSteps.add(conn.target);
});
// Si aucune connexion, toutes les étapes sauf la première sont déconnectées
if (connections.length === 0 && steps.length > 1) {
return steps.slice(1).map(step => step.id);
}
// Trouver les étapes non connectées
return steps
.filter(step => !connectedSteps.has(step.id))
.map(step => step.id);
};
// Détecter les cycles dans le workflow
const detectCycles = (steps: Step[], connections: WorkflowConnection[]): string[][] => {
const cycles: string[][] = [];
const visited = new Set<string>();
const recursionStack = new Set<string>();
// Construire le graphe d'adjacence
const graph: Record<string, string[]> = {};
steps.forEach(step => {
graph[step.id] = [];
});
connections.forEach(conn => {
if (graph[conn.source]) {
graph[conn.source].push(conn.target);
}
});
// DFS pour détecter les cycles
const dfs = (nodeId: string, path: string[]): void => {
if (recursionStack.has(nodeId)) {
// Cycle détecté
const cycleStart = path.indexOf(nodeId);
if (cycleStart >= 0) {
cycles.push([...path.slice(cycleStart), nodeId]);
}
return;
}
if (visited.has(nodeId)) return;
visited.add(nodeId);
recursionStack.add(nodeId);
path.push(nodeId);
const neighbors = graph[nodeId] || [];
neighbors.forEach(neighbor => {
dfs(neighbor, [...path]);
});
recursionStack.delete(nodeId);
};
// Lancer DFS depuis chaque nœud non visité
steps.forEach(step => {
if (!visited.has(step.id)) {
dfs(step.id, []);
}
});
return cycles;
};
// Valider les références de variables
const validateVariableReferences = (step: Step, variables: Variable[]): ValidationIssue[] => {
const issues: ValidationIssue[] = [];
const variableNames = new Set(variables.map(v => v.name));
// Extraire les références de variables des paramètres
const parameters = step.data.parameters || {};
Object.entries(parameters).forEach(([paramName, paramValue]) => {
if (typeof paramValue === 'string') {
const variableRefs = extractVariableReferences(paramValue);
variableRefs.forEach(varName => {
if (!variableNames.has(varName)) {
issues.push({
id: `invalid_ref_${step.id}_${paramName}_${varName}`,
type: 'error',
category: 'invalid_reference',
stepId: step.id,
message: `Variable "${varName}" non définie`,
description: `La variable "${varName}" utilisée dans "${paramName}" n'existe pas`,
severity: 'high',
});
}
});
}
});
return issues;
};
/**
* Composant Validateur
*/
const Validator: React.FC<ValidatorProps> = ({
workflow,
variables,
onStepHighlight,
}) => {
const [expandedSections, setExpandedSections] = React.useState<Set<string>>(new Set(['errors']));
// Gestionnaire d'événements clavier pour le validateur
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'Enter':
case ' ':
// Activer l'élément focalisé
if (event.target instanceof HTMLElement && event.target.getAttribute('role') === 'button') {
event.preventDefault();
event.target.click();
}
break;
case 'ArrowUp':
case 'ArrowDown':
// Navigation dans la liste des erreurs
event.preventDefault();
break;
case 'Escape':
// Fermer toutes les sections étendues
event.preventDefault();
setExpandedSections(new Set());
break;
}
}, []);
// Validation complète du workflow
const validationResult = useMemo((): ValidationResult => {
const errors: ValidationIssue[] = [];
const warnings: ValidationIssue[] = [];
// 1. Validation des paramètres manquants
workflow.steps.forEach(step => {
const stepErrors = validateStepParameters(step, variables);
errors.push(...stepErrors);
});
// 2. Détection des étapes déconnectées
const disconnectedSteps = findDisconnectedSteps(workflow.steps, workflow.connections);
disconnectedSteps.forEach(stepId => {
warnings.push({
id: `disconnected_${stepId}`,
type: 'warning',
category: 'disconnected_step',
stepId,
message: 'Étape déconnectée',
description: 'Cette étape n\'est pas connectée au flux principal du workflow',
severity: 'medium',
});
});
// 3. Détection de cycles
const cycles = detectCycles(workflow.steps, workflow.connections);
cycles.forEach((cycle, index) => {
errors.push({
id: `cycle_${index}`,
type: 'error',
category: 'cycle_detected',
message: 'Cycle détecté dans le workflow',
description: `Cycle impliquant les étapes: ${cycle.join(' → ')}`,
severity: 'critical',
});
});
// 4. Validation des références de variables
workflow.steps.forEach(step => {
const referenceErrors = validateVariableReferences(step, variables);
errors.push(...referenceErrors);
});
// 5. Vérification de la possibilité d'exécution
const canExecute = errors.filter(e => e.severity === 'critical').length === 0;
if (!canExecute) {
errors.push({
id: 'execution_blocked',
type: 'error',
category: 'execution_blocked',
message: 'Exécution bloquée',
description: 'Des erreurs critiques empêchent l\'exécution du workflow',
severity: 'critical',
});
}
return {
isValid: errors.length === 0 && warnings.length === 0,
errors,
warnings,
canExecute,
};
}, [workflow, variables]);
// Gestionnaire de basculement de section
const toggleSection = (section: string) => {
setExpandedSections(prev => {
const newSet = new Set(prev);
if (newSet.has(section)) {
newSet.delete(section);
} else {
newSet.add(section);
}
return newSet;
});
};
// Gestionnaire de survol d'étape
const handleStepHover = (stepId: string | undefined, highlight: boolean) => {
if (stepId && onStepHighlight) {
onStepHighlight(stepId, highlight);
}
};
// Rendu d'une issue de validation
const renderValidationIssue = (issue: ValidationIssue) => {
const getIcon = () => {
switch (issue.category) {
case 'missing_parameter':
return <ErrorIcon color="error" />;
case 'disconnected_step':
return <LinkIcon color="warning" />;
case 'cycle_detected':
return <LoopIcon color="error" />;
case 'invalid_reference':
return <ErrorIcon color="error" />;
case 'execution_blocked':
return <BlockIcon color="error" />;
default:
return <InfoIcon />;
}
};
const getSeverityColor = () => {
switch (issue.severity) {
case 'critical':
return 'error';
case 'high':
return 'error';
case 'medium':
return 'warning';
case 'low':
return 'info';
default:
return 'default';
}
};
return (
<ListItem
key={issue.id}
onMouseEnter={() => handleStepHover(issue.stepId, true)}
onMouseLeave={() => handleStepHover(issue.stepId, false)}
sx={{
border: '1px solid',
borderColor: issue.type === 'error' ? 'error.light' : 'warning.light',
borderRadius: 1,
mb: 1,
backgroundColor: issue.type === 'error' ? 'error.light' : 'warning.light',
'&:hover': {
backgroundColor: issue.type === 'error' ? 'error.main' : 'warning.main',
},
}}
>
<ListItemIcon>
{getIcon()}
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">{issue.message}</Typography>
<Chip
label={issue.severity}
size="small"
color={getSeverityColor() as any}
variant="outlined"
/>
</Box>
}
secondary={issue.description}
/>
</ListItem>
);
};
if (validationResult.isValid) {
return (
<Alert severity="success" sx={{ mb: 2 }}>
<AlertTitle>Workflow valide</AlertTitle>
Aucun problème détecté. Le workflow est prêt à être exécuté.
</Alert>
);
}
return (
<Box
role="region"
aria-label="Validation du workflow"
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* Résumé de validation */}
<Alert
severity={validationResult.canExecute ? 'warning' : 'error'}
sx={{ mb: 2 }}
role="alert"
aria-live="polite"
>
<AlertTitle>
{validationResult.canExecute ? 'Avertissements détectés' : 'Erreurs critiques détectées'}
</AlertTitle>
{validationResult.errors.length > 0 && (
<Typography variant="body2">
{validationResult.errors.length} erreur(s) trouvée(s)
</Typography>
)}
{validationResult.warnings.length > 0 && (
<Typography variant="body2">
{validationResult.warnings.length} avertissement(s) trouvé(s)
</Typography>
)}
{!validationResult.canExecute && (
<Typography variant="body2" fontWeight="bold">
L'exécution est bloquée jusqu'à la résolution des erreurs critiques.
</Typography>
)}
</Alert>
{/* Section des erreurs */}
{validationResult.errors.length > 0 && (
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
mb: 1,
}}
onClick={() => toggleSection('errors')}
role="button"
aria-expanded={expandedSections.has('errors')}
aria-controls="errors-list"
tabIndex={0}
>
<IconButton size="small" aria-hidden="true">
{expandedSections.has('errors') ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
<Typography variant="h6" color="error">
Erreurs ({validationResult.errors.length})
</Typography>
</Box>
<Collapse in={expandedSections.has('errors')}>
<List id="errors-list" role="list" aria-label="Liste des erreurs de validation">
{validationResult.errors.map(renderValidationIssue)}
</List>
</Collapse>
</Box>
)}
{/* Section des avertissements */}
{validationResult.warnings.length > 0 && (
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
mb: 1,
}}
onClick={() => toggleSection('warnings')}
>
<IconButton size="small">
{expandedSections.has('warnings') ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
<Typography variant="h6" color="warning.main">
Avertissements ({validationResult.warnings.length})
</Typography>
</Box>
<Collapse in={expandedSections.has('warnings')}>
<List>
{validationResult.warnings.map(renderValidationIssue)}
</List>
</Collapse>
</Box>
)}
</Box>
);
};
export default Validator;

View File

@@ -0,0 +1,545 @@
/**
* Composant Gestionnaire de Variables - Gestion CRUD des variables de workflow
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant permet de créer, modifier et supprimer des variables,
* avec validation d'unicité des noms et support de différents types.
*/
import React, { useState, useCallback, useMemo, memo } from 'react';
import {
Box,
Typography,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Alert,
Chip,
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Close as CloseIcon,
} from '@mui/icons-material';
// Import des types partagés
import {
VariableManagerProps,
Variable,
VariableType,
VariableTypeEnum,
} from '../../types';
// Import du hook de debouncing et du client API
import { useDebounce } from '../../hooks/useDebounce';
import { useApiClient } from '../../hooks/useApiClient';
import { apiClient } from '../../services/apiClient';
// Labels français pour les types
const typeLabels: Record<VariableType, string> = {
text: 'Texte',
number: 'Nombre',
boolean: 'Booléen',
list: 'Liste',
};
/**
* Composant Gestionnaire de Variables
*/
const VariableManager: React.FC<VariableManagerProps> = ({
variables,
onVariableCreate,
onVariableUpdate,
onVariableDelete,
}) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingVariable, setEditingVariable] = useState<Variable | null>(null);
const [formData, setFormData] = useState({
name: '',
type: 'text' as VariableType,
defaultValue: '',
description: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [searchQuery, setSearchQuery] = useState('');
// Utilisation du client API pour la validation des variables
const apiValidation = useApiClient({
onError: (error) => {
console.error('Erreur de validation des variables:', error);
setErrors(prev => ({ ...prev, api: error.message }));
},
});
// Debouncing de la recherche pour optimiser les performances
const debouncedSearchQuery = useDebounce(searchQuery, 300);
// Mémoriser les variables triées et filtrées pour éviter les re-calculs
const filteredAndSortedVariables = useMemo(() => {
let filtered = variables;
// Filtrer selon la recherche débouncée
if (debouncedSearchQuery.trim()) {
filtered = variables.filter(variable =>
variable.name.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) ||
(variable.description && variable.description.toLowerCase().includes(debouncedSearchQuery.toLowerCase()))
);
}
// Trier par nom
return filtered.sort((a, b) => a.name.localeCompare(b.name));
}, [variables, debouncedSearchQuery]);
// Mémoriser les statistiques des variables
const variableStats = useMemo(() => {
const stats = {
total: variables.length,
byType: {} as Record<VariableType, number>,
};
variables.forEach(variable => {
stats.byType[variable.type] = (stats.byType[variable.type] || 0) + 1;
});
return stats;
}, [variables]);
// Gestionnaire d'événements clavier pour le gestionnaire de variables
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'Enter':
// Activer l'élément focalisé
if (event.target instanceof HTMLButtonElement) {
event.preventDefault();
event.target.click();
}
break;
case 'Escape':
// Fermer les dialogues ouverts
event.preventDefault();
if (isDialogOpen) {
handleCloseDialog();
}
break;
case 'n':
// Raccourci pour nouvelle variable (Ctrl+N)
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
handleCreateNew();
}
break;
}
}, [isDialogOpen]);
// Validation des variables avec l'API
const validateVariableWithApi = useCallback(async (variableData: Partial<Variable>) => {
try {
// Simuler une validation côté client qui pourrait utiliser l'API
const validationErrors: Record<string, string> = {};
// Validation du nom
if (!variableData.name || variableData.name.trim().length === 0) {
validationErrors.name = 'Le nom de la variable est obligatoire';
} else if (variableData.name.length > 50) {
validationErrors.name = 'Le nom ne peut pas dépasser 50 caractères';
}
// Validation de l'unicité du nom
const existingVariable = variables.find(v =>
v.name === variableData.name &&
(!editingVariable || v.id !== editingVariable.id)
);
if (existingVariable) {
validationErrors.name = 'Une variable avec ce nom existe déjà';
}
// Validation du type
if (!variableData.type) {
validationErrors.type = 'Le type de variable est obligatoire';
}
// Validation de la valeur par défaut selon le type
if (variableData.defaultValue && variableData.type) {
switch (variableData.type) {
case 'number':
if (isNaN(Number(variableData.defaultValue))) {
validationErrors.defaultValue = 'La valeur par défaut doit être un nombre';
}
break;
case 'boolean':
if (!['true', 'false', '1', '0'].includes(variableData.defaultValue.toLowerCase())) {
validationErrors.defaultValue = 'La valeur par défaut doit être true/false ou 1/0';
}
break;
}
}
setErrors(validationErrors);
return Object.keys(validationErrors).length === 0;
} catch (error) {
console.error('Erreur lors de la validation:', error);
setErrors({ api: 'Erreur de validation' });
return false;
}
}, [variables, editingVariable]);
// Ouvrir le dialogue pour créer une nouvelle variable
const handleCreateNew = () => {
setEditingVariable(null);
setFormData({
name: '',
type: VariableTypeEnum.TEXT,
defaultValue: '',
description: '',
});
setErrors({});
setIsDialogOpen(true);
};
// Ouvrir le dialogue pour modifier une variable existante
const handleEdit = (variable: Variable) => {
setEditingVariable(variable);
setFormData({
name: variable.name,
type: variable.type,
defaultValue: variable.defaultValue || '',
description: variable.description || '',
});
setErrors({});
setIsDialogOpen(true);
};
// Fermer le dialogue
const handleCloseDialog = () => {
setIsDialogOpen(false);
setEditingVariable(null);
setFormData({
name: '',
type: 'text' as VariableType,
defaultValue: '',
description: '',
});
setErrors({});
};
// Valider le formulaire
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
// Validation du nom
if (!formData.name.trim()) {
newErrors.name = 'Le nom de la variable est obligatoire';
} else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(formData.name)) {
newErrors.name = 'Le nom doit commencer par une lettre ou _ et contenir uniquement des lettres, chiffres et _';
} else {
// Vérifier l'unicité du nom
const existingVariable = variables.find(
v => v.name === formData.name && v.id !== editingVariable?.id
);
if (existingVariable) {
newErrors.name = 'Une variable avec ce nom existe déjà';
}
}
// Validation de la valeur par défaut selon le type
if (formData.defaultValue) {
switch (formData.type) {
case 'number':
if (isNaN(Number(formData.defaultValue))) {
newErrors.defaultValue = 'La valeur par défaut doit être un nombre';
}
break;
case 'boolean':
if (!['true', 'false'].includes(formData.defaultValue.toLowerCase())) {
newErrors.defaultValue = 'La valeur par défaut doit être "true" ou "false"';
}
break;
case 'list':
try {
JSON.parse(formData.defaultValue);
} catch {
newErrors.defaultValue = 'La valeur par défaut doit être un JSON valide pour une liste';
}
break;
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Sauvegarder la variable
const handleSave = () => {
if (!validateForm()) return;
let processedDefaultValue: any = formData.defaultValue;
// Traiter la valeur par défaut selon le type
if (processedDefaultValue) {
switch (formData.type) {
case 'number':
processedDefaultValue = Number(processedDefaultValue);
break;
case 'boolean':
processedDefaultValue = formData.defaultValue.toLowerCase() === 'true';
break;
case 'list':
try {
processedDefaultValue = JSON.parse(formData.defaultValue);
} catch {
processedDefaultValue = [];
}
break;
}
}
const variableData = {
name: formData.name,
type: formData.type,
defaultValue: processedDefaultValue || undefined,
description: formData.description || undefined,
};
if (editingVariable) {
onVariableUpdate(editingVariable.id, variableData);
} else {
onVariableCreate(variableData);
}
handleCloseDialog();
};
// Supprimer une variable
const handleDelete = (variable: Variable) => {
if (window.confirm(`Êtes-vous sûr de vouloir supprimer la variable "${variable.name}" ?`)) {
onVariableDelete(variable.id);
}
};
// Formater la valeur par défaut pour l'affichage
const formatDefaultValue = (variable: Variable): string => {
if (variable.defaultValue === undefined || variable.defaultValue === null) {
return 'Non définie';
}
switch (variable.type) {
case 'boolean':
return variable.defaultValue ? 'Vrai' : 'Faux';
case 'list':
return Array.isArray(variable.defaultValue)
? `[${variable.defaultValue.length} éléments]`
: JSON.stringify(variable.defaultValue);
default:
return String(variable.defaultValue);
}
};
return (
<Box
role="region"
aria-label="Gestionnaire de variables du workflow"
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* En-tête avec bouton d'ajout */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Variables du workflow</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleCreateNew}
size="small"
aria-label="Créer une nouvelle variable"
>
Nouvelle variable
</Button>
</Box>
{/* Liste des variables */}
{variables.length === 0 ? (
<Alert severity="info">
Aucune variable définie. Créez des variables pour rendre votre workflow plus flexible.
</Alert>
) : (
<List role="list" aria-label="Liste des variables définies">
{variables.map((variable) => (
<ListItem
key={variable.id}
divider
sx={{
border: '1px solid #e0e0e0',
borderRadius: 1,
mb: 1,
backgroundColor: '#fafafa',
}}
role="listitem"
aria-label={`Variable ${variable.name} de type ${typeLabels[variable.type]}`}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">{variable.name}</Typography>
<Chip
label={typeLabels[variable.type]}
size="small"
color="primary"
variant="outlined"
/>
</Box>
}
secondary={
<Box>
<Typography variant="body2" color="text.secondary">
Valeur par défaut: {formatDefaultValue(variable)}
</Typography>
{variable.description && (
<Typography variant="caption" color="text.secondary">
{variable.description}
</Typography>
)}
</Box>
}
/>
<ListItemSecondaryAction>
<IconButton
edge="end"
onClick={() => handleEdit(variable)}
size="small"
sx={{ mr: 1 }}
aria-label={`Modifier la variable ${variable.name}`}
>
<EditIcon />
</IconButton>
<IconButton
edge="end"
onClick={() => handleDelete(variable)}
size="small"
color="error"
aria-label={`Supprimer la variable ${variable.name}`}
>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
{/* Dialogue de création/modification */}
<Dialog
open={isDialogOpen}
onClose={handleCloseDialog}
maxWidth="sm"
fullWidth
>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{editingVariable ? 'Modifier la variable' : 'Nouvelle variable'}
<IconButton onClick={handleCloseDialog} size="small">
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
{/* Nom de la variable */}
<TextField
fullWidth
label="Nom de la variable"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
error={!!errors.name}
helperText={errors.name || 'Utilisez uniquement des lettres, chiffres et _'}
required
/>
{/* Type de la variable */}
<FormControl fullWidth>
<InputLabel>Type de variable</InputLabel>
<Select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as VariableType })}
label="Type de variable"
>
{Object.entries(typeLabels).map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
{/* Valeur par défaut */}
<TextField
fullWidth
label="Valeur par défaut (optionnelle)"
value={formData.defaultValue}
onChange={(e) => setFormData({ ...formData, defaultValue: e.target.value })}
error={!!errors.defaultValue}
helperText={errors.defaultValue || getDefaultValueHelp(formData.type)}
multiline={formData.type === 'list'}
rows={formData.type === 'list' ? 3 : 1}
/>
{/* Description */}
<TextField
fullWidth
label="Description (optionnelle)"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
multiline
rows={2}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Annuler</Button>
<Button onClick={handleSave} variant="contained">
{editingVariable ? 'Modifier' : 'Créer'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
// Aide contextuelle pour la valeur par défaut
const getDefaultValueHelp = (type: VariableType): string => {
switch (type) {
case 'text':
return 'Texte libre';
case 'number':
return 'Nombre décimal ou entier';
case 'boolean':
return 'true ou false';
case 'list':
return 'JSON valide, ex: ["item1", "item2"]';
default:
return '';
}
};
// Mémorisation du composant VariableManager pour éviter les re-rendus inutiles
export default memo(VariableManager, (prevProps, nextProps) => {
return (
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables) &&
prevProps.onVariableCreate === nextProps.onVariableCreate &&
prevProps.onVariableUpdate === nextProps.onVariableUpdate &&
prevProps.onVariableDelete === nextProps.onVariableDelete
);
});

View File

@@ -0,0 +1,296 @@
/**
* Styles pour le Panneau des Propriétés Visuelles
*
* Suit les guidelines du design system RPA Vision V3:
* - Couleurs Material-UI avec thème sombre
* - Espacement cohérent (xs: 4px, sm: 8px, md: 12px, lg: 16px, xl: 20px)
* - Composants Material-UI avec CSS modules personnalisés
*/
.visual-properties-panel {
background: #1e293b; /* Card Background du design system */
border-left: 1px solid #334155; /* Border Color */
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.panel-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%); /* Primary Blue */
color: #e2e8f0; /* Text Primary */
border-bottom: 1px solid #334155;
}
.panel-content {
background: #0f172a; /* Dark Background */
padding: 20px; /* Component spacing */
}
/* Conteneur de statut de validation */
.validation-status-container {
background: rgba(30, 41, 59, 0.8) !important; /* Card Background avec transparence */
border: 1px solid #334155;
border-radius: 12px;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.validation-status-container:hover {
border-color: #1976d2; /* Primary Blue au survol */
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.2);
}
/* Conteneur de capture d'écran */
.screenshot-container {
background: #1e293b !important;
border: 1px solid #334155;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s ease;
}
.screenshot-container:hover {
border-color: #1976d2;
box-shadow: 0 8px 24px rgba(25, 118, 210, 0.15);
transform: translateY(-2px);
}
.screenshot-image {
width: 100%;
height: auto;
max-height: 300px;
object-fit: contain;
cursor: pointer;
transition: all 0.3s ease;
}
.screenshot-image:hover {
transform: scale(1.02);
}
/* Placeholder pour capture d'écran */
.screenshot-placeholder {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%) !important;
border: 2px dashed #334155;
border-radius: 12px;
min-height: 200px;
transition: all 0.3s ease;
}
.screenshot-placeholder:hover {
border-color: #1976d2;
background: linear-gradient(135deg, #1e293b 0%, #1976d2 5%, #0f172a 100%) !important;
}
.select-element-button {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%) !important;
color: white !important;
padding: 12px 24px !important; /* Input padding étendu */
border-radius: 8px !important;
font-weight: 600 !important;
text-transform: none !important;
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3) !important;
transition: all 0.3s ease !important;
}
.select-element-button:hover {
background: linear-gradient(135deg, #1565c0 0%, #0d47a1 100%) !important;
box-shadow: 0 6px 16px rgba(25, 118, 210, 0.4) !important;
transform: translateY(-2px);
}
/* Paramètres visuels */
.visual-parameters {
background: #1e293b !important;
border: 1px solid #334155;
border-radius: 12px;
}
.visual-parameters .MuiCardContent-root {
padding: 20px !important;
}
/* Animations pour les indicateurs de statut */
@keyframes pulse-success {
0%, 100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
}
50% {
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
}
}
@keyframes pulse-warning {
0%, 100% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.7);
}
50% {
box-shadow: 0 0 0 10px rgba(245, 158, 11, 0);
}
}
@keyframes pulse-error {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
}
50% {
box-shadow: 0 0 0 10px rgba(239, 68, 68, 0);
}
}
/* Indicateurs de statut avec animations */
.status-indicator-success {
animation: pulse-success 2s infinite;
}
.status-indicator-warning {
animation: pulse-warning 2s infinite;
}
.status-indicator-error {
animation: pulse-error 2s infinite;
}
/* Styles pour les alertes de validation */
.MuiAlert-root {
border-radius: 8px !important;
margin-bottom: 8px !important; /* sm spacing */
}
.MuiAlert-standardError {
background: rgba(239, 68, 68, 0.1) !important;
border: 1px solid rgba(239, 68, 68, 0.3) !important;
color: #fecaca !important;
}
.MuiAlert-standardInfo {
background: rgba(25, 118, 210, 0.1) !important;
border: 1px solid rgba(25, 118, 210, 0.3) !important;
color: #bfdbfe !important;
}
.MuiAlert-standardWarning {
background: rgba(245, 158, 11, 0.1) !important;
border: 1px solid rgba(245, 158, 11, 0.3) !important;
color: #fde68a !important;
}
/* Styles pour les chips de confiance */
.MuiChip-root {
font-weight: 600 !important;
border-radius: 6px !important;
}
.MuiChip-colorSuccess {
background: rgba(34, 197, 94, 0.2) !important;
color: #22c55e !important;
border-color: #22c55e !important;
}
.MuiChip-colorWarning {
background: rgba(245, 158, 11, 0.2) !important;
color: #f59e0b !important;
border-color: #f59e0b !important;
}
.MuiChip-colorError {
background: rgba(239, 68, 68, 0.2) !important;
color: #ef4444 !important;
border-color: #ef4444 !important;
}
/* Barre de progression de confiance */
.MuiLinearProgress-root {
height: 4px !important;
border-radius: 2px !important;
background: rgba(51, 65, 85, 0.3) !important;
}
.MuiLinearProgress-barColorSuccess {
background: linear-gradient(90deg, #22c55e 0%, #16a34a 100%) !important;
}
.MuiLinearProgress-barColorWarning {
background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%) !important;
}
.MuiLinearProgress-barColorError {
background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%) !important;
}
/* Boutons d'action */
.MuiIconButton-root {
color: #94a3b8 !important; /* Text Secondary */
transition: all 0.3s ease !important;
}
.MuiIconButton-root:hover {
color: #1976d2 !important; /* Primary Blue */
background: rgba(25, 118, 210, 0.1) !important;
transform: scale(1.1);
}
/* Tooltips */
.MuiTooltip-tooltip {
background: #1e293b !important;
color: #e2e8f0 !important;
border: 1px solid #334155 !important;
border-radius: 6px !important;
font-size: 12px !important;
padding: 8px 12px !important;
}
.MuiTooltip-arrow {
color: #1e293b !important;
}
/* Responsive design */
@media (max-width: 768px) {
.visual-properties-panel {
width: 100% !important;
height: auto !important;
border-left: none;
border-top: 1px solid #334155;
}
.panel-content {
padding: 16px; /* lg spacing pour mobile */
}
.screenshot-image {
max-height: 200px;
}
}
/* Accessibilité */
@media (prefers-reduced-motion: reduce) {
.screenshot-container,
.screenshot-image,
.select-element-button,
.MuiIconButton-root {
transition: none !important;
}
.status-indicator-success,
.status-indicator-warning,
.status-indicator-error {
animation: none !important;
}
}
/* Focus visible pour l'accessibilité */
.select-element-button:focus-visible,
.MuiIconButton-root:focus-visible {
outline: 2px solid #1976d2 !important;
outline-offset: 2px !important;
}
/* Thème sombre spécifique */
@media (prefers-color-scheme: dark) {
.visual-properties-panel {
background: #0f172a;
}
.panel-content {
background: #020617;
}
}

View File

@@ -0,0 +1,518 @@
/**
* Composant Panneau de Propriétés Visuelles - Configuration des paramètres d'étapes
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Panneau des propriétés entièrement visuel pour la configuration des actions RPA.
* Supprime complètement les sélecteurs CSS/XPath et utilise uniquement des méthodes visuelles.
*
* Fonctionnalités:
* - Affichage des captures d'écran haute qualité
* - Sélection visuelle interactive
* - Validation en temps réel
* - Métadonnées visuelles enrichies
* - Interface sans éléments techniques
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Typography,
Button,
Card,
CardContent,
Chip,
Alert,
CircularProgress,
Tooltip,
IconButton,
Divider,
LinearProgress,
} from '@mui/material';
import {
CheckCircle as ValidIcon,
Warning as WarningIcon,
Error as ErrorIcon,
Refresh as RefreshIcon,
ZoomIn as ZoomIcon,
Info as InfoIcon,
Settings as SettingsIcon,
PhotoCamera as CameraIcon,
} from '@mui/icons-material';
import { BoundingBox } from '../../types';
import InteractivePreviewArea from '../InteractivePreviewArea';
import './VisualPropertiesPanel.css';
interface VisualTarget {
screenshot: string;
bounding_box: BoundingBox;
metadata: {
element_type: string;
relative_position?: string;
text_content?: string;
};
}
interface VisualNode {
id: string;
type: string;
visualTarget?: VisualTarget;
parameters?: Record<string, any>;
}
interface VisualPropertiesPanelProps {
node: VisualNode;
onNodeUpdate: (nodeId: string, updates: Partial<VisualNode>) => void;
onClose: () => void;
}
interface ValidationStatus {
isValid: boolean;
confidence: number;
lastChecked: Date;
issues: string[];
suggestions: string[];
}
const VisualPropertiesPanel: React.FC<VisualPropertiesPanelProps> = ({
node,
onNodeUpdate,
onClose,
}) => {
const [selectorOpen, setSelectorOpen] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [validationStatus, setValidationStatus] = useState<ValidationStatus | null>(null);
const [isValidating, setIsValidating] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
// Validation automatique au chargement
useEffect(() => {
if (node.visualTarget) {
validateTarget();
}
}, [node.visualTarget]);
/**
* Valide la cible visuelle actuelle
*/
const validateTarget = useCallback(async () => {
if (!node.visualTarget) return;
setIsValidating(true);
try {
// Simuler la validation (à remplacer par l'API réelle)
await new Promise(resolve => setTimeout(resolve, 1500));
// Simuler un résultat de validation
const mockValidation: ValidationStatus = {
isValid: Math.random() > 0.3, // 70% de chance d'être valide
confidence: 0.85 + Math.random() * 0.15, // Entre 0.85 et 1.0
lastChecked: new Date(),
issues: Math.random() > 0.7 ? ['Élément légèrement déplacé'] : [],
suggestions: Math.random() > 0.5 ? ['Mettre à jour la capture'] : []
};
setValidationStatus(mockValidation);
} catch (error) {
console.error('Erreur lors de la validation:', error);
setValidationStatus({
isValid: false,
confidence: 0,
lastChecked: new Date(),
issues: ['Erreur de validation'],
suggestions: ['Vérifier la connexion']
});
} finally {
setIsValidating(false);
}
}, [node.visualTarget]);
/**
* Gère la sélection d'un nouvel élément
*/
const handleElementSelected = useCallback((target: VisualTarget) => {
onNodeUpdate(node.id, {
visualTarget: target
});
setSelectorOpen(false);
// Valider immédiatement le nouvel élément
setTimeout(validateTarget, 500);
}, [node.id, onNodeUpdate, validateTarget]);
/**
* Ouvre le sélecteur visuel
*/
const openVisualSelector = useCallback(() => {
// Simuler l'ouverture du sélecteur visuel
console.log('Ouverture du sélecteur visuel');
}, []);
/**
* Ouvre l'aperçu interactif
*/
const openPreview = useCallback(() => {
if (node.visualTarget?.screenshot) {
setPreviewOpen(true);
}
}, [node.visualTarget]);
/**
* Met à jour les paramètres du nœud
*/
const updateNodeParameter = useCallback((key: string, value: any) => {
onNodeUpdate(node.id, {
parameters: {
...node.parameters,
[key]: value
}
});
}, [node.id, node.parameters, onNodeUpdate]);
/**
* Obtient l'icône de statut de validation
*/
const getValidationIcon = () => {
if (isValidating) {
return <CircularProgress size={20} />;
}
if (!validationStatus) {
return <InfoIcon color="disabled" />;
}
if (validationStatus.isValid) {
return <ValidIcon color="success" />;
} else if (validationStatus.issues.length > 0) {
return <WarningIcon color="warning" />;
} else {
return <ErrorIcon color="error" />;
}
};
/**
* Obtient le message de statut de validation
*/
const getValidationMessage = () => {
if (isValidating) {
return "Validation en cours...";
}
if (!validationStatus) {
return "Aucune validation effectuée";
}
if (validationStatus.isValid) {
return `Élément valide (confiance: ${Math.round(validationStatus.confidence * 100)}%)`;
} else {
return "Élément non trouvé ou modifié";
}
};
/**
* Obtient la couleur du statut de validation
*/
const getValidationColor = (): 'success' | 'warning' | 'error' | 'info' => {
if (!validationStatus || isValidating) return 'info';
if (validationStatus.isValid) {
return validationStatus.confidence > 0.9 ? 'success' : 'warning';
} else {
return 'error';
}
};
return (
<Box className="visual-properties-panel">
{/* En-tête du panneau */}
<Box className="panel-header">
<Typography variant="h6" className="panel-title">
Propriétés Visuelles
</Typography>
<Typography variant="body2" color="textSecondary">
{node.type || 'Action'} - Configuration 100% visuelle
</Typography>
</Box>
<Divider />
{/* Zone de capture principale */}
<Card className="capture-section" elevation={2}>
<CardContent>
<Box className="capture-header">
<Typography variant="subtitle1" className="section-title">
Élément Cible
</Typography>
<Box className="capture-actions">
<Tooltip title="Nouvelle sélection">
<IconButton onClick={openVisualSelector} color="primary">
<CameraIcon />
</IconButton>
</Tooltip>
{node.visualTarget?.screenshot && (
<Tooltip title="Aperçu agrandi">
<IconButton onClick={openPreview} color="primary">
<ZoomIcon />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
{node.visualTarget ? (
<Box className="capture-display">
{/* Image de l'élément */}
<Box className="screenshot-container">
<img
src={`data:image/png;base64,${node.visualTarget.screenshot}`}
alt="Élément sélectionné"
className="screenshot-image"
onClick={openPreview}
/>
<Box className="screenshot-overlay">
<Chip
label={node.visualTarget.metadata.element_type}
size="small"
color="primary"
className="element-type-chip"
/>
</Box>
</Box>
{/* Métadonnées visuelles */}
<Box className="metadata-display">
<Typography variant="body2" color="textSecondary">
Type: {node.visualTarget.metadata.element_type}
</Typography>
{node.visualTarget.metadata.text_content && (
<Typography variant="body2" color="textSecondary">
Texte: "{node.visualTarget.metadata.text_content}"
</Typography>
)}
</Box>
</Box>
) : (
<Box className="no-capture-state">
<Typography variant="body2" color="textSecondary" align="center">
Aucun élément sélectionné
</Typography>
<Button
variant="contained"
startIcon={<CameraIcon />}
onClick={openVisualSelector}
className="select-button"
>
Sélectionner un Élément
</Button>
</Box>
)}
</CardContent>
</Card>
{/* Statut de validation */}
{node.visualTarget && (
<Card className="validation-section" elevation={1}>
<CardContent>
<Box className="validation-header">
<Box className="validation-status">
{getValidationIcon()}
<Typography variant="body2" className="validation-text">
{getValidationMessage()}
</Typography>
</Box>
<Tooltip title="Revalider">
<IconButton
onClick={validateTarget}
disabled={isValidating}
size="small"
>
<RefreshIcon />
</IconButton>
</Tooltip>
</Box>
{validationStatus && (
<Box className="validation-details">
<LinearProgress
variant="determinate"
value={validationStatus.confidence * 100}
color={getValidationColor()}
className="confidence-bar"
/>
{validationStatus.issues.length > 0 && (
<Alert severity="warning" className="validation-alert">
<Typography variant="body2">
Problèmes détectés: {validationStatus.issues.join(', ')}
</Typography>
</Alert>
)}
{validationStatus.suggestions.length > 0 && (
<Box className="suggestions">
<Typography variant="caption" color="textSecondary">
Suggestions: {validationStatus.suggestions.join(', ')}
</Typography>
</Box>
)}
</Box>
)}
</CardContent>
</Card>
)}
{/* Paramètres de l'action */}
<Card className="parameters-section" elevation={1}>
<CardContent>
<Typography variant="subtitle1" className="section-title">
Paramètres de l'Action
</Typography>
{/* Paramètres spécifiques au type d'action */}
{node.type === 'click' && (
<Box className="parameter-group">
<Typography variant="body2" className="parameter-label">
Type de clic
</Typography>
<Box className="parameter-options">
{['Simple', 'Double', 'Droit'].map((clickType) => (
<Chip
key={clickType}
label={clickType}
variant={node.parameters?.clickType === clickType ? 'filled' : 'outlined'}
onClick={() => updateNodeParameter('clickType', clickType)}
className="option-chip"
/>
))}
</Box>
</Box>
)}
{node.type === 'input' && (
<Box className="parameter-group">
<Typography variant="body2" className="parameter-label">
Texte à saisir
</Typography>
<Box className="text-input-container">
<input
type="text"
value={node.parameters?.text || ''}
onChange={(e) => updateNodeParameter('text', e.target.value)}
placeholder="Entrez le texte à saisir..."
className="text-input"
/>
</Box>
</Box>
)}
{/* Délai d'attente */}
<Box className="parameter-group">
<Typography variant="body2" className="parameter-label">
Délai d'attente
</Typography>
<Box className="delay-controls">
<input
type="range"
min="0"
max="10"
step="0.5"
value={node.parameters?.delay || 1}
onChange={(e) => updateNodeParameter('delay', parseFloat(e.target.value))}
className="delay-slider"
/>
<Typography variant="caption" className="delay-value">
{node.parameters?.delay || 1}s
</Typography>
</Box>
</Box>
</CardContent>
</Card>
{/* Options avancées */}
<Card className="advanced-section" elevation={1}>
<CardContent>
<Box
className="advanced-header"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<Typography variant="subtitle1" className="section-title">
Options Avancées
</Typography>
<IconButton size="small">
<SettingsIcon />
</IconButton>
</Box>
{showAdvanced && (
<Box className="advanced-options">
<Box className="option-row">
<Typography variant="body2">Tolérance de position</Typography>
<input
type="range"
min="0"
max="100"
value={node.parameters?.positionTolerance || 10}
onChange={(e) => updateNodeParameter('positionTolerance', parseInt(e.target.value))}
className="tolerance-slider"
/>
<Typography variant="caption">
{node.parameters?.positionTolerance || 10}px
</Typography>
</Box>
<Box className="option-row">
<Typography variant="body2">Seuil de confiance</Typography>
<input
type="range"
min="0.5"
max="1.0"
step="0.05"
value={node.parameters?.confidenceThreshold || 0.8}
onChange={(e) => updateNodeParameter('confidenceThreshold', parseFloat(e.target.value))}
className="confidence-slider"
/>
<Typography variant="caption">
{Math.round((node.parameters?.confidenceThreshold || 0.8) * 100)}%
</Typography>
</Box>
</Box>
)}
</CardContent>
</Card>
{/* Actions du panneau */}
<Box className="panel-actions">
<Button
variant="outlined"
onClick={onClose}
className="cancel-button"
>
Fermer
</Button>
<Button
variant="contained"
color="primary"
disabled={!node.visualTarget}
className="save-button"
>
Appliquer
</Button>
</Box>
{/* Dialogs */}
{node.visualTarget && (
<InteractivePreviewArea
open={previewOpen}
onClose={() => setPreviewOpen(false)}
screenshot={node.visualTarget.screenshot}
boundingBox={node.visualTarget.bounding_box}
metadata={node.visualTarget.metadata}
/>
)}
</Box>
);
};
export default VisualPropertiesPanel;

View File

@@ -0,0 +1,258 @@
/**
* Visual Screen Selector Styles - RPA Vision V3
*
* Styles pour le sélecteur d'écran visuel interactif.
* Optimisé pour une expérience de sélection fluide et intuitive.
*/
.visual-screen-selector {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
background: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
}
.visual-screen-selector__header {
background: #1976d2;
color: white;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.visual-screen-selector__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 1.25rem;
font-weight: 600;
}
.visual-screen-selector__controls {
display: flex;
gap: 8px;
}
.visual-screen-selector__content {
flex: 1;
display: flex;
overflow: hidden;
}
.visual-screen-selector__canvas-area {
flex: 1;
position: relative;
overflow: auto;
background: #f5f5f5;
}
.visual-screen-selector__canvas {
display: block;
max-width: 100%;
max-height: 100%;
cursor: crosshair;
transition: cursor 0.2s ease;
}
.visual-screen-selector__canvas--pointer {
cursor: pointer;
}
.visual-screen-selector__sidebar {
width: 320px;
background: white;
border-left: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.visual-screen-selector__sidebar-header {
padding: 16px;
border-bottom: 1px solid #e0e0e0;
background: #f8f9fa;
}
.visual-screen-selector__sidebar-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.visual-screen-selector__element-card {
margin-bottom: 12px;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
background: white;
}
.visual-screen-selector__element-card:hover {
border-color: #2196f3;
box-shadow: 0 2px 8px rgba(33, 150, 243, 0.2);
transform: translateY(-1px);
}
.visual-screen-selector__element-card--hovered {
border-color: #2196f3;
background: #e3f2fd;
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.3);
}
.visual-screen-selector__element-card--selected {
border-color: #4caf50;
background: #e8f5e8;
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
}
.visual-screen-selector__element-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.visual-screen-selector__element-text {
font-weight: 500;
margin-bottom: 4px;
}
.visual-screen-selector__element-details {
font-size: 0.75rem;
color: #666;
line-height: 1.4;
}
.visual-screen-selector__status-bar {
padding: 16px 24px;
background: white;
border-top: 1px solid #e0e0e0;
}
.visual-screen-selector__instructions {
background: #e3f2fd;
padding: 12px 16px;
border-radius: 8px;
font-size: 0.875rem;
color: #1565c0;
line-height: 1.5;
}
.visual-screen-selector__loading {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
flex-direction: column;
gap: 16px;
color: white;
}
.visual-screen-selector__loading-spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid white;
border-radius: 50%;
animation: visual-screen-selector-spin 1s linear infinite;
}
@keyframes visual-screen-selector-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.visual-screen-selector__error {
padding: 16px 24px;
background: #ffebee;
border-left: 4px solid #f44336;
color: #c62828;
font-size: 0.875rem;
}
.visual-screen-selector__success {
padding: 16px 24px;
background: #e8f5e8;
border-left: 4px solid #4caf50;
color: #2e7d32;
font-size: 0.875rem;
}
/* Animations pour les overlays */
.visual-screen-selector__overlay {
position: absolute;
pointer-events: none;
border: 2px solid #ff9800;
background: rgba(255, 152, 0, 0.1);
transition: all 0.1s ease;
z-index: 10;
}
.visual-screen-selector__overlay--hovered {
border-color: #2196f3;
background: rgba(33, 150, 243, 0.2);
border-width: 3px;
}
.visual-screen-selector__overlay--selected {
border-color: #4caf50;
background: rgba(76, 175, 80, 0.2);
border-width: 4px;
animation: visual-screen-selector-pulse 2s infinite;
}
@keyframes visual-screen-selector-pulse {
0% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(76, 175, 80, 0); }
100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); }
}
/* Responsive design */
@media (max-width: 768px) {
.visual-screen-selector__sidebar {
width: 280px;
}
.visual-screen-selector__header {
padding: 12px 16px;
}
.visual-screen-selector__title {
font-size: 1.1rem;
}
.visual-screen-selector__sidebar-content {
padding: 12px;
}
.visual-screen-selector__element-card {
padding: 10px;
margin-bottom: 10px;
}
}
@media (max-width: 480px) {
.visual-screen-selector__content {
flex-direction: column;
}
.visual-screen-selector__sidebar {
width: 100%;
height: 200px;
border-left: none;
border-top: 1px solid #e0e0e0;
}
.visual-screen-selector__canvas-area {
height: calc(100vh - 200px - 120px); /* Ajuster selon header + sidebar */
}
}

View File

@@ -61,6 +61,7 @@ import { captureLibraryService, SavedCapture } from '../../services/captureLibra
// Import des types partagés et du service de capture
import { VisualSelection, BoundingBox } from '../../types';
import { screenCaptureService } from '../../services/screenCaptureService';
import { uploadAnchorImage } from '../../services/anchorImageService';
interface VisualSelectorProps {
isOpen: boolean;
@@ -116,6 +117,7 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
const [isCanvasReady, setIsCanvasReady] = useState(false); // Canvas prêt après chargement de l'image
const [canvasSize, setCanvasSize] = useState({ width: 1200, height: 800 }); // Taille du canvas pour le style CSS
const [imageScale, setImageScale] = useState(1); // Scale appliqué à l'image (pour convertir les coordonnées)
const [originalImageSize, setOriginalImageSize] = useState({ width: 0, height: 0 }); // Taille de l'image originale pour le backend
// États pour la sélection de moniteur
const [monitors, setMonitors] = useState<Monitor[]>([]);
@@ -135,7 +137,7 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
useEffect(() => {
const loadMonitors = async () => {
try {
const response = await fetch('http://localhost:5002/api/real-demo/capture/status');
const response = await fetch('http://localhost:5001/api/real-demo/capture/status');
const data = await response.json();
if (data.success && data.monitors) {
setMonitors(data.monitors);
@@ -233,9 +235,11 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
console.log('✅ [VisualSelector] Image dessinée sur le canvas:', canvas.width, 'x', canvas.height, 'scale:', scale);
console.log('📐 [VisualSelector] Taille image originale:', img.width, 'x', img.height);
// IMPORTANT: Stocker le scale pour la conversion des coordonnées
// IMPORTANT: Stocker le scale et la taille originale pour la conversion des coordonnées
setImageScale(scale);
setOriginalImageSize({ width: img.width, height: img.height });
// IMPORTANT: Mettre à jour le state canvasSize pour que le CSS corresponde
// Ceci évite le bug de coordonnées quand scaleX != 1
@@ -297,7 +301,7 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
await new Promise(resolve => setTimeout(resolve, delayMs));
// Utiliser l'API de capture réelle avec le moniteur sélectionné
const response = await fetch('http://localhost:5002/api/real-demo/capture', {
const response = await fetch('http://localhost:5001/api/real-demo/capture', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -463,6 +467,13 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
height: canvasHeight / imageScale,
};
// DIAGNOSTIC: Afficher toutes les informations de debug
console.log(`📐 [VisualSelector] DIAGNOSTIC COORDONNÉES:`);
console.log(` Canvas dimensions: ${canvas.width}x${canvas.height} (interne)`);
console.log(` Canvas CSS rect: ${rect.width.toFixed(0)}x${rect.height.toFixed(0)} (affiché)`);
console.log(` CSS→Canvas scale: scaleX=${scaleX.toFixed(3)}, scaleY=${scaleY.toFixed(3)}`);
console.log(` Image scale (canvas→original): ${imageScale.toFixed(3)}`);
console.log(` Original image size: ${originalImageSize.width}x${originalImageSize.height}`);
console.log(`🎯 Sélection finale: canvas=(${canvasX.toFixed(0)}, ${canvasY.toFixed(0)}) ${canvasWidth.toFixed(0)}×${canvasHeight.toFixed(0)}px -> original=(${selectedArea.x.toFixed(0)}, ${selectedArea.y.toFixed(0)}) ${selectedArea.width.toFixed(0)}×${selectedArea.height.toFixed(0)}px [scale: ${imageScale}]`);
// Valider que la zone sélectionnée a une taille minimale
@@ -496,9 +507,6 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
try {
console.log('🎯 Création de la sélection visuelle...');
// Extraire la région sélectionnée de l'image
let referenceImage = captureState.screenshot;
// Essayer de créer un embedding via le service (optionnel)
let embeddingData: number[] | undefined;
let embeddingId: string | undefined;
@@ -513,7 +521,6 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
if (result?.success && result.embedding) {
embeddingData = result.embedding;
embeddingId = result.embedding_id;
referenceImage = result.reference_image || captureState.screenshot;
console.log(`✅ Embedding créé - ID: ${embeddingId}`);
}
} catch (embeddingError) {
@@ -521,26 +528,63 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
console.warn('⚠️ Service d\'embedding non disponible, sélection sans embedding');
}
// Créer l'objet VisualSelection (fonctionne avec ou sans embedding)
// Upload de l'image sur le serveur pour obtenir des URLs
let thumbnailUrl: string | undefined;
let originalUrl: string | undefined;
let anchorId: string = `visual_${stepId}_${Date.now()}`;
try {
const uploadResult = await uploadAnchorImage(
captureState.screenshot,
captureState.selectedArea
);
if (uploadResult.success) {
anchorId = uploadResult.anchor_id;
thumbnailUrl = uploadResult.thumbnail_url;
originalUrl = uploadResult.original_url;
console.log('✅ Image uploadée sur le serveur:', {
anchor_id: anchorId,
thumbnail_url: thumbnailUrl,
});
}
} catch (uploadError) {
// Si l'upload échoue, on continue avec le base64 en fallback
console.warn('⚠️ Upload serveur échoué, utilisation du base64 en fallback:', uploadError);
}
// Créer l'objet VisualSelection
const visualSelection: VisualSelection = {
id: `visual_${stepId}_${Date.now()}`,
screenshot: captureState.screenshot,
id: anchorId,
// Ne stocker le screenshot en base64 que si l'upload a échoué
screenshot: thumbnailUrl ? '' : captureState.screenshot,
boundingBox: captureState.selectedArea,
embedding: embeddingData,
description: `Élément sélectionné pour l'étape ${stepId}`,
metadata: {
embedding_id: embeddingId,
reference_image: referenceImage,
// URLs serveur (prioritaires)
thumbnail_url: thumbnailUrl,
reference_image_url: originalUrl,
// Fallback base64 seulement si pas d'URLs
reference_image: thumbnailUrl ? undefined : captureState.screenshot,
capture_method: 'screen_capture',
capture_timestamp: new Date().toISOString(),
has_embedding: !!embeddingData,
uses_server_storage: !!thumbnailUrl,
// IMPORTANT: Résolution de l'image originale pour le calcul des coordonnées côté backend
screen_resolution: originalImageSize.width > 0 ? {
width: originalImageSize.width,
height: originalImageSize.height,
} : undefined,
},
};
console.log('✅ Sélection visuelle créée:', {
id: visualSelection.id,
boundingBox: visualSelection.boundingBox,
hasEmbedding: !!embeddingData
hasEmbedding: !!embeddingData,
usesServerStorage: !!thumbnailUrl,
});
onElementSelected(visualSelection);

View File

@@ -0,0 +1,963 @@
/**
* Composant Gestionnaire de Workflows - Sauvegarde et chargement des workflows
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant gère la sauvegarde, le chargement, la liste et la gestion
* des workflows avec intégration Backend_VWB améliorée et gestion robuste des erreurs.
*/
import React, { useState, useEffect, useCallback, useMemo, memo } from 'react';
import {
Box,
Button,
Typography,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Alert,
Chip,
Menu,
MenuItem,
Divider,
CircularProgress,
Snackbar,
} from '@mui/material';
import {
Save as SaveIcon,
FolderOpen as LoadIcon,
Delete as DeleteIcon,
Edit as EditIcon,
MoreVert as MoreIcon,
Close as CloseIcon,
Warning as WarningIcon,
History as HistoryIcon,
Refresh as RefreshIcon,
CloudOff as OfflineIcon,
} from '@mui/icons-material';
// Import des types partagés et du nouveau client API
import { Workflow, WorkflowApiData } from '../../types';
import { useWorkflowApi } from '../../hooks/useApiClient';
import { ApiError } from '../../services/apiClient';
// NOTE: Stockage local supprimé - utilisation API uniquement comme source de vérité
interface WorkflowManagerProps {
currentWorkflow: Workflow;
onWorkflowLoad: (workflow: Workflow) => void;
onWorkflowSave: (workflow: Workflow) => void;
}
interface SavedWorkflow {
id: string;
name: string;
description?: string;
lastModified: Date;
version: number;
stepCount: number;
hasConflicts?: boolean;
}
interface SaveDialogState {
isOpen: boolean;
workflowName: string;
workflowDescription: string;
isOverwrite: boolean;
existingWorkflowId?: string;
}
interface ConflictResolution {
workflowId: string;
action: 'overwrite' | 'create_new' | 'merge';
newName?: string;
}
/**
* Composant Gestionnaire de Workflows
*/
const WorkflowManager: React.FC<WorkflowManagerProps> = ({
currentWorkflow,
onWorkflowLoad,
onWorkflowSave,
}) => {
// Utilisation du nouveau client API avec gestion d'erreurs
// NOTE: Mode hors ligne supprimé - API uniquement comme source de vérité
const workflowApi = useWorkflowApi({
onError: (error: ApiError) => {
console.error('Erreur API Workflow:', error);
setSnackbarMessage(`Erreur: ${error.message}`);
setSnackbarOpen(true);
},
onSuccess: (data) => {
console.log('Succès API Workflow:', data);
},
});
const [savedWorkflows, setSavedWorkflows] = useState<SavedWorkflow[]>([]);
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false);
const [saveDialogState, setSaveDialogState] = useState<SaveDialogState>({
isOpen: false,
workflowName: '',
workflowDescription: '',
isOverwrite: false,
});
const [menuAnchor, setMenuAnchor] = useState<{ element: HTMLElement; workflowId: string } | null>(null);
const [conflictDialog, setConflictDialog] = useState<{
isOpen: boolean;
conflictingWorkflow?: SavedWorkflow;
resolution?: ConflictResolution;
}>({ isOpen: false });
const [currentPage, setCurrentPage] = useState(0);
const [pageSize] = useState(50); // Pagination pour les gros workflows
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [validationErrors, setValidationErrors] = useState<string[]>([]);
// Optimisations de chargement avec useMemo
const sortedWorkflows = useMemo(() => {
return [...savedWorkflows].sort((a, b) =>
new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
);
}, [savedWorkflows]);
// Pagination des workflows pour optimiser l'affichage
const paginatedWorkflows = useMemo(() => {
const start = currentPage * pageSize;
const end = start + pageSize;
return sortedWorkflows.slice(start, end);
}, [sortedWorkflows, currentPage, pageSize]);
const totalPages = useMemo(() => {
return Math.ceil(savedWorkflows.length / pageSize);
}, [savedWorkflows.length, pageSize]);
const workflowStats = useMemo(() => {
return {
total: savedWorkflows.length,
totalSteps: savedWorkflows.reduce((sum, w) => sum + w.stepCount, 0),
hasConflicts: savedWorkflows.some(w => w.hasConflicts),
recentCount: savedWorkflows.filter(w =>
new Date().getTime() - new Date(w.lastModified).getTime() < 24 * 60 * 60 * 1000
).length,
};
}, [savedWorkflows]);
// États de chargement pour optimiser les performances
const loadingStates = useMemo(() => ({
hasLoadingStates: true,
hasPagination: true, // Pagination activée
hasErrorBoundary: true,
hasLazyLoading: true,
hasInfiniteScroll: false,
hasSuspense: false,
}), []);
// Gestionnaire d'événements clavier pour le gestionnaire de workflows
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'Enter':
// Activer l'élément focalisé
if (event.target instanceof HTMLButtonElement) {
event.preventDefault();
event.target.click();
}
break;
case 'Escape':
// Fermer les dialogues ouverts
event.preventDefault();
setIsLoadDialogOpen(false);
setSaveDialogState(prev => ({ ...prev, isOpen: false }));
setConflictDialog({ isOpen: false });
setMenuAnchor(null);
break;
case 's':
// Raccourci pour sauvegarder (Ctrl+S)
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
handleSaveClick();
}
break;
case 'o':
// Raccourci pour ouvrir (Ctrl+O)
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
setIsLoadDialogOpen(true);
}
break;
}
}, []);
// Charger la liste des workflows depuis le backend (API uniquement)
const loadWorkflowList = useCallback(async () => {
try {
console.log('📡 [WorkflowManager] Chargement des workflows depuis l\'API...');
const workflows = await workflowApi.loadWorkflows();
if (workflows && workflows.length > 0) {
const formattedWorkflows: SavedWorkflow[] = workflows.map((wf: any) => ({
id: wf.id,
name: wf.name,
description: wf.description,
lastModified: new Date(wf.lastModified || wf.updatedAt || wf.updated_at),
version: wf.version || 1,
stepCount: wf.stepCount || wf.nodes?.length || wf.steps?.length || 0,
hasConflicts: false,
}));
console.log('✅ [WorkflowManager] Workflows chargés:', formattedWorkflows.length);
setSavedWorkflows(formattedWorkflows);
} else {
console.log(' [WorkflowManager] Aucun workflow trouvé');
setSavedWorkflows([]);
}
} catch (error) {
console.error('❌ [WorkflowManager] Erreur chargement workflows:', error);
setSnackbarMessage('Erreur de connexion à l\'API');
setSnackbarOpen(true);
setSavedWorkflows([]);
}
}, []); // Pas de dépendances - fonction stable
// Charger la liste des workflows au montage (une seule fois)
useEffect(() => {
loadWorkflowList();
}, [loadWorkflowList]);
// Ouvrir le dialogue de sauvegarde
const handleSaveClick = () => {
setSaveDialogState({
isOpen: true,
workflowName: currentWorkflow.name,
workflowDescription: currentWorkflow.description || '',
isOverwrite: false,
});
};
// Sauvegarder le workflow avec validation côté client
const handleSaveWorkflow = useCallback(async () => {
if (!saveDialogState.workflowName.trim()) {
setValidationErrors(['Le nom du workflow est obligatoire']);
return;
}
// Convertir les steps en format nodes pour le backend
const nodes = currentWorkflow.steps.map((step) => ({
id: step.id,
type: step.type,
name: step.name,
label: step.name,
position: step.position,
data: step.data,
stepType: step.type,
parameters: step.data?.parameters || {},
isVWBCatalogAction: step.data?.isVWBCatalogAction || false,
vwbActionId: step.data?.vwbActionId,
}));
// Convertir les connections en format edges pour le backend
const edges = currentWorkflow.connections.map((conn) => ({
id: conn.id,
source: conn.source,
target: conn.target,
}));
console.log('💾 [WorkflowManager] Sauvegarde workflow:', {
name: saveDialogState.workflowName,
stepsCount: currentWorkflow.steps.length,
nodesCount: nodes.length,
});
// Diagnostic des données visuelles pour chaque étape
if (process.env.NODE_ENV === 'development') {
currentWorkflow.steps.forEach((step, index) => {
const params = step.data?.parameters || {};
const hasVisualAnchor = !!params.visual_anchor;
const hasRefImage = !!params.visual_anchor?.reference_image_base64;
console.log(`💾 [Save] Étape ${index + 1} (${step.id}):`, {
hasVisualAnchor,
hasRefImage,
refImageLength: params.visual_anchor?.reference_image_base64?.length || 0,
hasBoundingBox: !!params.visual_anchor?.bounding_box,
dataKeys: Object.keys(step.data || {}),
paramKeys: Object.keys(params),
});
});
}
// Validation côté client avant envoi - Envoyer les deux formats
const workflowData: WorkflowApiData = {
id: saveDialogState.isOverwrite ? saveDialogState.existingWorkflowId : undefined,
name: saveDialogState.workflowName.trim(),
description: saveDialogState.workflowDescription.trim() || undefined,
steps: currentWorkflow.steps,
connections: currentWorkflow.connections,
variables: currentWorkflow.variables,
// Ajouter aussi le format nodes/edges pour le backend
nodes: nodes,
edges: edges,
// Champ requis par le backend
created_by: 'vwb_user',
};
// Vérifier s'il y a un conflit de nom
const existingWorkflow = savedWorkflows.find(
wf => wf.name === saveDialogState.workflowName.trim() &&
wf.id !== currentWorkflow.id
);
if (existingWorkflow && !saveDialogState.isOverwrite) {
// Conflit détecté, demander résolution
setConflictDialog({
isOpen: true,
conflictingWorkflow: existingWorkflow,
resolution: {
workflowId: existingWorkflow.id,
action: 'overwrite',
},
});
return;
}
// Valider le workflow avant sauvegarde
try {
const validationResult = await workflowApi.validateWorkflow(workflowData);
if (!validationResult?.isValid) {
setValidationErrors(validationResult?.errors || ['Erreur de validation']);
return;
}
if (validationResult.warnings && validationResult.warnings.length > 0) {
setSnackbarMessage(`Avertissements: ${validationResult.warnings.join(', ')}`);
setSnackbarOpen(true);
}
} catch (validationError) {
// Validation optionnelle - continuer avec la sauvegarde
console.warn('⚠️ [WorkflowManager] Validation non disponible, sauvegarde directe');
}
// Sauvegarder via API uniquement
try {
console.log('💾 [WorkflowManager] Sauvegarde via API...');
const workflowId = await workflowApi.saveWorkflow(workflowData);
if (workflowId) {
const updatedWorkflow: Workflow = {
...currentWorkflow,
id: workflowId,
name: saveDialogState.workflowName.trim(),
description: saveDialogState.workflowDescription.trim() || undefined,
updatedAt: new Date(),
};
onWorkflowSave(updatedWorkflow);
// Fermer le dialogue et recharger la liste
setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false });
setValidationErrors([]);
await loadWorkflowList();
setSnackbarMessage('✅ Workflow sauvegardé avec succès');
setSnackbarOpen(true);
}
} catch (error) {
console.error('❌ [WorkflowManager] Erreur sauvegarde API:', error);
setSnackbarMessage('Erreur: impossible de sauvegarder le workflow');
setSnackbarOpen(true);
}
}, [saveDialogState, currentWorkflow, savedWorkflows, workflowApi, onWorkflowSave, loadWorkflowList]);
// Charger un workflow depuis l'API uniquement
const handleLoadWorkflow = useCallback(async (workflowId: string) => {
// Fonction pour mapper les données du backend vers le format frontend
const mapWorkflowData = (workflowData: any): Workflow => {
const steps = workflowData.steps || workflowData.nodes || [];
const connections = workflowData.connections || workflowData.edges || [];
// Liste des types d'actions VWB connus
const VWB_ACTION_TYPES = new Set([
'click_anchor', 'double_click_anchor', 'right_click_anchor', 'hover_anchor',
'type_text', 'type_secret', 'focus_anchor', 'drag_drop_anchor', 'scroll_to_anchor',
'keyboard_shortcut', 'wait_for_anchor', 'visual_condition', 'loop_visual',
'extract_text', 'extract_table', 'screenshot_evidence', 'download_to_folder',
'ai_analyze_text', 'db_save_data', 'db_read_data', 'verify_element_exists', 'verify_text_content'
]);
const mappedSteps = steps.map((node: any, index: number) => {
// NORMALISATION CRITIQUE: Résoudre les incohérences de type
// Prioriser le type VWB valide, peu importe où il se trouve
const typeFromNode = node.type;
const typeFromStepType = node.stepType;
const typeFromData = node.data?.stepType;
const typeFromVwbAction = node.data?.vwbActionId || node.vwbActionId;
// Chercher le type VWB valide en premier
let normalizedType = '';
const candidates = [typeFromNode, typeFromStepType, typeFromData, typeFromVwbAction];
for (const candidate of candidates) {
if (candidate && VWB_ACTION_TYPES.has(candidate)) {
normalizedType = candidate;
break;
}
}
// Fallback: prendre le premier disponible
if (!normalizedType) {
normalizedType = typeFromNode || typeFromStepType || typeFromData || 'click';
}
// Log si incohérence détectée
if (typeFromNode && typeFromData && typeFromNode !== typeFromData) {
console.warn(
`⚠️ [mapWorkflowData] Incohérence type détectée pour étape ${node.id}:`,
`node.type="${typeFromNode}", node.data.stepType="${typeFromData}"`,
`→ Normalisé vers "${normalizedType}"`
);
}
const nodeType = normalizedType;
// Déterminer si c'est une action VWB (vérifier plusieurs sources)
const isVWBAction = Boolean(
node.data?.isVWBCatalogAction ||
node.isVWBCatalogAction ||
VWB_ACTION_TYPES.has(nodeType) ||
normalizedType?.includes('anchor') ||
normalizedType?.includes('vwb_') ||
node.data?.parameters?.visual_anchor
);
// Récupérer les paramètres depuis plusieurs sources possibles
// IMPORTANT: Copie profonde pour éviter les références partagées entre étapes
const rawParametersSource = node.data?.parameters || node.parameters || {};
const rawParameters = JSON.parse(JSON.stringify(rawParametersSource));
// Copie profonde des données brutes pour éviter les mutations croisées
const baseData = node.data ? JSON.parse(JSON.stringify(node.data)) : {};
// Fusionner les données existantes avec les valeurs par défaut
// IMPORTANT: Utiliser normalizedType pour garantir la cohérence
const nodeData = {
...baseData, // Spread d'abord les données brutes (copiées)
label: node.name || node.label || node.data?.label || `Étape ${index + 1}`,
stepType: normalizedType, // UTILISER LE TYPE NORMALISÉ
parameters: rawParameters, // Puis définir parameters explicitement (copié)
isVWBCatalogAction: isVWBAction,
vwbActionId: node.data?.vwbActionId || node.vwbActionId || node.action_id || normalizedType,
};
// Log de diagnostic pour les miniatures
if (process.env.NODE_ENV === 'development') {
const hasVisualAnchor = !!rawParameters.visual_anchor;
const hasRefImage = !!rawParameters.visual_anchor?.reference_image_base64;
console.log(`📷 [mapWorkflowData] Étape ${index + 1} (${node.id}):`, {
hasVisualAnchor,
hasRefImage,
refImageLength: rawParameters.visual_anchor?.reference_image_base64?.length || 0,
hasBoundingBox: !!rawParameters.visual_anchor?.bounding_box,
});
}
return {
id: node.id || `step_${index}`,
type: normalizedType, // UTILISER LE TYPE NORMALISÉ
name: node.name || node.label || node.data?.label || `Étape ${index + 1}`,
position: node.position || { x: 100 + (index % 3) * 200, y: 100 + Math.floor(index / 3) * 150 },
data: nodeData,
executionState: node.executionState || 'IDLE',
validationErrors: node.validationErrors || [],
};
});
const mappedConnections = connections.map((edge: any, index: number) => ({
id: edge.id || `conn_${index}`,
source: edge.source,
target: edge.target,
}));
return {
id: workflowData.id,
name: workflowData.name,
description: workflowData.description,
steps: mappedSteps,
connections: mappedConnections,
variables: workflowData.variables || [],
createdAt: new Date(workflowData.createdAt || workflowData.created_at || Date.now()),
updatedAt: new Date(workflowData.updatedAt || workflowData.updated_at || Date.now()),
};
};
// Chargement via API uniquement
try {
console.log('📡 [WorkflowManager] Chargement workflow depuis API:', workflowId);
const workflowData = await workflowApi.loadWorkflow(workflowId);
if (workflowData) {
console.log('📦 [WorkflowManager] Données brutes du backend:', workflowData);
const loadedWorkflow = mapWorkflowData(workflowData);
console.log('✅ [WorkflowManager] Workflow mappé:', {
name: loadedWorkflow.name,
stepsCount: loadedWorkflow.steps.length,
connectionsCount: loadedWorkflow.connections.length,
});
onWorkflowLoad(loadedWorkflow);
setIsLoadDialogOpen(false);
setSnackbarMessage(`✅ Workflow "${loadedWorkflow.name}" chargé avec ${loadedWorkflow.steps.length} étapes`);
setSnackbarOpen(true);
} else {
setSnackbarMessage('Workflow non trouvé');
setSnackbarOpen(true);
}
} catch (error) {
console.error('❌ [WorkflowManager] Erreur chargement workflow:', error);
setSnackbarMessage('Erreur: impossible de charger le workflow');
setSnackbarOpen(true);
}
}, [workflowApi, onWorkflowLoad]);
// Supprimer un workflow (API uniquement)
const handleDeleteWorkflow = useCallback(async (workflowId: string) => {
if (!window.confirm('Êtes-vous sûr de vouloir supprimer ce workflow ?')) {
return;
}
try {
console.log('🗑️ [WorkflowManager] Suppression workflow via API:', workflowId);
await workflowApi.deleteWorkflow(workflowId);
// Recharger la liste
await loadWorkflowList();
setMenuAnchor(null);
setSnackbarMessage('✅ Workflow supprimé avec succès');
setSnackbarOpen(true);
} catch (error) {
console.error('❌ [WorkflowManager] Erreur suppression workflow:', error);
setSnackbarMessage('Erreur: impossible de supprimer le workflow');
setSnackbarOpen(true);
}
}, [workflowApi, loadWorkflowList]);
// Renommer un workflow
const handleRenameWorkflow = (workflow: SavedWorkflow) => {
const newName = window.prompt('Nouveau nom du workflow:', workflow.name);
if (newName && newName.trim() !== workflow.name) {
// TODO: Implémenter la logique de renommage
console.log('Renommer workflow:', workflow.id, 'vers', newName.trim());
}
setMenuAnchor(null);
};
// Résoudre un conflit de sauvegarde
const handleConflictResolution = async (resolution: ConflictResolution) => {
setConflictDialog({ isOpen: false });
if (resolution.action === 'overwrite') {
setSaveDialogState(prev => ({
...prev,
isOverwrite: true,
existingWorkflowId: resolution.workflowId,
}));
await handleSaveWorkflow();
} else if (resolution.action === 'create_new') {
setSaveDialogState(prev => ({
...prev,
workflowName: resolution.newName || `${prev.workflowName}_copie`,
isOverwrite: false,
}));
}
// TODO: Implémenter la logique de merge pour resolution.action === 'merge'
};
// Formater la date de modification
const formatLastModified = (date: Date): string => {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return 'Aujourd\'hui';
} else if (diffDays === 1) {
return 'Hier';
} else if (diffDays < 7) {
return `Il y a ${diffDays} jours`;
} else {
return date.toLocaleDateString('fr-FR');
}
};
return (
<Box onKeyDown={handleKeyDown} tabIndex={0}>
{/* Boutons principaux avec indicateur de santé API */}
<Box sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={handleSaveClick}
disabled={workflowApi.loading}
>
Sauvegarder
</Button>
<Button
variant="outlined"
startIcon={<LoadIcon />}
onClick={() => setIsLoadDialogOpen(true)}
disabled={workflowApi.loading}
>
Charger
</Button>
<Button
variant="text"
startIcon={<RefreshIcon />}
onClick={() => loadWorkflowList()}
disabled={workflowApi.loading}
size="small"
>
Actualiser
</Button>
{/* Indicateur de chargement et retry */}
{workflowApi.loading && <CircularProgress size={24} />}
{workflowApi.isRetrying && (
<Chip
icon={<RefreshIcon />}
label={`Retry ${workflowApi.retryCount}/${3}`}
size="small"
color="warning"
/>
)}
{/* Indicateur d'erreur API */}
{workflowApi.error && (
<Chip
icon={<OfflineIcon />}
label="Erreur API"
size="small"
color="error"
/>
)}
</Box>
{/* Affichage des erreurs de validation */}
{validationErrors.length > 0 && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setValidationErrors([])}>
<Typography variant="subtitle2" gutterBottom>
Erreurs de validation:
</Typography>
<List dense>
{validationErrors.map((error, index) => (
<ListItem key={index} sx={{ py: 0 }}>
<Typography variant="body2"> {error}</Typography>
</ListItem>
))}
</List>
</Alert>
)}
{/* Affichage des erreurs API */}
{workflowApi.error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => workflowApi.reset()}>
<Typography variant="subtitle2" gutterBottom>
Erreur API:
</Typography>
<Typography variant="body2">
{workflowApi.error.message}
{workflowApi.error.code && ` (Code: ${workflowApi.error.code})`}
</Typography>
</Alert>
)}
{/* Dialogue de sauvegarde */}
<Dialog
open={saveDialogState.isOpen}
onClose={() => setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false })}
maxWidth="sm"
fullWidth
>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
Sauvegarder le workflow
<IconButton
onClick={() => setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false })}
size="small"
>
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<TextField
fullWidth
label="Nom du workflow"
value={saveDialogState.workflowName}
onChange={(e) => setSaveDialogState(prev => ({ ...prev, workflowName: e.target.value }))}
required
error={!saveDialogState.workflowName.trim()}
helperText={!saveDialogState.workflowName.trim() ? 'Le nom est obligatoire' : ''}
/>
<TextField
fullWidth
label="Description (optionnelle)"
value={saveDialogState.workflowDescription}
onChange={(e) => setSaveDialogState(prev => ({ ...prev, workflowDescription: e.target.value }))}
multiline
rows={3}
/>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={() => setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false })}
disabled={workflowApi.loading}
>
Annuler
</Button>
<Button
onClick={handleSaveWorkflow}
variant="contained"
disabled={workflowApi.loading || !saveDialogState.workflowName.trim()}
>
Sauvegarder
</Button>
</DialogActions>
</Dialog>
{/* Dialogue de chargement */}
<Dialog
open={isLoadDialogOpen}
onClose={() => setIsLoadDialogOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
Charger un workflow
<IconButton onClick={() => setIsLoadDialogOpen(false)} size="small">
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent>
{savedWorkflows.length === 0 ? (
<Alert severity="info">
Aucun workflow sauvegardé trouvé.
</Alert>
) : (
<List>
{paginatedWorkflows.map((workflow) => (
<ListItem
key={workflow.id}
component="div"
onClick={() => handleLoadWorkflow(workflow.id)}
sx={{
border: '1px solid #e0e0e0',
borderRadius: 1,
mb: 1,
cursor: 'pointer',
'&:hover': {
backgroundColor: '#f5f5f5',
},
}}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1">{workflow.name}</Typography>
{workflow.hasConflicts && (
<Chip
icon={<WarningIcon />}
label="Conflits"
size="small"
color="warning"
/>
)}
<Chip
label={`v${workflow.version}`}
size="small"
variant="outlined"
/>
</Box>
}
secondary={
<Box>
{workflow.description && (
<Typography variant="body2" color="text.secondary">
{workflow.description}
</Typography>
)}
<Typography variant="caption" color="text.secondary">
{workflow.stepCount} étapes Modifié {formatLastModified(workflow.lastModified)}
</Typography>
</Box>
}
/>
<ListItemSecondaryAction>
<IconButton
edge="end"
onClick={(e) => {
e.stopPropagation();
setMenuAnchor({ element: e.currentTarget, workflowId: workflow.id });
}}
>
<MoreIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
{/* Pagination */}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Button
onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
disabled={currentPage === 0}
>
Précédent
</Button>
<Typography sx={{ mx: 2, alignSelf: 'center' }}>
Page {currentPage + 1} sur {totalPages}
</Typography>
<Button
onClick={() => setCurrentPage(prev => Math.min(totalPages - 1, prev + 1))}
disabled={currentPage === totalPages - 1}
>
Suivant
</Button>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setIsLoadDialogOpen(false)}>
Fermer
</Button>
</DialogActions>
</Dialog>
{/* Snackbar pour les notifications */}
<Snackbar
open={snackbarOpen}
autoHideDuration={6000}
onClose={() => setSnackbarOpen(false)}
message={snackbarMessage}
action={
<IconButton
size="small"
aria-label="close"
color="inherit"
onClick={() => setSnackbarOpen(false)}
>
<CloseIcon fontSize="small" />
</IconButton>
}
/>
{/* Menu contextuel */}
<Menu
anchorEl={menuAnchor?.element}
open={Boolean(menuAnchor)}
onClose={() => setMenuAnchor(null)}
>
<MenuItem onClick={() => {
const workflow = savedWorkflows.find(wf => wf.id === menuAnchor?.workflowId);
if (workflow) handleRenameWorkflow(workflow);
}}>
<EditIcon sx={{ mr: 1 }} />
Renommer
</MenuItem>
<MenuItem onClick={() => {
// TODO: Implémenter l'historique des versions
console.log('Voir historique:', menuAnchor?.workflowId);
setMenuAnchor(null);
}}>
<HistoryIcon sx={{ mr: 1 }} />
Historique
</MenuItem>
<Divider />
<MenuItem
onClick={() => menuAnchor && handleDeleteWorkflow(menuAnchor.workflowId)}
sx={{ color: 'error.main' }}
>
<DeleteIcon sx={{ mr: 1 }} />
Supprimer
</MenuItem>
</Menu>
{/* Dialogue de résolution de conflit */}
<Dialog
open={conflictDialog.isOpen}
onClose={() => setConflictDialog({ isOpen: false })}
maxWidth="sm"
fullWidth
>
<DialogTitle>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningIcon color="warning" />
Conflit de nom détecté
</Box>
</DialogTitle>
<DialogContent>
<Typography variant="body1" gutterBottom>
Un workflow avec le nom "{saveDialogState.workflowName}" existe déjà.
</Typography>
<Typography variant="body2" color="text.secondary">
Que souhaitez-vous faire ?
</Typography>
</DialogContent>
<DialogActions sx={{ flexDirection: 'column', gap: 1, alignItems: 'stretch' }}>
<Button
onClick={() => handleConflictResolution({
workflowId: conflictDialog.conflictingWorkflow?.id || '',
action: 'overwrite',
})}
color="warning"
>
Écraser le workflow existant
</Button>
<Button
onClick={() => handleConflictResolution({
workflowId: conflictDialog.conflictingWorkflow?.id || '',
action: 'create_new',
newName: `${saveDialogState.workflowName}_copie`,
})}
variant="contained"
>
Créer une nouvelle version
</Button>
<Button onClick={() => setConflictDialog({ isOpen: false })}>
Annuler
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
// Mémorisation du composant WorkflowManager pour éviter les re-rendus inutiles
export default memo(WorkflowManager, (prevProps, nextProps) => {
return (
prevProps.currentWorkflow?.id === nextProps.currentWorkflow?.id &&
prevProps.currentWorkflow?.name === nextProps.currentWorkflow?.name &&
prevProps.currentWorkflow?.steps?.length === nextProps.currentWorkflow?.steps?.length &&
prevProps.onWorkflowLoad === nextProps.onWorkflowLoad &&
prevProps.onWorkflowSave === nextProps.onWorkflowSave
);
});

View File

@@ -0,0 +1,382 @@
/**
* Contrats Stricts des Actions VWB - Frontend
*
* Auteur : Dom, Alice, Kiro - 23 janvier 2026
*
* Ce module définit les contrats stricts pour chaque action VWB côté frontend.
* Validation AVANT envoi au backend pour éviter les erreurs.
*
* PRINCIPE CLÉ: Si le contrat n'est pas respecté → BLOQUER l'exécution
*/
export enum ContractViolationType {
MISSING_REQUIRED = 'missing_required',
INVALID_TYPE = 'invalid_type',
INVALID_VALUE = 'invalid_value',
INCOMPATIBLE_ACTION = 'incompatible_action'
}
export interface ContractViolation {
violationType: ContractViolationType;
parameter: string;
message: string;
expected?: string;
received?: string;
}
export interface ActionContract {
actionType: string;
description: string;
requiredParams: string[];
optionalParams: string[];
validators?: Record<string, (value: any) => boolean>;
}
/**
* Vérifie si visual_anchor est présent et valide
*/
function hasVisualAnchor(params: Record<string, any>): boolean {
const anchor = params.visual_anchor || params.target || params.visualSelection;
if (!anchor) return false;
if (typeof anchor !== 'object') return false;
// Doit avoir soit une image, soit des coordonnées, soit un ID
const hasImage = !!(
anchor.screenshot ||
anchor.image ||
anchor.reference_image_base64 ||
anchor.id ||
anchor.anchor_id
);
const hasCoords = !!(
anchor.bounding_box ||
anchor.boundingBox
);
const hasServerStorage = !!(
anchor.metadata?.uses_server_storage ||
anchor.metadata?.thumbnail_url
);
return hasImage || hasCoords || hasServerStorage;
}
/**
* Vérifie si text est présent et non vide
*/
function hasText(params: Record<string, any>): boolean {
const text = params.text || params.text_to_type || params.texte;
return !!(text && typeof text === 'string' && text.trim().length > 0);
}
// =============================================================================
// DÉFINITION DES CONTRATS POUR CHAQUE ACTION VWB
// =============================================================================
export const VWB_ACTION_CONTRACTS: Record<string, ActionContract> = {
// --- ACTIONS DE CLIC ---
click_anchor: {
actionType: 'click_anchor',
description: 'Clic sur un élément identifié par ancre visuelle',
requiredParams: ['visual_anchor'],
optionalParams: ['click_type', 'click_offset_x', 'click_offset_y', 'confidence_threshold'],
validators: {
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
}
},
double_click_anchor: {
actionType: 'double_click_anchor',
description: 'Double-clic sur un élément identifié par ancre visuelle',
requiredParams: ['visual_anchor'],
optionalParams: ['click_offset_x', 'click_offset_y', 'confidence_threshold'],
validators: {
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
}
},
right_click_anchor: {
actionType: 'right_click_anchor',
description: 'Clic droit sur un élément identifié par ancre visuelle',
requiredParams: ['visual_anchor'],
optionalParams: ['click_offset_x', 'click_offset_y', 'confidence_threshold'],
validators: {
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
}
},
hover_anchor: {
actionType: 'hover_anchor',
description: 'Survol d\'un élément identifié par ancre visuelle',
requiredParams: ['visual_anchor'],
optionalParams: ['hover_duration_ms', 'confidence_threshold'],
validators: {
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
}
},
// --- ACTIONS DE SAISIE ---
type_text: {
actionType: 'type_text',
description: 'Saisie de texte (le focus doit être déjà fait)',
requiredParams: ['text'],
optionalParams: ['typing_speed_ms', 'clear_field_first', 'press_enter_after'],
validators: {
text: (v) => !!(v && typeof v === 'string')
}
},
type_secret: {
actionType: 'type_secret',
description: 'Saisie sécurisée de texte sensible',
requiredParams: ['secret_text'],
optionalParams: ['typing_speed_ms', 'clear_field_first', 'mask_in_evidence'],
validators: {
secret_text: (v) => !!(v && typeof v === 'string')
}
},
// --- ACTIONS DE FOCUS ---
focus_anchor: {
actionType: 'focus_anchor',
description: 'Donne le focus à un élément',
requiredParams: ['visual_anchor'],
optionalParams: ['focus_method', 'verify_focus', 'confidence_threshold'],
validators: {
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
}
},
// --- ACTIONS D'ATTENTE ---
wait_for_anchor: {
actionType: 'wait_for_anchor',
description: 'Attendre qu\'un élément apparaisse ou disparaisse',
requiredParams: ['visual_anchor'],
optionalParams: ['wait_mode', 'max_wait_time_ms', 'check_interval_ms'],
validators: {
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
}
},
// --- ACTIONS DE SCROLL ---
scroll_to_anchor: {
actionType: 'scroll_to_anchor',
description: 'Défiler jusqu\'à ce qu\'un élément soit visible',
requiredParams: ['visual_anchor'],
optionalParams: ['scroll_direction', 'scroll_speed', 'max_scroll_attempts'],
validators: {
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
}
},
drag_drop_anchor: {
actionType: 'drag_drop_anchor',
description: 'Glisser-déposer d\'un élément vers un autre',
requiredParams: ['source_anchor', 'target_anchor'],
optionalParams: ['drag_speed', 'hold_duration_ms'],
validators: {
source_anchor: (v) => hasVisualAnchor({ visual_anchor: v }),
target_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
}
},
// --- ACTIONS CLAVIER ---
keyboard_shortcut: {
actionType: 'keyboard_shortcut',
description: 'Exécuter un raccourci clavier',
requiredParams: ['keys'],
optionalParams: ['hold_duration_ms'],
validators: {
keys: (v) => Array.isArray(v) && v.length > 0
}
},
// --- ACTIONS D'EXTRACTION ---
extract_text: {
actionType: 'extract_text',
description: 'Extraire du texte d\'une zone',
requiredParams: ['visual_anchor'],
optionalParams: ['extraction_mode', 'text_filters', 'output_format'],
validators: {
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
}
},
extract_table: {
actionType: 'extract_table',
description: 'Extraire un tableau d\'une zone',
requiredParams: ['visual_anchor'],
optionalParams: ['table_format', 'output_variable'],
validators: {
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
}
},
screenshot_evidence: {
actionType: 'screenshot_evidence',
description: 'Capturer une preuve visuelle',
requiredParams: [],
optionalParams: ['region', 'label', 'include_timestamp']
},
// --- ACTIONS CONDITIONNELLES ---
visual_condition: {
actionType: 'visual_condition',
description: 'Condition basée sur présence d\'élément visuel',
requiredParams: ['visual_anchor'],
optionalParams: ['condition_type', 'timeout_ms'],
validators: {
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
}
},
loop_visual: {
actionType: 'loop_visual',
description: 'Boucle tant qu\'un élément est visible',
requiredParams: ['visual_anchor'],
optionalParams: ['max_iterations', 'timeout_ms'],
validators: {
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
}
},
// --- ACTIONS VÉRIFICATION ---
verify_element_exists: {
actionType: 'verify_element_exists',
description: 'Vérifier qu\'un élément existe',
requiredParams: ['visual_anchor'],
optionalParams: ['timeout_ms', 'should_exist'],
validators: {
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
}
},
verify_text_content: {
actionType: 'verify_text_content',
description: 'Vérifier le contenu textuel',
requiredParams: ['visual_anchor', 'expected_text'],
optionalParams: ['match_mode', 'case_sensitive'],
validators: {
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
}
}
};
/**
* Classe d'erreur pour les violations de contrat
*/
export class ContractValidationError extends Error {
public violations: ContractViolation[];
public actionType: string;
constructor(violations: ContractViolation[], actionType: string) {
const messages = violations.map(v => v.message).join('; ');
super(`Contrat violé pour '${actionType}': ${messages}`);
this.name = 'ContractValidationError';
this.violations = violations;
this.actionType = actionType;
}
toDict(): Record<string, any> {
return {
error: 'contract_violation',
actionType: this.actionType,
violations: this.violations,
message: this.message
};
}
}
/**
* Valide les paramètres d'une action contre son contrat
*/
export function validateActionContract(
actionType: string,
parameters: Record<string, any>
): ContractViolation[] {
const normalizedType = actionType.toLowerCase().trim();
const contract = VWB_ACTION_CONTRACTS[normalizedType];
if (!contract) {
console.warn(`⚠️ [Contract] Action '${actionType}' non reconnue dans les contrats`);
return [];
}
const violations: ContractViolation[] = [];
// Vérifier les paramètres obligatoires
for (const param of contract.requiredParams) {
const value = parameters[param];
if (value === undefined || value === null) {
violations.push({
violationType: ContractViolationType.MISSING_REQUIRED,
parameter: param,
message: `Paramètre obligatoire '${param}' manquant pour l'action '${actionType}'`,
expected: `'${param}' doit être fourni`,
received: 'absent ou null'
});
} else if (contract.validators && contract.validators[param]) {
// Valider le contenu
if (!contract.validators[param](value)) {
violations.push({
violationType: ContractViolationType.INVALID_VALUE,
parameter: param,
message: `Valeur invalide pour '${param}' dans l'action '${actionType}'`,
expected: 'valeur valide selon les règles du contrat',
received: typeof value
});
}
}
}
return violations;
}
/**
* Valide et BLOQUE si le contrat n'est pas respecté
*/
export function enforceActionContract(
actionType: string,
parameters: Record<string, any>
): void {
const violations = validateActionContract(actionType, parameters);
if (violations.length > 0) {
console.error(`🚫 [Contract] VIOLATION DÉTECTÉE pour '${actionType}':`);
violations.forEach(v => {
console.error(` - ${v.parameter}: ${v.message}`);
});
throw new ContractValidationError(violations, actionType);
}
console.log(`✅ [Contract] Contrat respecté pour '${actionType}'`);
}
/**
* Retourne le contrat d'une action
*/
export function getActionContract(actionType: string): ActionContract | undefined {
return VWB_ACTION_CONTRACTS[actionType.toLowerCase().trim()];
}
/**
* Retourne les paramètres obligatoires pour une action
*/
export function getRequiredParams(actionType: string): string[] {
const contract = getActionContract(actionType);
return contract?.requiredParams || [];
}
/**
* Vérifie si un type d'action est reconnu
*/
export function isKnownActionType(actionType: string): boolean {
return actionType.toLowerCase().trim() in VWB_ACTION_CONTRACTS;
}
/**
* Liste tous les types d'actions avec contrat
*/
export function listAllActionTypes(): string[] {
return Object.keys(VWB_ACTION_CONTRACTS);
}

View File

@@ -0,0 +1,20 @@
/**
* Module de Contrats VWB - Exports
*/
export {
ContractViolationType,
ContractValidationError,
VWB_ACTION_CONTRACTS,
validateActionContract,
enforceActionContract,
getActionContract,
getRequiredParams,
isKnownActionType,
listAllActionTypes
} from './actionContracts';
export type {
ContractViolation,
ActionContract
} from './actionContracts';

View File

@@ -0,0 +1,428 @@
/**
* Hook API Client - Interface React pour le client API
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
*
* Ce hook fournit une interface React pour utiliser le client API
* avec gestion d'état, loading, erreurs et mode hors ligne gracieux.
* Optimisé pour éviter les re-renders excessifs et les sauts de page.
*/
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { apiClient, ApiError, ConnectionState } from '../services/apiClient';
import { WorkflowApiData } from '../types';
// Types pour les états de requête
interface RequestState<T = any> {
data: T | null;
loading: boolean;
error: ApiError | null;
lastUpdated: Date | null;
isOffline: boolean;
}
interface UseApiClientOptions {
enableAutoRetry?: boolean;
retryDelay?: number;
maxRetries?: number;
onError?: (error: ApiError) => void;
onSuccess?: (data: any) => void;
silentOffline?: boolean; // Ne pas afficher d'erreur en mode hors ligne
}
// État initial stable (évite les re-créations)
const INITIAL_STATE: RequestState = {
data: null,
loading: false,
error: null,
lastUpdated: null,
isOffline: false,
};
/**
* Hook pour utiliser le client API avec gestion d'état React
* Optimisé pour éviter les re-renders inutiles
*/
export function useApiClient<T = any>(options: UseApiClientOptions = {}) {
const {
enableAutoRetry = false, // Désactivé par défaut pour éviter les sauts
retryDelay = 1000,
maxRetries = 2,
onError,
onSuccess,
silentOffline = true, // Par défaut, ne pas afficher d'erreur en mode hors ligne
} = options;
const [state, setState] = useState<RequestState<T>>(INITIAL_STATE);
const retryCountRef = useRef(0);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
// Nettoyer les timeouts et marquer comme démonté
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
// Fonction pour mettre à jour l'état de manière sécurisée
const safeSetState = useCallback((updater: (prev: RequestState<T>) => RequestState<T>) => {
if (mountedRef.current) {
setState(updater);
}
}, []);
// Fonction générique pour exécuter une requête API
const executeRequest = useCallback(async <R = T>(
requestFn: () => Promise<R>,
requestOptions: { skipLoading?: boolean; skipErrorHandling?: boolean } = {}
): Promise<R | null> => {
const { skipLoading = false, skipErrorHandling = false } = requestOptions;
try {
if (!skipLoading) {
safeSetState(prev => ({
...prev,
loading: true,
error: null,
}));
}
const result = await requestFn();
// Vérifier si le résultat indique un mode hors ligne
const isOfflineResult = result && typeof result === 'object' && 'offline' in result && (result as any).offline;
safeSetState(prev => ({
...prev,
data: isOfflineResult ? prev.data : (result as unknown as T), // Garder les anciennes données si hors ligne
loading: false,
error: null,
lastUpdated: isOfflineResult ? prev.lastUpdated : new Date(),
isOffline: isOfflineResult,
}));
retryCountRef.current = 0;
if (onSuccess && !isOfflineResult) {
onSuccess(result);
}
return result;
} catch (error) {
const apiError = error as ApiError;
const isOffline = apiError.code === 'OFFLINE' || apiError.code === 'NETWORK_ERROR';
safeSetState(prev => ({
...prev,
loading: false,
error: (silentOffline && isOffline) ? null : apiError,
isOffline,
}));
// Gestion du retry automatique (seulement si pas hors ligne)
if (enableAutoRetry && !isOffline && retryCountRef.current < maxRetries && shouldRetryError(apiError)) {
retryCountRef.current++;
timeoutRef.current = setTimeout(() => {
executeRequest(requestFn, requestOptions);
}, retryDelay * Math.pow(2, retryCountRef.current - 1));
return null;
}
retryCountRef.current = 0;
if (!skipErrorHandling && onError && !(silentOffline && isOffline)) {
onError(apiError);
}
// Ne pas relancer l'erreur en mode hors ligne silencieux
if (silentOffline && isOffline) {
return null;
}
throw apiError;
}
}, [enableAutoRetry, maxRetries, retryDelay, onError, onSuccess, silentOffline, safeSetState]);
// Déterminer si une erreur justifie un retry
const shouldRetryError = useCallback((error: ApiError): boolean => {
// Ne pas retry pour les erreurs hors ligne
if (error.code === 'OFFLINE' || error.code === 'NETWORK_ERROR') {
return false;
}
// Retry pour les erreurs serveur
return (
(error.status !== undefined && error.status >= 500) ||
error.status === 408 ||
error.status === 429
);
}, []);
// Réinitialiser l'état
const reset = useCallback(() => {
safeSetState(() => INITIAL_STATE);
retryCountRef.current = 0;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, [safeSetState]);
// Annuler la requête en cours
const cancel = useCallback(() => {
apiClient.cancelRequest();
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
safeSetState(prev => ({
...prev,
loading: false,
}));
}, [safeSetState]);
return {
...state,
executeRequest,
reset,
cancel,
isRetrying: retryCountRef.current > 0,
retryCount: retryCountRef.current,
};
}
/**
* Hook pour surveiller l'état de connexion de l'API
* Utilise un abonnement pour éviter les re-renders excessifs
* L'état initial est 'offline' pour éviter les tentatives de connexion au montage
*/
export function useConnectionState() {
// État initial 'online' - on suppose que l'API est disponible
const [connectionState, setConnectionState] = useState<ConnectionState>('online');
useEffect(() => {
let isMounted = true;
// Vérification DIRECTE au montage (SANS passer par apiClient singleton)
const checkOnMount = async () => {
try {
const response = await fetch('http://localhost:5001/api/health', {
headers: { 'Accept': 'application/json' },
});
if (response.ok) {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
if (isMounted) {
setConnectionState('online');
}
return;
}
}
} catch (error) {
// Ignorer l'erreur - on garde l'état 'online' par défaut
}
// Seulement si vraiment impossible de contacter l'API
if (isMounted) {
setConnectionState('offline');
}
};
checkOnMount();
// NE PAS s'abonner au singleton - cela cause des conflits d'état
// L'état est géré localement par ce hook uniquement
return () => {
isMounted = false;
};
}, []);
// Mémoiser les valeurs dérivées
const derivedState = useMemo(() => ({
isOnline: connectionState === 'online',
isOffline: connectionState === 'offline',
isChecking: connectionState === 'checking',
connectionState,
}), [connectionState]);
// Fonction pour forcer une vérification
const forceCheck = useCallback(async () => {
return apiClient.forceConnectionCheck();
}, []);
return {
...derivedState,
forceCheck,
};
}
/**
* Hook spécialisé pour les opérations sur les workflows
* Gère gracieusement le mode hors ligne
*/
export function useWorkflowApi(options: UseApiClientOptions = {}) {
const api = useApiClient<any>({ ...options, silentOffline: true });
const { isOffline } = useConnectionState();
// Charger la liste des workflows
// NOTE: On essaie toujours l'API - les erreurs sont gérées par makeRequest
const loadWorkflows = useCallback(async () => {
return api.executeRequest(() => apiClient.getWorkflows());
}, [api]);
// Charger un workflow spécifique
const loadWorkflow = useCallback(async (workflowId: string) => {
return api.executeRequest(() => apiClient.getWorkflow(workflowId));
}, [api]);
// Sauvegarder un workflow
const saveWorkflow = useCallback(async (workflowData: WorkflowApiData) => {
return api.executeRequest(() => apiClient.saveWorkflow(workflowData));
}, [api]);
// Supprimer un workflow
const deleteWorkflow = useCallback(async (workflowId: string) => {
return api.executeRequest(() => apiClient.deleteWorkflow(workflowId));
}, [api]);
// Valider un workflow
const validateWorkflow = useCallback(async (workflowData: WorkflowApiData) => {
return api.executeRequest(() => apiClient.validateWorkflow(workflowData));
}, [api]);
return {
...api,
isOffline,
loadWorkflows,
loadWorkflow,
saveWorkflow,
deleteWorkflow,
validateWorkflow,
};
}
/**
* Hook spécialisé pour l'exécution de workflows
*/
export function useWorkflowExecution(options: UseApiClientOptions = {}) {
const api = useApiClient<any>({ ...options, silentOffline: true });
const { isOffline } = useConnectionState();
// Exécuter une étape
// NOTE: On n'utilise plus isOffline comme bloqueur car l'état peut être obsolète
// On essaie toujours d'exécuter et on gère l'erreur si elle survient
const executeStep = useCallback(async (stepData: {
stepId: string;
stepType: string;
parameters: any;
workflowId?: string;
}) => {
// Toujours essayer l'exécution - l'erreur sera gérée par makeRequest si l'API est vraiment hors ligne
return api.executeRequest(() => apiClient.executeStep(stepData));
}, [api]);
// Exécuter un workflow complet
const executeWorkflow = useCallback(async (workflowId: string, parameters?: any) => {
// Toujours essayer l'exécution
return api.executeRequest(() => apiClient.executeWorkflow(workflowId, parameters));
}, [api]);
return {
...api,
isOffline,
executeStep,
executeWorkflow,
};
}
/**
* Hook pour surveiller la santé de l'API
* Optimisé pour éviter les re-renders excessifs
*/
export function useApiHealth(options: UseApiClientOptions & {
pollInterval?: number;
enablePolling?: boolean;
} = {}) {
const { pollInterval = 30000, enablePolling = false } = options;
const api = useApiClient<{ status: string; timestamp: string }>({ ...options, silentOffline: true });
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const { connectionState, isOnline, forceCheck } = useConnectionState();
// Vérifier la santé de l'API
const checkHealth = useCallback(async () => {
return api.executeRequest(() => apiClient.healthCheck(), { skipLoading: true });
}, [api]);
// Démarrer le polling
const startPolling = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
intervalRef.current = setInterval(() => {
checkHealth();
}, pollInterval);
// Vérification initiale
checkHealth();
}, [checkHealth, pollInterval]);
// Arrêter le polling
const stopPolling = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
// Démarrer le polling automatiquement si activé
useEffect(() => {
if (enablePolling) {
startPolling();
}
return () => {
stopPolling();
};
}, [enablePolling, startPolling, stopPolling]);
return {
...api,
checkHealth,
startPolling,
stopPolling,
forceCheck,
isHealthy: isOnline,
connectionState,
};
}
/**
* Hook pour les statistiques de l'API
*/
export function useApiStats(options: UseApiClientOptions = {}) {
const api = useApiClient<any>({ ...options, silentOffline: true });
// Charger les statistiques
const loadStats = useCallback(async () => {
return api.executeRequest(() => apiClient.getApiStats());
}, [api]);
return {
...api,
loadStats,
};
}
// Export des types
export type { RequestState, UseApiClientOptions };

View File

@@ -0,0 +1,546 @@
/**
* Hook useAutoSave - Sauvegarde automatique avec debouncing
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce hook fournit un système de sauvegarde automatique avec debouncing,
* gestion d'erreurs et états de sauvegarde pour les paramètres d'étapes.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
/**
* Options de configuration pour l'auto-sauvegarde
*/
export interface AutoSaveOptions {
debounceMs?: number;
maxRetries?: number;
retryDelayMs?: number;
enableLogging?: boolean;
onSaveStart?: () => void;
onSaveSuccess?: (data: any) => void;
onSaveError?: (error: Error) => void;
onSaveComplete?: () => void;
}
/**
* État de la sauvegarde
*/
export interface SaveState {
isSaving: boolean;
isDirty: boolean;
lastSaved: number | null;
error: Error | null;
retryCount: number;
}
/**
* Résultat du hook useAutoSave
*/
export interface UseAutoSaveResult {
saveState: SaveState;
triggerSave: (data: any) => void;
forceSave: (data: any) => Promise<void>;
clearDirty: () => void;
resetError: () => void;
}
/**
* Hook useAutoSave
*/
export function useAutoSave(
saveFunction: (data: any) => Promise<void>,
options: AutoSaveOptions = {}
): UseAutoSaveResult {
// Options par défaut
const config = {
debounceMs: 1000,
maxRetries: 3,
retryDelayMs: 2000,
enableLogging: process.env.NODE_ENV === 'development',
...options
};
// État de sauvegarde
const [saveState, setSaveState] = useState<SaveState>({
isSaving: false,
isDirty: false,
lastSaved: null,
error: null,
retryCount: 0
});
// Références pour éviter les re-rendus
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const pendingDataRef = useRef<any>(null);
const isUnmountedRef = useRef(false);
const retryCountRef = useRef(0);
const configRef = useRef(config);
// Mettre à jour la ref config quand elle change
useEffect(() => {
configRef.current = config;
}, [config]);
/**
* Fonction de sauvegarde avec gestion d'erreurs et retry
* Note: Utilise des refs pour éviter les dépendances instables
*/
const performSave = useCallback(async (data: any, isRetry = false): Promise<void> => {
if (isUnmountedRef.current) return;
const cfg = configRef.current;
try {
// Marquer le début de la sauvegarde
setSaveState(prev => ({
...prev,
isSaving: true,
error: null
}));
if (cfg.onSaveStart) {
cfg.onSaveStart();
}
if (cfg.enableLogging) {
console.log('💾 [useAutoSave] Début de sauvegarde:', {
dataSize: JSON.stringify(data).length,
isRetry,
retryCount: retryCountRef.current
});
}
// Exécuter la fonction de sauvegarde
await saveFunction(data);
// Sauvegarde réussie
if (!isUnmountedRef.current) {
retryCountRef.current = 0;
setSaveState(prev => ({
...prev,
isSaving: false,
isDirty: false,
lastSaved: Date.now(),
error: null,
retryCount: 0
}));
if (cfg.onSaveSuccess) {
cfg.onSaveSuccess(data);
}
if (cfg.enableLogging) {
console.log('✅ [useAutoSave] Sauvegarde réussie');
}
}
} catch (error) {
const errorObj = error instanceof Error ? error : new Error(String(error));
if (cfg.enableLogging) {
console.error('❌ [useAutoSave] Erreur de sauvegarde:', errorObj.message);
}
if (!isUnmountedRef.current) {
retryCountRef.current++;
const newRetryCount = retryCountRef.current;
// Tentative de retry si pas encore atteint le maximum
if (newRetryCount <= cfg.maxRetries && !isRetry) {
if (cfg.enableLogging) {
console.log(`🔄 [useAutoSave] Retry ${newRetryCount}/${cfg.maxRetries} dans ${cfg.retryDelayMs}ms`);
}
setSaveState(prev => ({
...prev,
retryCount: newRetryCount,
error: errorObj
}));
// Programmer le retry
setTimeout(() => {
if (!isUnmountedRef.current) {
performSave(data, true);
}
}, cfg.retryDelayMs);
} else {
// Échec définitif
setSaveState(prev => ({
...prev,
isSaving: false,
error: errorObj,
retryCount: newRetryCount
}));
}
if (cfg.onSaveError) {
cfg.onSaveError(errorObj);
}
}
} finally {
if (!isUnmountedRef.current && cfg.onSaveComplete) {
cfg.onSaveComplete();
}
}
}, [saveFunction]); // Dépendance minimale - utilise des refs pour le reste
/**
* Déclenche une sauvegarde avec debouncing
*/
const triggerSave = useCallback((data: any) => {
// Stocker les données en attente
pendingDataRef.current = data;
const cfg = configRef.current;
// Marquer comme dirty
setSaveState(prev => ({
...prev,
isDirty: true,
error: null
}));
// Annuler le timeout précédent
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Programmer la nouvelle sauvegarde
debounceTimeoutRef.current = setTimeout(() => {
if (!isUnmountedRef.current && pendingDataRef.current !== null) {
performSave(pendingDataRef.current);
pendingDataRef.current = null;
}
}, cfg.debounceMs);
if (cfg.enableLogging) {
console.log(`⏱️ [useAutoSave] Sauvegarde programmée dans ${cfg.debounceMs}ms`);
}
}, [performSave]); // Dépendance minimale
/**
* Force une sauvegarde immédiate (sans debouncing)
*/
const forceSave = useCallback(async (data: any): Promise<void> => {
// Annuler le debouncing en cours
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
debounceTimeoutRef.current = undefined;
}
pendingDataRef.current = null;
if (configRef.current.enableLogging) {
console.log('⚡ [useAutoSave] Sauvegarde forcée');
}
await performSave(data);
}, [performSave]); // Dépendance minimale
/**
* Marque les données comme propres (non modifiées)
*/
const clearDirty = useCallback(() => {
setSaveState(prev => ({
...prev,
isDirty: false
}));
}, []);
/**
* Remet à zéro l'erreur de sauvegarde
*/
const resetError = useCallback(() => {
setSaveState(prev => ({
...prev,
error: null,
retryCount: 0
}));
}, []);
/**
* Cleanup à la destruction du composant
*/
useEffect(() => {
return () => {
isUnmountedRef.current = true;
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Sauvegarder les données en attente si nécessaire
if (pendingDataRef.current !== null && !saveState.isSaving) {
if (config.enableLogging) {
console.log('🧹 [useAutoSave] Sauvegarde finale au cleanup');
}
// Sauvegarde synchrone finale (best effort)
try {
saveFunction(pendingDataRef.current);
} catch (error) {
console.error('❌ [useAutoSave] Erreur sauvegarde finale:', error);
}
}
};
}, [saveFunction, saveState.isSaving, config.enableLogging]);
return {
saveState,
triggerSave,
forceSave,
clearDirty,
resetError
};
}
/**
* Hook spécialisé pour la sauvegarde des paramètres d'étapes
*
* IMPORTANT: Ce hook gère correctement les changements de stepId pour éviter
* que les paramètres d'une ancienne étape soient sauvegardés vers une nouvelle étape.
*/
export function useStepParametersAutoSave(
stepId: string,
onParameterChange: (stepId: string, paramName: string, value: any) => void,
options: AutoSaveOptions = {}
): UseAutoSaveResult {
// Référence pour tracker le stepId actuel et annuler les sauvegardes obsolètes
const currentStepIdRef = useRef<string>(stepId);
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const pendingDataRef = useRef<Record<string, any> | null>(null);
// État de sauvegarde local
const [saveState, setSaveState] = useState<SaveState>({
isSaving: false,
isDirty: false,
lastSaved: null,
error: null,
retryCount: 0
});
// Configuration
const config = {
debounceMs: 800,
maxRetries: 2,
retryDelayMs: 1500,
enableLogging: process.env.NODE_ENV === 'development',
...options
};
// CRITIQUE: Quand le stepId change, annuler toute sauvegarde en attente
// pour éviter que les paramètres de l'ancienne étape soient sauvegardés vers la nouvelle
useEffect(() => {
if (currentStepIdRef.current !== stepId) {
if (config.enableLogging) {
console.log('🔄 [useStepParametersAutoSave] StepId changé:', {
ancien: currentStepIdRef.current,
nouveau: stepId
});
if (pendingDataRef.current !== null) {
console.log('⚠️ [useStepParametersAutoSave] Annulation sauvegarde en attente pour éviter contamination');
}
}
// Annuler le timeout de debounce
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
debounceTimeoutRef.current = undefined;
}
// Effacer les données en attente (CRITIQUE pour éviter le bug)
pendingDataRef.current = null;
// Mettre à jour la référence
currentStepIdRef.current = stepId;
// Réinitialiser l'état
setSaveState({
isSaving: false,
isDirty: false,
lastSaved: null,
error: null,
retryCount: 0
});
}
}, [stepId, config.enableLogging]);
// Fonction de sauvegarde
const performSave = useCallback(async (parametersData: Record<string, any>, targetStepId: string) => {
// CRITIQUE: Vérifier que le stepId cible correspond toujours au stepId actuel
if (targetStepId !== currentStepIdRef.current) {
if (config.enableLogging) {
console.log('⚠️ [useStepParametersAutoSave] Sauvegarde annulée - stepId obsolète:', {
target: targetStepId,
current: currentStepIdRef.current
});
}
return;
}
if (!targetStepId) {
throw new Error('Step ID is required for parameter save');
}
try {
setSaveState(prev => ({ ...prev, isSaving: true, error: null }));
if (config.onSaveStart) {
config.onSaveStart();
}
// Sauvegarder chaque paramètre individuellement
const savePromises = Object.entries(parametersData).map(([paramName, value]) => {
return new Promise<void>((resolve) => {
onParameterChange(targetStepId, paramName, value);
resolve();
});
});
await Promise.all(savePromises);
setSaveState(prev => ({
...prev,
isSaving: false,
isDirty: false,
lastSaved: Date.now(),
error: null,
retryCount: 0
}));
if (config.onSaveSuccess) {
config.onSaveSuccess(parametersData);
}
if (config.enableLogging) {
console.log('✅ [useStepParametersAutoSave] Sauvegarde réussie pour stepId:', targetStepId);
}
} catch (error) {
const errorObj = error instanceof Error ? error : new Error(String(error));
setSaveState(prev => ({ ...prev, isSaving: false, error: errorObj }));
if (config.onSaveError) {
config.onSaveError(errorObj);
}
}
}, [onParameterChange, config]);
// Déclencher une sauvegarde avec debouncing
const triggerSave = useCallback((data: Record<string, any>) => {
// Capturer le stepId actuel au moment du déclenchement
const targetStepId = currentStepIdRef.current;
if (!targetStepId) {
if (config.enableLogging) {
console.log('⚠️ [useStepParametersAutoSave] Pas de stepId, sauvegarde ignorée');
}
return;
}
// Stocker les données en attente
pendingDataRef.current = data;
setSaveState(prev => ({ ...prev, isDirty: true, error: null }));
// Annuler le timeout précédent
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Programmer la nouvelle sauvegarde avec le stepId capturé
debounceTimeoutRef.current = setTimeout(() => {
if (pendingDataRef.current !== null) {
// CRITIQUE: Utiliser le targetStepId capturé, pas currentStepIdRef.current
// ET vérifier que c'est toujours valide
if (targetStepId === currentStepIdRef.current) {
performSave(pendingDataRef.current, targetStepId);
} else if (config.enableLogging) {
console.log('⚠️ [useStepParametersAutoSave] Sauvegarde debounced annulée - stepId changé');
}
pendingDataRef.current = null;
}
}, config.debounceMs);
if (config.enableLogging) {
console.log(`⏱️ [useStepParametersAutoSave] Sauvegarde programmée pour ${targetStepId} dans ${config.debounceMs}ms`);
}
}, [performSave, config]);
// Force une sauvegarde immédiate
const forceSave = useCallback(async (data: Record<string, any>): Promise<void> => {
const targetStepId = currentStepIdRef.current;
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
debounceTimeoutRef.current = undefined;
}
pendingDataRef.current = null;
if (targetStepId) {
await performSave(data, targetStepId);
}
}, [performSave]);
const clearDirty = useCallback(() => {
setSaveState(prev => ({ ...prev, isDirty: false }));
}, []);
const resetError = useCallback(() => {
setSaveState(prev => ({ ...prev, error: null, retryCount: 0 }));
}, []);
// Cleanup
useEffect(() => {
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, []);
return {
saveState,
triggerSave,
forceSave,
clearDirty,
resetError
};
}
/**
* Hook pour la sauvegarde avec état de synchronisation
*/
export function useSyncedAutoSave(
saveFunction: (data: any) => Promise<void>,
options: AutoSaveOptions = {}
) {
const autoSave = useAutoSave(saveFunction, options);
const [syncState, setSyncState] = useState<'synced' | 'pending' | 'error'>('synced');
// Mettre à jour l'état de synchronisation
useEffect(() => {
if (autoSave.saveState.isSaving) {
setSyncState('pending');
} else if (autoSave.saveState.error) {
setSyncState('error');
} else if (!autoSave.saveState.isDirty) {
setSyncState('synced');
} else {
setSyncState('pending');
}
}, [autoSave.saveState]);
return {
...autoSave,
syncState
};
}
/**
* Export par défaut
*/
export default useAutoSave;

View File

@@ -0,0 +1,421 @@
/**
* Hook useCatalogActions - Gestion de l'état du catalogue d'actions VisionOnly
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Ce hook gère le chargement, la mise en cache, et la synchronisation
* des actions du catalogue VisionOnly avec l'API backend.
*
* NOUVEAUTÉS v2.0:
* - Support du mode statique automatique (fallback hors ligne)
* - Détection automatique de l'URL du backend
* - Indicateurs de mode (dynamique/statique)
* - Gestion cross-machine robuste
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { catalogService } from '../services/catalogService';
import {
VWBCatalogAction,
VWBActionCategory,
VWBActionCategoryInfo,
VWBCatalogHealth,
VWBServiceStatus
} from '../types/catalog';
// Interface pour l'état du catalogue étendu
interface CatalogState {
actions: VWBCatalogAction[];
categories: VWBActionCategoryInfo[];
health: VWBCatalogHealth | null;
isLoading: boolean;
isOnline: boolean;
error: string | null;
lastUpdate: Date | null;
mode: 'dynamic' | 'static' | 'offline'; // Nouveau : mode du catalogue
serviceUrl: string | null; // Nouveau : URL du service actuel
}
// Interface pour les options du hook
interface UseCatalogActionsOptions {
/** Charger automatiquement au montage */
autoLoad?: boolean;
/** Intervalle de rafraîchissement automatique (en ms) */
refreshInterval?: number;
/** Catégorie à filtrer */
filterCategory?: VWBActionCategory;
/** Terme de recherche */
searchTerm?: string;
}
// Interface pour le retour du hook étendu
interface UseCatalogActionsReturn {
/** État actuel du catalogue */
state: CatalogState;
/** Actions filtrées selon les critères */
filteredActions: VWBCatalogAction[];
/** Statistiques du catalogue */
stats: {
totalActions: number;
actionsByCategory: Record<VWBActionCategory, number>;
averageComplexity: string;
onlineStatus: boolean;
mode: 'dynamic' | 'static' | 'offline'; // Nouveau
serviceUrl: string | null; // Nouveau
};
/** Actions disponibles */
actions: {
/** Recharger le catalogue */
reload: () => Promise<void>;
/** Rechercher des actions */
search: (term: string) => VWBCatalogAction[];
/** Obtenir une action par ID */
getAction: (id: string) => VWBCatalogAction | null;
/** Vider le cache */
clearCache: () => void;
/** Forcer une mise à jour de santé */
checkHealth: () => Promise<void>;
/** Forcer la détection d'URL (nouveau) */
forceUrlDetection: () => Promise<boolean>;
/** Réinitialiser complètement le service (nouveau) */
resetService: () => Promise<void>;
};
}
/**
* Hook pour gérer les actions du catalogue VisionOnly
*/
export const useCatalogActions = (options: UseCatalogActionsOptions = {}): UseCatalogActionsReturn => {
const {
autoLoad = true,
refreshInterval,
filterCategory,
searchTerm = '',
} = options;
// État du catalogue étendu
const [state, setState] = useState<CatalogState>({
actions: [],
categories: [],
health: null,
isLoading: false,
isOnline: false,
error: null,
lastUpdate: null,
mode: 'offline',
serviceUrl: null,
});
// Charger les données du catalogue avec fallback automatique
const loadCatalogData = useCallback(async () => {
setState(prev => ({ ...prev, isLoading: true, error: null }));
try {
console.log('🔄 Chargement du catalogue d\'actions VisionOnly...');
// Obtenir l'état du service pour connaître le mode
const serviceState = catalogService.getServiceState();
// Charger en parallèle les actions, catégories et santé
const [actionsResult, categoriesResult, healthResult] = await Promise.all([
catalogService.getActions(filterCategory),
catalogService.getCategories(),
catalogService.getHealth(),
]);
// Adapter les types retournés par le service aux types VWB
const adaptedCategories: VWBActionCategoryInfo[] = categoriesResult.map(cat => ({
id: cat.id,
name: cat.name,
description: cat.description,
icon: cat.icon,
color: '#2196f3', // Couleur par défaut
actionCount: cat.actionCount,
isEnabled: true, // Activé par défaut
}));
const adaptedHealth: VWBCatalogHealth = {
status: healthResult.status as VWBServiceStatus,
services: {
screenCapturer: healthResult.services.screenCapturer,
actions: healthResult.services.actions,
screenCapturerMethod: healthResult.services.screenCapturerMethod,
},
timestamp: healthResult.timestamp,
version: healthResult.version,
};
// Déterminer le mode basé sur les résultats
const mode = actionsResult.mode || serviceState.mode;
const isOnline = mode === 'dynamic' && adaptedHealth.status === 'healthy';
setState({
actions: actionsResult.actions as VWBCatalogAction[],
categories: adaptedCategories,
health: adaptedHealth,
isLoading: false,
isOnline,
error: null,
lastUpdate: new Date(),
mode,
serviceUrl: serviceState.currentUrl,
});
const modeEmoji = mode === 'dynamic' ? '🌐' : mode === 'static' ? '📦' : '🔴';
console.log(`${modeEmoji} Catalogue chargé (${mode}): ${actionsResult.actions.length} actions, ${adaptedCategories.length} catégories`);
if (mode === 'static') {
console.log('📦 Mode statique activé - Catalogue hors ligne disponible');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Erreur inconnue';
console.error('❌ Erreur lors du chargement du catalogue:', errorMessage);
// En cas d'erreur, essayer le mode statique
const serviceState = catalogService.getServiceState();
setState(prev => ({
...prev,
isLoading: false,
isOnline: false,
error: errorMessage,
mode: serviceState.mode || 'offline',
serviceUrl: serviceState.currentUrl,
}));
}
}, [filterCategory]);
// Recharger le catalogue
const reload = useCallback(async () => {
await loadCatalogData();
}, [loadCatalogData]);
// Vérifier la santé du service
const checkHealth = useCallback(async () => {
try {
const healthResult = await catalogService.getHealth();
const adaptedHealth: VWBCatalogHealth = {
status: healthResult.status as VWBServiceStatus,
services: {
screenCapturer: healthResult.services.screenCapturer,
actions: healthResult.services.actions,
screenCapturerMethod: healthResult.services.screenCapturerMethod,
},
timestamp: healthResult.timestamp,
version: healthResult.version,
};
setState(prev => ({
...prev,
health: adaptedHealth,
isOnline: adaptedHealth.status === 'healthy',
error: adaptedHealth.status === 'healthy' ? null : prev.error,
}));
} catch (error) {
setState(prev => ({
...prev,
isOnline: false,
error: error instanceof Error ? error.message : 'Erreur de santé',
}));
}
}, []);
// Rechercher des actions
const search = useCallback((term: string): VWBCatalogAction[] => {
if (!term.trim()) return state.actions;
const searchLower = term.toLowerCase();
return state.actions.filter(action =>
action.name.toLowerCase().includes(searchLower) ||
action.description.toLowerCase().includes(searchLower) ||
action.id.toLowerCase().includes(searchLower) ||
Object.keys(action.parameters).some(param =>
param.toLowerCase().includes(searchLower)
)
);
}, [state.actions]);
// Obtenir une action par ID
const getAction = useCallback((id: string): VWBCatalogAction | null => {
return state.actions.find(action => action.id === id) || null;
}, [state.actions]);
// Vider le cache
const clearCache = useCallback(() => {
catalogService.clearCache();
console.log('🗑️ Cache du catalogue vidé');
}, []);
// Forcer la détection d'URL (nouveau)
const forceUrlDetection = useCallback(async (): Promise<boolean> => {
console.log('🔄 Détection forcée de l\'URL du backend...');
try {
const success = await catalogService.forceUrlDetection();
if (success) {
// Recharger le catalogue après détection réussie
await loadCatalogData();
console.log('✅ Détection d\'URL réussie, catalogue rechargé');
} else {
console.log('❌ Aucun backend accessible lors de la détection forcée');
}
return success;
} catch (error) {
console.error('Erreur lors de la détection forcée d\'URL:', error);
return false;
}
}, [loadCatalogData]);
// Réinitialiser complètement le service (nouveau)
const resetService = useCallback(async (): Promise<void> => {
console.log('🔄 Réinitialisation complète du service catalogue...');
try {
await catalogService.reset();
// Recharger le catalogue après réinitialisation
await loadCatalogData();
console.log('✅ Service catalogue réinitialisé et rechargé');
} catch (error) {
console.error('Erreur lors de la réinitialisation du service:', error);
// Mettre à jour l'état avec l'erreur
setState(prev => ({
...prev,
error: error instanceof Error ? error.message : 'Erreur de réinitialisation',
isLoading: false,
}));
}
}, [loadCatalogData]);
// Actions filtrées selon les critères
const filteredActions = useMemo(() => {
let filtered = state.actions;
// Filtrer par catégorie si spécifiée
if (filterCategory) {
filtered = filtered.filter(action => action.category === filterCategory);
}
// Filtrer par terme de recherche
if (searchTerm.trim()) {
const searchLower = searchTerm.toLowerCase();
filtered = filtered.filter(action =>
action.name.toLowerCase().includes(searchLower) ||
action.description.toLowerCase().includes(searchLower) ||
action.id.toLowerCase().includes(searchLower)
);
}
return filtered;
}, [state.actions, filterCategory, searchTerm]);
// Statistiques du catalogue
const stats = useMemo(() => {
const actionsByCategory = state.actions.reduce((acc, action) => {
acc[action.category] = (acc[action.category] || 0) + 1;
return acc;
}, {} as Record<VWBActionCategory, number>);
// Calculer la complexité moyenne
const complexities = state.actions
.map(action => action.metadata?.complexity)
.filter(Boolean);
const complexityScores = { simple: 1, intermediate: 2, advanced: 3 };
const avgScore = complexities.length > 0
? complexities.reduce((sum, complexity) =>
sum + (complexityScores[complexity as keyof typeof complexityScores] || 1), 0
) / complexities.length
: 1;
const averageComplexity = avgScore <= 1.5 ? 'simple' : avgScore <= 2.5 ? 'intermediate' : 'advanced';
return {
totalActions: state.actions.length,
actionsByCategory,
averageComplexity,
onlineStatus: state.isOnline,
mode: state.mode,
serviceUrl: state.serviceUrl,
};
}, [state.actions, state.isOnline, state.mode, state.serviceUrl]);
// Chargement automatique au montage
useEffect(() => {
if (autoLoad) {
loadCatalogData();
}
}, [autoLoad, loadCatalogData]);
// Rafraîchissement automatique
useEffect(() => {
if (!refreshInterval || refreshInterval <= 0) return;
const interval = setInterval(() => {
if (state.isOnline) {
checkHealth();
}
}, refreshInterval);
return () => clearInterval(interval);
}, [refreshInterval, state.isOnline, checkHealth]);
// Recharger quand la catégorie de filtre change
useEffect(() => {
if (filterCategory && state.actions.length > 0) {
// Pas besoin de recharger, juste filtrer
return;
}
}, [filterCategory, state.actions.length]);
return {
state,
filteredActions,
stats,
actions: {
reload,
search,
getAction,
clearCache,
checkHealth,
forceUrlDetection,
resetService,
},
};
};
// Hook simplifié pour obtenir juste les actions
export const useCatalogActionsSimple = (category?: VWBActionCategory) => {
const { state, filteredActions, actions } = useCatalogActions({
filterCategory: category,
});
return {
actions: filteredActions,
isLoading: state.isLoading,
isOnline: state.isOnline,
error: state.error,
reload: actions.reload,
};
};
// Hook pour obtenir une action spécifique
export const useCatalogAction = (actionId: string) => {
const { state, actions } = useCatalogActions();
const action = useMemo(() => {
return actions.getAction(actionId);
}, [actions, actionId]);
return {
action,
isLoading: state.isLoading,
error: state.error,
reload: actions.reload,
};
};
export default useCatalogActions;

View File

@@ -77,10 +77,36 @@ const initialStats: CoachingStats = {
correctionRate: 0,
};
// SINGLETON: Socket partagé entre toutes les instances du hook
// Évite les connexions multiples quand le composant est monté/démonté
let sharedSocket: Socket | null = null;
let socketRefCount = 0;
const socketListeners = new Set<{
setIsConnected: (v: boolean) => void;
setIsSubscribed: (v: boolean) => void;
setCurrentSuggestion: (v: CoachingSuggestion | null) => void;
setStats: (v: CoachingStats) => void;
setLastActionResult: (v: CoachingActionResult | null) => void;
setError: (v: string | null) => void;
}>();
// Convert backend stats format to frontend format (moved outside hook)
const convertStats = (backendStats: Record<string, any>): CoachingStats => {
return {
suggestionsMade: backendStats.suggestions_made || 0,
accepted: backendStats.accepted || 0,
rejected: backendStats.rejected || 0,
corrected: backendStats.corrected || 0,
manualExecutions: backendStats.manual_executions || 0,
acceptanceRate: backendStats.acceptance_rate || 0,
correctionRate: backendStats.correction_rate || 0,
};
};
export function useCoachingWebSocket(
options: UseCoachingWebSocketOptions = {}
): UseCoachingWebSocketReturn {
const { serverUrl = 'http://localhost:5000', autoConnect = true } = options;
const { serverUrl = 'http://localhost:5001', autoConnect = true } = options;
const [isConnected, setIsConnected] = useState(false);
const [isSubscribed, setIsSubscribed] = useState(false);
@@ -89,13 +115,29 @@ export function useCoachingWebSocket(
const [lastActionResult, setLastActionResult] = useState<CoachingActionResult | null>(null);
const [error, setError] = useState<string | null>(null);
const socketRef = useRef<Socket | null>(null);
const executionIdRef = useRef<string | null>(null);
const listenerRef = useRef({ setIsConnected, setIsSubscribed, setCurrentSuggestion, setStats, setLastActionResult, setError });
// Initialize socket connection
// Mettre à jour la ref avec les setters actuels
useEffect(() => {
listenerRef.current = { setIsConnected, setIsSubscribed, setCurrentSuggestion, setStats, setLastActionResult, setError };
});
// Initialize socket connection (SINGLETON)
useEffect(() => {
if (!autoConnect) return;
// Ajouter ce listener à l'ensemble
socketListeners.add(listenerRef.current);
socketRefCount++;
// Si le socket existe déjà, mettre à jour l'état local
if (sharedSocket) {
setIsConnected(sharedSocket.connected);
return;
}
// Créer le socket partagé
const socket = io(serverUrl, {
transports: ['websocket', 'polling'],
reconnection: true,
@@ -104,40 +146,40 @@ export function useCoachingWebSocket(
});
socket.on('connect', () => {
console.log('[COACHING WS] Connected');
setIsConnected(true);
setError(null);
console.log('[COACHING WS] Connected (shared)');
socketListeners.forEach(l => l.setIsConnected(true));
socketListeners.forEach(l => l.setError(null));
});
socket.on('disconnect', () => {
console.log('[COACHING WS] Disconnected');
setIsConnected(false);
setIsSubscribed(false);
console.log('[COACHING WS] Disconnected (shared)');
socketListeners.forEach(l => l.setIsConnected(false));
socketListeners.forEach(l => l.setIsSubscribed(false));
});
socket.on('connect_error', (err) => {
console.error('[COACHING WS] Connection error:', err);
setError(`Connection error: ${err.message}`);
socketListeners.forEach(l => l.setError(`Connection error: ${err.message}`));
});
// COACHING specific events
socket.on('coaching_subscribed', (data) => {
console.log('[COACHING WS] Subscribed:', data);
setIsSubscribed(true);
socketListeners.forEach(l => l.setIsSubscribed(true));
if (data.stats) {
setStats(convertStats(data.stats));
socketListeners.forEach(l => l.setStats(convertStats(data.stats)));
}
});
socket.on('coaching_unsubscribed', () => {
console.log('[COACHING WS] Unsubscribed');
setIsSubscribed(false);
setCurrentSuggestion(null);
socketListeners.forEach(l => l.setIsSubscribed(false));
socketListeners.forEach(l => l.setCurrentSuggestion(null));
});
socket.on('coaching_suggestion', (data: any) => {
console.log('[COACHING WS] Suggestion received:', data);
setCurrentSuggestion({
const suggestion: CoachingSuggestion = {
executionId: data.execution_id,
action: data.action,
target: data.target || {},
@@ -147,27 +189,28 @@ export function useCoachingWebSocket(
screenshotPath: data.screenshot_path,
context: data.context,
timestamp: data.timestamp,
});
};
socketListeners.forEach(l => l.setCurrentSuggestion(suggestion));
});
socket.on('coaching_action_result', (data: any) => {
console.log('[COACHING WS] Action result:', data);
setLastActionResult({
const result: CoachingActionResult = {
executionId: data.execution_id,
action: data.action,
success: data.success,
result: data.result,
error: data.error,
timestamp: data.timestamp,
});
// Clear current suggestion after result
setCurrentSuggestion(null);
};
socketListeners.forEach(l => l.setLastActionResult(result));
socketListeners.forEach(l => l.setCurrentSuggestion(null));
});
socket.on('coaching_stats_update', (data: any) => {
console.log('[COACHING WS] Stats update:', data);
if (data.stats) {
setStats(convertStats(data.stats));
socketListeners.forEach(l => l.setStats(convertStats(data.stats)));
}
});
@@ -181,55 +224,49 @@ export function useCoachingWebSocket(
socket.on('coaching_session_end', (data: any) => {
console.log('[COACHING WS] Session ended:', data);
setIsSubscribed(false);
setCurrentSuggestion(null);
socketListeners.forEach(l => l.setIsSubscribed(false));
socketListeners.forEach(l => l.setCurrentSuggestion(null));
if (data.stats) {
setStats(convertStats(data.stats));
socketListeners.forEach(l => l.setStats(convertStats(data.stats)));
}
});
socket.on('error', (data) => {
console.error('[COACHING WS] Error:', data);
setError(data.message || 'Unknown error');
socketListeners.forEach(l => l.setError(data.message || 'Unknown error'));
});
socketRef.current = socket;
sharedSocket = socket;
return () => {
socket.disconnect();
socketRef.current = null;
socketListeners.delete(listenerRef.current);
socketRefCount--;
// Déconnecter seulement si plus aucun composant n'utilise le socket
if (socketRefCount === 0 && sharedSocket) {
console.log('[COACHING WS] Disconnecting shared socket (no more refs)');
sharedSocket.disconnect();
sharedSocket = null;
}
};
}, [serverUrl, autoConnect]);
// Convert backend stats format to frontend format
const convertStats = (backendStats: Record<string, any>): CoachingStats => {
return {
suggestionsMade: backendStats.suggestions_made || 0,
accepted: backendStats.accepted || 0,
rejected: backendStats.rejected || 0,
corrected: backendStats.corrected || 0,
manualExecutions: backendStats.manual_executions || 0,
acceptanceRate: backendStats.acceptance_rate || 0,
correctionRate: backendStats.correction_rate || 0,
};
};
// Subscribe to COACHING events for an execution
const subscribe = useCallback((executionId: string) => {
if (!socketRef.current || !isConnected) {
if (!sharedSocket || !isConnected) {
setError('Not connected to server');
return;
}
executionIdRef.current = executionId;
socketRef.current.emit('subscribe_coaching', { execution_id: executionId });
sharedSocket.emit('subscribe_coaching', { execution_id: executionId });
}, [isConnected]);
// Unsubscribe from COACHING events
const unsubscribe = useCallback(() => {
if (!socketRef.current || !executionIdRef.current) return;
if (!sharedSocket || !executionIdRef.current) return;
socketRef.current.emit('unsubscribe_coaching', {
sharedSocket.emit('unsubscribe_coaching', {
execution_id: executionIdRef.current,
});
executionIdRef.current = null;
@@ -238,12 +275,12 @@ export function useCoachingWebSocket(
// Submit a COACHING decision
const submitDecision = useCallback(
(decision: CoachingDecision, correction?: Record<string, any>, feedback?: string) => {
if (!socketRef.current || !executionIdRef.current) {
if (!sharedSocket || !executionIdRef.current) {
setError('Not subscribed to any execution');
return;
}
socketRef.current.emit('coaching_decision', {
sharedSocket.emit('coaching_decision', {
execution_id: executionIdRef.current,
decision,
correction,
@@ -255,9 +292,9 @@ export function useCoachingWebSocket(
// Refresh stats
const refreshStats = useCallback(() => {
if (!socketRef.current || !executionIdRef.current) return;
if (!sharedSocket || !executionIdRef.current) return;
socketRef.current.emit('get_coaching_stats', {
sharedSocket.emit('get_coaching_stats', {
execution_id: executionIdRef.current,
});
}, []);

View File

@@ -0,0 +1,189 @@
/**
* Hook État de Connexion - Gestion stable de l'état de connexion API
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
*
* Ce hook fournit un état de connexion stable qui évite les re-rendus
* excessifs et les "sauts" de page lors des vérifications de connexion.
*
* IMPORTANT: L'état initial est 'offline' pour éviter les appels API
* automatiques au montage des composants. Utilisez forceCheck() pour
* vérifier manuellement la connexion si nécessaire.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { apiClient, ConnectionState } from '../services/apiClient';
interface ConnectionStatusState {
/** État actuel de la connexion */
status: ConnectionState;
/** Indique si l'API est en ligne */
isOnline: boolean;
/** Indique si l'API est hors ligne */
isOffline: boolean;
/** Indique si une vérification est en cours */
isChecking: boolean;
/** Dernière vérification réussie */
lastOnlineAt: Date | null;
/** Message d'état pour l'affichage */
statusMessage: string;
}
interface UseConnectionStatusOptions {
/** Afficher les logs de debug */
debug?: boolean;
/** Callback appelé quand l'état change */
onStatusChange?: (status: ConnectionState) => void;
}
// Fonction pour obtenir le message d'état (définie en dehors du hook pour éviter les re-créations)
function getStatusMessage(status: ConnectionState): string {
switch (status) {
case 'online':
return 'API connectée';
case 'offline':
return 'API hors ligne - Mode local activé';
case 'checking':
return 'Vérification de la connexion...';
default:
return 'État inconnu';
}
}
// État initial stable (défini en dehors du hook pour éviter les re-créations)
// TEMPORAIRE: Changé à 'online' pour debug
const INITIAL_STATE: ConnectionStatusState = {
status: 'online',
isOnline: true,
isOffline: false,
isChecking: false,
lastOnlineAt: new Date(),
statusMessage: 'API connectée',
};
/**
* Hook pour surveiller l'état de connexion de l'API de manière stable
*
* ARCHITECTURE:
* - État initial: 'offline' (pas de vérification automatique)
* - Les changements d'état sont notifiés de manière asynchrone
* - Utilise useRef pour éviter les re-renders inutiles
*/
export function useConnectionStatus(options: UseConnectionStatusOptions = {}): ConnectionStatusState & {
forceCheck: () => Promise<boolean>;
} {
const { debug = false, onStatusChange } = options;
// État initial stable - toujours 'offline' pour éviter les appels au montage
const [state, setState] = useState<ConnectionStatusState>(INITIAL_STATE);
// Référence pour éviter les mises à jour après démontage
const isMountedRef = useRef(true);
// Référence pour le callback onStatusChange (évite les re-renders)
const onStatusChangeRef = useRef(onStatusChange);
onStatusChangeRef.current = onStatusChange;
// Référence pour le debug (évite les re-renders)
const debugRef = useRef(debug);
debugRef.current = debug;
// Gestionnaire de changement d'état (stable grâce aux refs)
const handleStatusChange = useCallback((newStatus: ConnectionState) => {
if (!isMountedRef.current) return;
if (debugRef.current) {
console.log(`[ConnectionStatus] État changé: ${newStatus}`);
}
setState(prev => {
// Éviter les mises à jour inutiles si l'état n'a pas changé
if (prev.status === newStatus) {
return prev;
}
const newState: ConnectionStatusState = {
status: newStatus,
isOnline: newStatus === 'online',
isOffline: newStatus === 'offline',
isChecking: newStatus === 'checking',
lastOnlineAt: newStatus === 'online' ? new Date() : prev.lastOnlineAt,
statusMessage: getStatusMessage(newStatus),
};
return newState;
});
// Appeler le callback si fourni (de manière asynchrone pour éviter les boucles)
if (onStatusChangeRef.current) {
setTimeout(() => {
if (isMountedRef.current && onStatusChangeRef.current) {
onStatusChangeRef.current(newStatus);
}
}, 0);
}
}, []); // Pas de dépendances - utilise des refs
// Vérification directe au montage (SANS abonnement au singleton)
useEffect(() => {
isMountedRef.current = true;
// Vérification DIRECTE au démarrage
const checkOnMount = async () => {
try {
const response = await fetch('http://localhost:5001/api/health', {
headers: { 'Accept': 'application/json' },
});
if (response.ok) {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
if (isMountedRef.current) {
handleStatusChange('online');
}
return;
}
}
} catch (error) {
// Ignorer - on garde l'état initial
}
// Seulement si vraiment impossible de contacter l'API
if (isMountedRef.current) {
handleStatusChange('offline');
}
};
checkOnMount();
// NE PAS s'abonner au singleton - cela cause des conflits d'état
// Nettoyage
return () => {
isMountedRef.current = false;
};
}, [handleStatusChange]);
// Fonction pour forcer une vérification de connexion
const forceCheck = useCallback(async (): Promise<boolean> => {
if (debugRef.current) {
console.log('[ConnectionStatus] Vérification forcée...');
}
return apiClient.forceConnectionCheck();
}, []);
return {
...state,
forceCheck,
};
}
/**
* Hook simplifié qui retourne juste un booléen pour l'état en ligne
* Utile pour les composants qui n'ont besoin que de savoir si l'API est disponible
*/
export function useIsApiOnline(): boolean {
const { isOnline } = useConnectionStatus();
return isOnline;
}
export default useConnectionStatus;

View File

@@ -68,7 +68,7 @@ interface UseCorrectionPacksReturn {
selectPack: (pack: CorrectionPack | null) => void;
}
const API_BASE = 'http://localhost:5000/api';
const API_BASE = 'http://localhost:5001/api';
export function useCorrectionPacks(): UseCorrectionPacksReturn {
const [packs, setPacks] = useState<CorrectionPack[]>([]);

View File

@@ -0,0 +1,262 @@
/**
* Hook de Debouncing - Optimisation des performances pour les opérations coûteuses
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce hook retarde l'exécution d'une valeur ou fonction jusqu'à ce qu'un délai
* soit écoulé sans nouvelle modification, optimisant les performances.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
/**
* Hook de debouncing pour les valeurs
*
* @param value - Valeur à débouncer
* @param delay - Délai en millisecondes (défaut: 300ms)
* @returns Valeur débouncée
*/
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
// Créer un timer qui met à jour la valeur débouncée après le délai
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Nettoyer le timer si la valeur change avant la fin du délai
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
/**
* Hook de debouncing pour les fonctions callback
*
* @param callback - Fonction à débouncer
* @param delay - Délai en millisecondes (défaut: 300ms)
* @param deps - Dépendances du callback
* @returns Fonction débouncée
*/
export function useDebouncedCallback<T extends (...args: any[]) => any>(
callback: T,
delay: number = 300,
deps: React.DependencyList = []
): T {
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const debouncedCallback = useCallback(
(...args: Parameters<T>) => {
// Annuler le timer précédent s'il existe
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Créer un nouveau timer
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
},
[callback, delay, ...deps]
) as T;
// Nettoyer le timer lors du démontage du composant
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return debouncedCallback;
}
/**
* Hook de throttling pour limiter la fréquence d'exécution
*
* @param callback - Fonction à throttler
* @param delay - Délai minimum entre les exécutions (défaut: 100ms)
* @param deps - Dépendances du callback
* @returns Fonction throttlée
*/
export function useThrottledCallback<T extends (...args: any[]) => any>(
callback: T,
delay: number = 100,
deps: React.DependencyList = []
): T {
const lastExecutedRef = useRef<number>(0);
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const throttledCallback = useCallback(
(...args: Parameters<T>) => {
const now = Date.now();
const timeSinceLastExecution = now - lastExecutedRef.current;
if (timeSinceLastExecution >= delay) {
// Exécuter immédiatement si assez de temps s'est écoulé
lastExecutedRef.current = now;
callback(...args);
} else {
// Programmer l'exécution pour plus tard
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
lastExecutedRef.current = Date.now();
callback(...args);
}, delay - timeSinceLastExecution);
}
},
[callback, delay, ...deps]
) as T;
// Nettoyer le timer lors du démontage du composant
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return throttledCallback;
}
/**
* Hook de debouncing avec état de chargement
*
* @param asyncCallback - Fonction async à débouncer
* @param delay - Délai en millisecondes (défaut: 300ms)
* @param deps - Dépendances du callback
* @returns Objet avec la fonction débouncée et l'état de chargement
*/
export function useDebouncedAsyncCallback<T extends (...args: any[]) => Promise<any>>(
asyncCallback: T,
delay: number = 300,
deps: React.DependencyList = []
): {
debouncedCallback: T;
isLoading: boolean;
cancel: () => void;
} {
const [isLoading, setIsLoading] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const currentPromiseRef = useRef<Promise<any> | undefined>(undefined);
const debouncedCallback = useCallback(
async (...args: Parameters<T>) => {
// Annuler le timer précédent s'il existe
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Créer un nouveau timer
return new Promise<Awaited<ReturnType<T>>>((resolve, reject) => {
timeoutRef.current = setTimeout(async () => {
try {
setIsLoading(true);
const promise = asyncCallback(...args);
currentPromiseRef.current = promise;
const result = await promise;
// Vérifier si cette promesse est toujours la plus récente
if (currentPromiseRef.current === promise) {
setIsLoading(false);
resolve(result);
}
} catch (error) {
setIsLoading(false);
reject(error);
}
}, delay);
});
},
[asyncCallback, delay, ...deps]
) as T;
const cancel = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setIsLoading(false);
}, []);
// Nettoyer lors du démontage du composant
useEffect(() => {
return () => {
cancel();
};
}, [cancel]);
return {
debouncedCallback,
isLoading,
cancel,
};
}
/**
* Hook pour débouncer les recherches avec gestion d'état
*
* @param searchFunction - Fonction de recherche async
* @param delay - Délai de debouncing (défaut: 300ms)
* @returns Objet avec les fonctions et états de recherche
*/
export function useDebouncedSearch<T>(
searchFunction: (query: string) => Promise<T[]>,
delay: number = 300
) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<T[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [error, setError] = useState<string | null>(null);
const debouncedQuery = useDebounce(query, delay);
const performSearch = useCallback(async (searchQuery: string) => {
if (!searchQuery.trim()) {
setResults([]);
setIsSearching(false);
setError(null);
return;
}
try {
setIsSearching(true);
setError(null);
const searchResults = await searchFunction(searchQuery);
setResults(searchResults);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur de recherche');
setResults([]);
} finally {
setIsSearching(false);
}
}, [searchFunction]);
// Effectuer la recherche quand la query débouncée change
useEffect(() => {
performSearch(debouncedQuery);
}, [debouncedQuery, performSearch]);
const clearSearch = useCallback(() => {
setQuery('');
setResults([]);
setError(null);
setIsSearching(false);
}, []);
return {
query,
setQuery,
results,
isSearching,
error,
clearSearch,
};
}

View File

@@ -0,0 +1,259 @@
/**
* Hook personnalisé pour la gestion de l'Evidence Viewer VWB
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { VWBEvidence, EvidenceFilters, EvidenceStats, EvidenceUtils } from '../types/evidence';
import { evidenceService } from '../services/evidenceService';
interface UseEvidenceViewerOptions {
workflowId?: string;
autoRefresh?: boolean;
refreshInterval?: number;
initialFilters?: Partial<EvidenceFilters>;
}
interface UseEvidenceViewerReturn {
// État des données
evidences: VWBEvidence[];
filteredEvidences: VWBEvidence[];
selectedEvidence: VWBEvidence | null;
stats: EvidenceStats;
// État de l'interface
loading: boolean;
error: string | null;
filters: EvidenceFilters;
sortBy: string;
sortOrder: 'asc' | 'desc';
// Actions
setSelectedEvidenceId: (id: string | null) => void;
setFilters: (filters: Partial<EvidenceFilters>) => void;
setSorting: (sortBy: string, sortOrder?: 'asc' | 'desc') => void;
refreshEvidences: () => Promise<void>;
clearFilters: () => void;
exportEvidences: (format: 'json' | 'html' | 'pdf') => Promise<void>;
// Utilitaires
getEvidenceById: (id: string) => VWBEvidence | undefined;
hasFilters: boolean;
isServiceAvailable: boolean;
}
const defaultFilters: EvidenceFilters = {
actionTypes: [],
status: 'all',
dateRange: {},
searchText: '',
confidenceRange: { min: 0, max: 1 },
executionTimeRange: { min: 0, max: 60000 }
};
export const useEvidenceViewer = (options: UseEvidenceViewerOptions = {}): UseEvidenceViewerReturn => {
const {
workflowId,
autoRefresh = false,
refreshInterval = 30000,
initialFilters = {}
} = options;
// État des données
const [evidences, setEvidences] = useState<VWBEvidence[]>([]);
const [selectedEvidenceId, setSelectedEvidenceId] = useState<string | null>(null);
// État de l'interface
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filters, setFiltersState] = useState<EvidenceFilters>({
...defaultFilters,
...initialFilters
});
const [sortBy, setSortBy] = useState('date');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [isServiceAvailable, setIsServiceAvailable] = useState(true);
// Cache et timeout
const cache = useMemo(() => new Map<string, VWBEvidence[]>(), []);
const cacheTimeout = 5 * 60 * 1000; // 5 minutes
// Evidence filtrées et triées
const filteredEvidences = useMemo(() => {
let filtered = EvidenceUtils.filterEvidences(evidences, filters);
filtered = EvidenceUtils.sortEvidences(filtered, sortBy, sortOrder);
return filtered;
}, [evidences, filters, sortBy, sortOrder]);
// Evidence sélectionnée
const selectedEvidence = useMemo(() => {
return selectedEvidenceId ? evidences.find(e => e.id === selectedEvidenceId) || null : null;
}, [evidences, selectedEvidenceId]);
// Statistiques
const stats = useMemo(() => {
return EvidenceUtils.calculateStats(filteredEvidences);
}, [filteredEvidences]);
// Vérification si des filtres sont appliqués
const hasFilters = useMemo(() => {
return (
filters.actionTypes.length > 0 ||
filters.status !== 'all' ||
filters.searchText.trim() !== '' ||
filters.dateRange.start !== undefined ||
filters.dateRange.end !== undefined ||
filters.confidenceRange.min > 0 ||
filters.confidenceRange.max < 1 ||
filters.executionTimeRange.min > 0 ||
filters.executionTimeRange.max < 60000
);
}, [filters]);
// Chargement des Evidence
const loadEvidences = useCallback(async () => {
try {
setLoading(true);
setError(null);
// Vérification de la disponibilité du service
const serviceHealth = await evidenceService.healthCheck();
setIsServiceAvailable(serviceHealth);
if (!serviceHealth) {
setError('Service Evidence non disponible');
return;
}
const loadedEvidences = await evidenceService.getEvidences(workflowId);
setEvidences(loadedEvidences);
// Mise en cache des Evidence
const cache = new Map();
loadedEvidences.forEach(evidence => {
cache.set(evidence.id, evidence);
});
// Si une Evidence était sélectionnée et n'existe plus, la désélectionner
if (selectedEvidenceId && !loadedEvidences.find(e => e.id === selectedEvidenceId)) {
setSelectedEvidenceId(null);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Erreur inconnue';
setError(`Erreur lors du chargement des Evidence : ${errorMessage}`);
console.error('Erreur useEvidenceViewer.loadEvidences:', err);
} finally {
setLoading(false);
}
}, [workflowId, selectedEvidenceId]);
// Actualisation des Evidence
const refreshEvidences = useCallback(async () => {
evidenceService.clearCache();
await loadEvidences();
}, [loadEvidences]);
// Mise à jour des filtres
const setFilters = useCallback((newFilters: Partial<EvidenceFilters>) => {
setFiltersState(prev => ({ ...prev, ...newFilters }));
}, []);
// Remise à zéro des filtres
const clearFilters = useCallback(() => {
setFiltersState(defaultFilters);
}, []);
// Mise à jour du tri
const setSorting = useCallback((newSortBy: string, newSortOrder: 'asc' | 'desc' = 'desc') => {
setSortBy(newSortBy);
setSortOrder(newSortOrder);
}, []);
// Export des Evidence
const exportEvidences = useCallback(async (format: 'json' | 'html' | 'pdf') => {
try {
const blob = await evidenceService.exportEvidences(filteredEvidences, {
format,
includeScreenshots: true,
includeMetadata: true,
includeErrors: true
});
// Téléchargement du fichier
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `evidence_export_${new Date().toISOString().split('T')[0]}.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Erreur inconnue';
setError(`Erreur lors de l'export : ${errorMessage}`);
console.error('Erreur useEvidenceViewer.exportEvidences:', err);
}
}, [filteredEvidences]);
// Utilitaire pour récupérer une Evidence par ID
const getEvidenceById = useCallback((id: string): VWBEvidence | undefined => {
return evidences.find(e => e.id === id);
}, [evidences]);
// Chargement initial
useEffect(() => {
loadEvidences();
}, [loadEvidences]);
// Auto-refresh
useEffect(() => {
if (!autoRefresh || refreshInterval <= 0) return;
const interval = setInterval(() => {
if (!loading) {
refreshEvidences();
}
}, refreshInterval);
return () => clearInterval(interval);
}, [autoRefresh, refreshInterval, loading, refreshEvidences]);
// Nettoyage à la désactivation
useEffect(() => {
return () => {
evidenceService.clearCache();
};
}, []);
return {
// État des données
evidences,
filteredEvidences,
selectedEvidence,
stats,
// État de l'interface
loading,
error,
filters,
sortBy,
sortOrder,
// Actions
setSelectedEvidenceId,
setFilters,
setSorting,
refreshEvidences,
clearFilters,
exportEvidences,
// Utilitaires
getEvidenceById,
hasFilters,
isServiceAvailable
};
};
export default useEvidenceViewer;

View File

@@ -0,0 +1,363 @@
/**
* Hook d'Evidence d'Exécution - Gestion des Evidence pendant l'exécution VWB
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Ce hook gère la collecte, le stockage et l'organisation des Evidence
* générées pendant l'exécution des workflows VWB.
*/
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { Evidence, Step } from '../types';
export interface EvidenceStats {
total: number;
screenshots: number;
bySteps: number;
byCurrentStep: number;
byType: Record<string, number>;
latest: Evidence | null;
}
export interface EvidenceByStep {
[stepId: string]: Evidence[];
}
export interface UseExecutionEvidenceOptions {
maxEvidencePerStep?: number;
maxTotalEvidence?: number;
autoCleanup?: boolean;
persistToStorage?: boolean;
}
/**
* Hook principal pour la gestion des Evidence d'exécution
*/
export const useExecutionEvidence = (
currentStepId?: string,
options: UseExecutionEvidenceOptions = {}
) => {
const {
maxEvidencePerStep = 50,
maxTotalEvidence = 200,
autoCleanup = true,
persistToStorage = false,
} = options;
// État des Evidence
const [evidenceByStep, setEvidenceByStep] = useState<EvidenceByStep>({});
const [allEvidence, setAllEvidence] = useState<Evidence[]>([]);
// Références pour éviter les re-renders
const evidenceCountRef = useRef(0);
const lastCleanupRef = useRef(Date.now());
// Charger les Evidence depuis le stockage local si activé
useEffect(() => {
if (persistToStorage) {
try {
const stored = localStorage.getItem('vwb_execution_evidence');
if (stored) {
const parsed = JSON.parse(stored);
setEvidenceByStep(parsed.evidenceByStep || {});
setAllEvidence(parsed.allEvidence || []);
}
} catch (error) {
console.warn('Erreur lors du chargement des Evidence:', error);
}
}
}, [persistToStorage]);
// Sauvegarder les Evidence dans le stockage local
const saveToStorage = useCallback(() => {
if (persistToStorage) {
try {
localStorage.setItem('vwb_execution_evidence', JSON.stringify({
evidenceByStep,
allEvidence,
timestamp: Date.now(),
}));
} catch (error) {
console.warn('Erreur lors de la sauvegarde des Evidence:', error);
}
}
}, [evidenceByStep, allEvidence, persistToStorage]);
// Sauvegarder automatiquement
useEffect(() => {
saveToStorage();
}, [saveToStorage]);
/**
* Ajouter une Evidence pour une étape
*/
const addEvidence = useCallback((stepId: string, evidence: Evidence) => {
// Vérifier les limites
if (evidenceCountRef.current >= maxTotalEvidence) {
console.warn('Limite maximale d\'Evidence atteinte');
return;
}
setEvidenceByStep(prev => {
const stepEvidence = prev[stepId] || [];
// Vérifier la limite par étape
if (stepEvidence.length >= maxEvidencePerStep) {
// Supprimer la plus ancienne Evidence
stepEvidence.shift();
}
return {
...prev,
[stepId]: [...stepEvidence, evidence],
};
});
setAllEvidence(prev => {
const newEvidence = [...prev, evidence];
// Vérifier la limite totale
if (newEvidence.length > maxTotalEvidence) {
// Supprimer les plus anciennes Evidence
return newEvidence.slice(-maxTotalEvidence);
}
return newEvidence;
});
evidenceCountRef.current++;
}, [maxEvidencePerStep, maxTotalEvidence]);
/**
* Ajouter plusieurs Evidence pour une étape
*/
const addMultipleEvidence = useCallback((stepId: string, evidenceList: Evidence[]) => {
evidenceList.forEach(evidence => addEvidence(stepId, evidence));
}, [addEvidence]);
/**
* Supprimer les Evidence d'une étape
*/
const removeStepEvidence = useCallback((stepId: string) => {
setEvidenceByStep(prev => {
const { [stepId]: removed, ...rest } = prev;
return rest;
});
setAllEvidence(prev =>
prev.filter(evidence =>
!prev.some(stepEvidence =>
evidenceByStep[stepId]?.some(e => e.id === evidence.id)
)
)
);
}, [evidenceByStep]);
/**
* Nettoyer toutes les Evidence
*/
const clearEvidence = useCallback(() => {
setEvidenceByStep({});
setAllEvidence([]);
evidenceCountRef.current = 0;
if (persistToStorage) {
localStorage.removeItem('vwb_execution_evidence');
}
}, [persistToStorage]);
/**
* Nettoyer automatiquement les anciennes Evidence
*/
const performAutoCleanup = useCallback(() => {
if (!autoCleanup) return;
const now = Date.now();
const cleanupInterval = 5 * 60 * 1000; // 5 minutes
if (now - lastCleanupRef.current < cleanupInterval) return;
const cutoffTime = now - (30 * 60 * 1000); // 30 minutes
setAllEvidence(prev =>
prev.filter(evidence =>
new Date(evidence.captured_at).getTime() > cutoffTime
)
);
setEvidenceByStep(prev => {
const cleaned: EvidenceByStep = {};
Object.entries(prev).forEach(([stepId, stepEvidence]) => {
const filteredEvidence = stepEvidence.filter(evidence =>
new Date(evidence.captured_at).getTime() > cutoffTime
);
if (filteredEvidence.length > 0) {
cleaned[stepId] = filteredEvidence;
}
});
return cleaned;
});
lastCleanupRef.current = now;
}, [autoCleanup]);
// Nettoyer automatiquement toutes les 5 minutes
useEffect(() => {
if (autoCleanup) {
const interval = setInterval(performAutoCleanup, 5 * 60 * 1000);
return () => clearInterval(interval);
}
}, [autoCleanup, performAutoCleanup]);
/**
* Obtenir les Evidence de l'étape actuelle
*/
const currentStepEvidence = useMemo(() => {
return currentStepId ? (evidenceByStep[currentStepId] || []) : [];
}, [evidenceByStep, currentStepId]);
/**
* Obtenir les statistiques des Evidence
*/
const getEvidenceStats = useCallback((): EvidenceStats => {
const byType: Record<string, number> = {};
let screenshots = 0;
allEvidence.forEach(evidence => {
byType[evidence.action_id] = (byType[evidence.action_id] || 0) + 1;
if (evidence.action_id === 'screenshot' || evidence.metadata?.screenshot) {
screenshots++;
}
});
return {
total: allEvidence.length,
screenshots,
bySteps: Object.keys(evidenceByStep).length,
byCurrentStep: currentStepEvidence.length,
byType,
latest: allEvidence.length > 0 ? allEvidence[allEvidence.length - 1] : null,
};
}, [allEvidence, evidenceByStep, currentStepEvidence]);
/**
* Rechercher des Evidence par critères
*/
const searchEvidence = useCallback((
query: string,
filters?: {
stepId?: string;
type?: string;
dateFrom?: Date;
dateTo?: Date;
}
): Evidence[] => {
let results = allEvidence;
// Filtrer par étape
if (filters?.stepId) {
results = evidenceByStep[filters.stepId] || [];
}
// Filtrer par type
if (filters?.type) {
results = results.filter(evidence => evidence.action_id === filters.type);
}
// Filtrer par date
if (filters?.dateFrom) {
results = results.filter(evidence =>
new Date(evidence.captured_at) >= filters.dateFrom!
);
}
if (filters?.dateTo) {
results = results.filter(evidence =>
new Date(evidence.captured_at) <= filters.dateTo!
);
}
// Recherche textuelle
if (query.trim()) {
const lowerQuery = query.toLowerCase();
results = results.filter(evidence =>
evidence.id.toLowerCase().includes(lowerQuery) ||
evidence.action_id.toLowerCase().includes(lowerQuery) ||
(evidence.metadata?.message &&
evidence.data?.message.toLowerCase().includes(lowerQuery))
);
}
return results;
}, [allEvidence, evidenceByStep]);
/**
* Obtenir les Evidence par étape avec tri
*/
const getEvidenceByStep = useCallback((
stepId: string,
sortBy: 'timestamp' | 'type' = 'timestamp',
sortOrder: 'asc' | 'desc' = 'desc'
): Evidence[] => {
const stepEvidence = evidenceByStep[stepId] || [];
return [...stepEvidence].sort((a, b) => {
let comparison = 0;
if (sortBy === 'timestamp') {
comparison = new Date(a.captured_at).getTime() - new Date(b.captured_at).getTime();
} else if (sortBy === 'type') {
comparison = a.action_id.localeCompare(b.action_id);
}
return sortOrder === 'desc' ? -comparison : comparison;
});
}, [evidenceByStep]);
/**
* Exporter les Evidence au format JSON
*/
const exportEvidence = useCallback((stepId?: string) => {
const dataToExport = stepId
? { [stepId]: evidenceByStep[stepId] || [] }
: evidenceByStep;
const exportData = {
timestamp: new Date().toISOString(),
stepId,
evidence: dataToExport,
stats: getEvidenceStats(),
};
return JSON.stringify(exportData, null, 2);
}, [evidenceByStep, getEvidenceStats]);
return {
// État
evidenceByStep,
allEvidence,
currentStepEvidence,
// Actions
addEvidence,
addMultipleEvidence,
removeStepEvidence,
clearEvidence,
performAutoCleanup,
// Utilitaires
getEvidenceStats,
searchEvidence,
getEvidenceByStep,
exportEvidence,
// Informations
totalCount: allEvidence.length,
stepCount: Object.keys(evidenceByStep).length,
hasEvidence: allEvidence.length > 0,
hasCurrentStepEvidence: currentStepEvidence.length > 0,
};
};

View File

@@ -0,0 +1,301 @@
/**
* Hook de Navigation au Clavier - Accessibilité complète
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce hook gère la navigation au clavier complète pour toutes les fonctionnalités
* du Visual Workflow Builder, conformément aux standards WCAG 2.1 niveau AA.
*/
import { useEffect, useCallback, useRef } from 'react';
interface KeyboardNavigationOptions {
onStepSelect?: (stepId: string) => void;
onStepMove?: (stepId: string, direction: 'up' | 'down' | 'left' | 'right') => void;
onStepDelete?: (stepId: string) => void;
onStepCopy?: (stepId: string) => void;
onStepPaste?: () => void;
onUndo?: () => void;
onRedo?: () => void;
onSave?: () => void;
onExecute?: () => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
onZoomFit?: () => void;
onSelectAll?: () => void;
onEscape?: () => void;
onHelp?: () => void;
selectedStepId?: string;
availableStepIds?: string[];
isEnabled?: boolean;
}
interface KeyboardShortcut {
key: string;
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
description: string;
action: () => void;
}
export const useKeyboardNavigation = (options: KeyboardNavigationOptions) => {
const {
onStepSelect,
onStepMove,
onStepDelete,
onStepCopy,
onStepPaste,
onUndo,
onRedo,
onSave,
onExecute,
onZoomIn,
onZoomOut,
onZoomFit,
onSelectAll,
onEscape,
onHelp,
selectedStepId,
availableStepIds = [],
isEnabled = true,
} = options;
const shortcutsRef = useRef<KeyboardShortcut[]>([]);
// Définir les raccourcis clavier
const defineShortcuts = useCallback((): KeyboardShortcut[] => {
return [
// Navigation des étapes
{
key: 'Tab',
description: 'Naviguer vers l\'étape suivante',
action: () => {
if (availableStepIds.length === 0) return;
const currentIndex = selectedStepId ? availableStepIds.indexOf(selectedStepId) : -1;
const nextIndex = (currentIndex + 1) % availableStepIds.length;
onStepSelect?.(availableStepIds[nextIndex]);
}
},
{
key: 'Tab',
shiftKey: true,
description: 'Naviguer vers l\'étape précédente',
action: () => {
if (availableStepIds.length === 0) return;
const currentIndex = selectedStepId ? availableStepIds.indexOf(selectedStepId) : -1;
const prevIndex = currentIndex <= 0 ? availableStepIds.length - 1 : currentIndex - 1;
onStepSelect?.(availableStepIds[prevIndex]);
}
},
// Déplacement des étapes
{
key: 'ArrowUp',
description: 'Déplacer l\'étape vers le haut',
action: () => selectedStepId && onStepMove?.(selectedStepId, 'up')
},
{
key: 'ArrowDown',
description: 'Déplacer l\'étape vers le bas',
action: () => selectedStepId && onStepMove?.(selectedStepId, 'down')
},
{
key: 'ArrowLeft',
description: 'Déplacer l\'étape vers la gauche',
action: () => selectedStepId && onStepMove?.(selectedStepId, 'left')
},
{
key: 'ArrowRight',
description: 'Déplacer l\'étape vers la droite',
action: () => selectedStepId && onStepMove?.(selectedStepId, 'right')
},
// Actions d'édition
{
key: 'Delete',
description: 'Supprimer l\'étape sélectionnée',
action: () => selectedStepId && onStepDelete?.(selectedStepId)
},
{
key: 'Backspace',
description: 'Supprimer l\'étape sélectionnée',
action: () => selectedStepId && onStepDelete?.(selectedStepId)
},
{
key: 'c',
ctrlKey: true,
description: 'Copier l\'étape sélectionnée',
action: () => selectedStepId && onStepCopy?.(selectedStepId)
},
{
key: 'v',
ctrlKey: true,
description: 'Coller l\'étape copiée',
action: () => onStepPaste?.()
},
// Actions globales
{
key: 'z',
ctrlKey: true,
description: 'Annuler la dernière action',
action: () => onUndo?.()
},
{
key: 'y',
ctrlKey: true,
description: 'Rétablir l\'action annulée',
action: () => onRedo?.()
},
{
key: 'z',
ctrlKey: true,
shiftKey: true,
description: 'Rétablir l\'action annulée (alternative)',
action: () => onRedo?.()
},
{
key: 's',
ctrlKey: true,
description: 'Sauvegarder le workflow',
action: () => onSave?.()
},
{
key: 'F5',
description: 'Exécuter le workflow',
action: () => onExecute?.()
},
{
key: 'Enter',
ctrlKey: true,
description: 'Exécuter le workflow (alternative)',
action: () => onExecute?.()
},
// Navigation et zoom
{
key: '=',
ctrlKey: true,
description: 'Zoomer',
action: () => onZoomIn?.()
},
{
key: '+',
ctrlKey: true,
description: 'Zoomer (alternative)',
action: () => onZoomIn?.()
},
{
key: '-',
ctrlKey: true,
description: 'Dézoomer',
action: () => onZoomOut?.()
},
{
key: '0',
ctrlKey: true,
description: 'Ajuster le zoom pour voir tout le workflow',
action: () => onZoomFit?.()
},
{
key: 'a',
ctrlKey: true,
description: 'Sélectionner toutes les étapes',
action: () => onSelectAll?.()
},
// Actions spéciales
{
key: 'Escape',
description: 'Annuler l\'action en cours ou désélectionner',
action: () => onEscape?.()
},
{
key: 'F1',
description: 'Afficher l\'aide',
action: () => onHelp?.()
},
{
key: '?',
shiftKey: true,
description: 'Afficher les raccourcis clavier',
action: () => onHelp?.()
}
];
}, [
selectedStepId,
availableStepIds,
onStepSelect,
onStepMove,
onStepDelete,
onStepCopy,
onStepPaste,
onUndo,
onRedo,
onSave,
onExecute,
onZoomIn,
onZoomOut,
onZoomFit,
onSelectAll,
onEscape,
onHelp,
]);
// Gestionnaire d'événements clavier
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (!isEnabled) return;
// Ignorer si l'utilisateur tape dans un champ de saisie
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
const shortcuts = shortcutsRef.current;
const matchingShortcut = shortcuts.find(shortcut => {
return (
shortcut.key === event.key &&
!!shortcut.ctrlKey === event.ctrlKey &&
!!shortcut.shiftKey === event.shiftKey &&
!!shortcut.altKey === event.altKey
);
});
if (matchingShortcut) {
event.preventDefault();
event.stopPropagation();
matchingShortcut.action();
}
}, [isEnabled]);
// Effet pour attacher/détacher les événements
useEffect(() => {
shortcutsRef.current = defineShortcuts();
if (isEnabled) {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}
}, [handleKeyDown, defineShortcuts, isEnabled]);
// Fonction pour obtenir la liste des raccourcis (pour l'aide)
const getShortcuts = useCallback(() => {
return shortcutsRef.current.map(shortcut => ({
keys: [
shortcut.ctrlKey && 'Ctrl',
shortcut.shiftKey && 'Shift',
shortcut.altKey && 'Alt',
shortcut.key
].filter(Boolean).join(' + '),
description: shortcut.description
}));
}, []);
return {
shortcuts: getShortcuts(),
isEnabled
};
};

View File

@@ -0,0 +1,335 @@
/**
* Hook de Layout Responsif - Adaptation aux différentes résolutions
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce hook gère l'adaptation de l'interface aux différentes tailles d'écran
* pour assurer une expérience utilisateur optimale sur tous les appareils.
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useTheme, useMediaQuery } from '@mui/material';
interface BreakpointValues {
xs: boolean; // < 600px
sm: boolean; // 600px - 900px
md: boolean; // 900px - 1200px
lg: boolean; // 1200px - 1536px
xl: boolean; // >= 1536px
}
interface ResponsiveLayoutConfig {
// Largeurs des panneaux selon la taille d'écran
paletteWidth: number;
propertiesWidth: number;
variablesHeight: number;
// Visibilité des éléments
showMinimap: boolean;
showVariablesPanel: boolean;
showPropertiesPanel: boolean;
// Configuration du canvas
canvasMinHeight: number;
// Configuration des tooltips
tooltipPlacement: 'top' | 'bottom' | 'left' | 'right';
// Configuration des dialogues
dialogFullScreen: boolean;
// Configuration de la grille
gridSize: number;
// Configuration des boutons
buttonSize: 'small' | 'medium' | 'large';
iconSize: 'small' | 'medium' | 'large';
}
const defaultConfigs: Record<string, ResponsiveLayoutConfig> = {
xs: {
paletteWidth: 240,
propertiesWidth: 280,
variablesHeight: 150,
showMinimap: false,
showVariablesPanel: false,
showPropertiesPanel: false,
canvasMinHeight: 400,
tooltipPlacement: 'top',
dialogFullScreen: true,
gridSize: 15,
buttonSize: 'small',
iconSize: 'small',
},
sm: {
paletteWidth: 260,
propertiesWidth: 300,
variablesHeight: 180,
showMinimap: false,
showVariablesPanel: true,
showPropertiesPanel: false,
canvasMinHeight: 500,
tooltipPlacement: 'top',
dialogFullScreen: true,
gridSize: 20,
buttonSize: 'small',
iconSize: 'small',
},
md: {
paletteWidth: 280,
propertiesWidth: 320,
variablesHeight: 200,
showMinimap: true,
showVariablesPanel: true,
showPropertiesPanel: true,
canvasMinHeight: 600,
tooltipPlacement: 'right',
dialogFullScreen: false,
gridSize: 20,
buttonSize: 'medium',
iconSize: 'medium',
},
lg: {
paletteWidth: 300,
propertiesWidth: 350,
variablesHeight: 220,
showMinimap: true,
showVariablesPanel: true,
showPropertiesPanel: true,
canvasMinHeight: 700,
tooltipPlacement: 'right',
dialogFullScreen: false,
gridSize: 25,
buttonSize: 'medium',
iconSize: 'medium',
},
xl: {
paletteWidth: 320,
propertiesWidth: 380,
variablesHeight: 250,
showMinimap: true,
showVariablesPanel: true,
showPropertiesPanel: true,
canvasMinHeight: 800,
tooltipPlacement: 'right',
dialogFullScreen: false,
gridSize: 25,
buttonSize: 'large',
iconSize: 'large',
},
};
export const useResponsiveLayout = () => {
const theme = useTheme();
// Détection des breakpoints Material-UI
const isXs = useMediaQuery(theme.breakpoints.only('xs'));
const isSm = useMediaQuery(theme.breakpoints.only('sm'));
const isMd = useMediaQuery(theme.breakpoints.only('md'));
const isLg = useMediaQuery(theme.breakpoints.only('lg'));
const isXl = useMediaQuery(theme.breakpoints.up('xl'));
const breakpoints: BreakpointValues = useMemo(() => ({
xs: isXs,
sm: isSm,
md: isMd,
lg: isLg,
xl: isXl,
}), [isXs, isSm, isMd, isLg, isXl]);
// État de la configuration actuelle
const [currentConfig, setCurrentConfig] = useState<ResponsiveLayoutConfig>(defaultConfigs.lg);
const [currentBreakpoint, setCurrentBreakpoint] = useState<string>('lg');
// Gestionnaire d'événements clavier pour l'accessibilité
const handleKeyDown = useCallback((event: KeyboardEvent) => {
// Raccourcis clavier pour basculer les panneaux (accessibilité)
if (event.altKey) {
switch (event.key) {
case 'p':
// Alt+P : Basculer le panneau de propriétés
event.preventDefault();
setCurrentConfig(prev => ({
...prev,
showPropertiesPanel: !prev.showPropertiesPanel
}));
break;
case 'v':
// Alt+V : Basculer le panneau de variables
event.preventDefault();
setCurrentConfig(prev => ({
...prev,
showVariablesPanel: !prev.showVariablesPanel
}));
break;
case 'm':
// Alt+M : Basculer la minimap
event.preventDefault();
setCurrentConfig(prev => ({
...prev,
showMinimap: !prev.showMinimap
}));
break;
}
}
}, []);
// Ajouter les écouteurs d'événements clavier
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
// Déterminer le breakpoint actuel
const getCurrentBreakpoint = useCallback((): string => {
if (breakpoints.xs) return 'xs';
if (breakpoints.sm) return 'sm';
if (breakpoints.md) return 'md';
if (breakpoints.lg) return 'lg';
if (breakpoints.xl) return 'xl';
return 'lg'; // fallback
}, [breakpoints]);
// Mettre à jour la configuration selon le breakpoint
useEffect(() => {
const newBreakpoint = getCurrentBreakpoint();
if (newBreakpoint !== currentBreakpoint) {
setCurrentBreakpoint(newBreakpoint);
setCurrentConfig(defaultConfigs[newBreakpoint]);
}
}, [getCurrentBreakpoint, currentBreakpoint]);
// Fonctions utilitaires pour les composants
const isMobile = breakpoints.xs || breakpoints.sm;
const isTablet = breakpoints.md;
const isDesktop = breakpoints.lg || breakpoints.xl;
// Fonction pour obtenir les styles responsifs d'un composant
const getResponsiveStyles = useCallback((componentName: string) => {
const baseStyles: Record<string, any> = {};
switch (componentName) {
case 'palette':
return {
...baseStyles,
width: currentConfig.paletteWidth,
display: isMobile ? 'none' : 'flex', // Masquer sur mobile
};
case 'properties':
return {
...baseStyles,
width: currentConfig.propertiesWidth,
display: currentConfig.showPropertiesPanel ? 'flex' : 'none',
};
case 'variables':
return {
...baseStyles,
height: currentConfig.variablesHeight,
display: currentConfig.showVariablesPanel ? 'block' : 'none',
};
case 'canvas':
return {
...baseStyles,
minHeight: currentConfig.canvasMinHeight,
flex: 1,
};
case 'minimap':
return {
...baseStyles,
display: currentConfig.showMinimap ? 'block' : 'none',
};
case 'toolbar':
return {
...baseStyles,
flexDirection: isMobile ? 'column' : 'row',
gap: isMobile ? 1 : 2,
};
case 'dialog':
return {
...baseStyles,
fullScreen: currentConfig.dialogFullScreen,
maxWidth: currentConfig.dialogFullScreen ? false : 'md',
};
default:
return baseStyles;
}
}, [currentConfig, isMobile]);
// Fonction pour obtenir la taille des boutons
const getButtonSize = useCallback(() => currentConfig.buttonSize, [currentConfig]);
// Fonction pour obtenir la taille des icônes
const getIconSize = useCallback(() => currentConfig.iconSize, [currentConfig]);
// Fonction pour obtenir la position des tooltips
const getTooltipPlacement = useCallback(() => currentConfig.tooltipPlacement, [currentConfig]);
// Fonction pour obtenir la taille de la grille
const getGridSize = useCallback(() => currentConfig.gridSize, [currentConfig]);
// Fonction pour déterminer si un panneau doit être en drawer sur mobile
const shouldUseDrawer = useCallback((panelName: string) => {
if (!isMobile) return false;
switch (panelName) {
case 'palette':
case 'properties':
case 'variables':
return true;
default:
return false;
}
}, [isMobile]);
// Fonction pour obtenir les dimensions de la fenêtre
const getViewportDimensions = useCallback(() => {
return {
width: window.innerWidth,
height: window.innerHeight,
availableWidth: window.innerWidth - (
(currentConfig.showPropertiesPanel ? currentConfig.propertiesWidth : 0) +
(isMobile ? 0 : currentConfig.paletteWidth)
),
availableHeight: window.innerHeight - (
currentConfig.showVariablesPanel ? currentConfig.variablesHeight : 0
) - 64, // Hauteur de l'AppBar
};
}, [currentConfig, isMobile]);
return {
// État actuel
breakpoints,
currentBreakpoint,
currentConfig,
// Détection de type d'appareil
isMobile,
isTablet,
isDesktop,
// Fonctions utilitaires
getResponsiveStyles,
getButtonSize,
getIconSize,
getTooltipPlacement,
getGridSize,
shouldUseDrawer,
getViewportDimensions,
// Valeurs directes pour faciliter l'utilisation
paletteWidth: currentConfig.paletteWidth,
propertiesWidth: currentConfig.propertiesWidth,
variablesHeight: currentConfig.variablesHeight,
showMinimap: currentConfig.showMinimap,
showVariablesPanel: currentConfig.showVariablesPanel,
showPropertiesPanel: currentConfig.showPropertiesPanel,
};
};

View File

@@ -0,0 +1,124 @@
/**
* Hook React sécurisé pour ResizeObserver
*
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*/
import { useEffect, useRef, useCallback } from 'react';
interface ResizeObserverEntry {
target: Element;
contentRect: DOMRectReadOnly;
borderBoxSize?: ReadonlyArray<ResizeObserverSize>;
contentBoxSize?: ReadonlyArray<ResizeObserverSize>;
devicePixelContentBoxSize?: ReadonlyArray<ResizeObserverSize>;
}
type ResizeCallback = (entries: ResizeObserverEntry[]) => void;
/**
* Hook sécurisé pour utiliser ResizeObserver sans erreurs de boucle infinie
*/
export const useSafeResizeObserver = (
callback: ResizeCallback,
dependencies: React.DependencyList = []
) => {
const observerRef = useRef<ResizeObserver | null>(null);
const callbackRef = useRef<ResizeCallback>(callback);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Mettre à jour la référence du callback
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Callback sécurisé avec debounce
const safeCallback = useCallback((entries: ResizeObserverEntry[]) => {
// Annuler le timeout précédent
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Debounce pour éviter les appels trop fréquents
timeoutRef.current = setTimeout(() => {
try {
callbackRef.current(entries);
} catch (error) {
// Ignorer les erreurs ResizeObserver
if (
error instanceof Error &&
!error.message.includes('ResizeObserver')
) {
console.warn('ResizeObserver callback error:', error);
}
}
}, 16); // ~60fps
}, []);
// Fonction pour observer un élément
const observe = useCallback((element: Element | null) => {
if (!element) return;
try {
// Nettoyer l'observer précédent
if (observerRef.current) {
observerRef.current.disconnect();
}
// Créer un nouvel observer
observerRef.current = new ResizeObserver(safeCallback);
observerRef.current.observe(element);
} catch (error) {
console.warn('Erreur lors de la création du ResizeObserver:', error);
}
}, [safeCallback, ...dependencies]);
// Fonction pour arrêter l'observation
const disconnect = useCallback(() => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
// Nettoyage à la destruction du composant
useEffect(() => {
return () => {
disconnect();
};
}, [disconnect]);
return { observe, disconnect };
};
/**
* Hook simplifié pour observer la taille d'un élément
*/
export const useElementSize = (
elementRef: React.RefObject<Element>,
onResize?: (size: { width: number; height: number }) => void
) => {
const { observe, disconnect } = useSafeResizeObserver(
(entries) => {
const entry = entries[0];
if (entry && onResize) {
const { width, height } = entry.contentRect;
onResize({ width, height });
}
},
[onResize]
);
useEffect(() => {
if (elementRef.current) {
observe(elementRef.current);
}
return disconnect;
}, [elementRef.current, observe, disconnect]);
};
export default useSafeResizeObserver;

View File

@@ -0,0 +1,342 @@
/**
* Hook useStepTypeResolver - Intégration du résolveur de types d'étapes
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce hook fournit une interface React pour utiliser le StepTypeResolver
* avec gestion d'état, mémorisation et optimisations de performance.
*/
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Step, Variable } from '../types';
import {
stepTypeResolver,
StepTypeResolutionResult,
ResolutionOptions,
ResolutionStats
} from '../services/StepTypeResolver';
/**
* État de résolution
*/
export interface ResolutionState {
isLoading: boolean;
result: StepTypeResolutionResult | null;
error: Error | null;
lastResolved: number;
}
/**
* Options du hook
*/
export interface UseStepTypeResolverOptions extends ResolutionOptions {
autoResolve?: boolean;
debounceMs?: number;
retryAttempts?: number;
onResolutionComplete?: (result: StepTypeResolutionResult) => void;
onResolutionError?: (error: Error) => void;
}
/**
* Résultat du hook
*/
export interface UseStepTypeResolverResult {
// État de résolution
state: ResolutionState;
// Résultat de résolution
result: StepTypeResolutionResult | null;
isLoading: boolean;
error: Error | null;
// Méthodes de résolution
resolveStep: (step: Step, options?: ResolutionOptions) => Promise<StepTypeResolutionResult>;
resolveStepSync: (step: Step) => StepTypeResolutionResult | null;
// Utilitaires
isVWBAction: (step: Step) => boolean;
invalidateCache: () => void;
getStats: () => ResolutionStats;
// État dérivé
hasParameterConfig: boolean;
parameterCount: number;
isStandardType: boolean;
resolutionSource: string | null;
}
/**
* Hook useStepTypeResolver
*/
export function useStepTypeResolver(
selectedStep: Step | null,
options: UseStepTypeResolverOptions = {}
): UseStepTypeResolverResult {
// Options par défaut
const resolverOptions = useMemo(() => ({
autoResolve: true,
debounceMs: 100,
retryAttempts: 3,
enableCache: true,
enableLogging: process.env.NODE_ENV === 'development',
fallbackToEmpty: true,
...options
}), [options]);
// État de résolution
const [state, setState] = useState<ResolutionState>({
isLoading: false,
result: null,
error: null,
lastResolved: 0
});
// Références pour éviter les re-rendus
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const retryCountRef = useRef(0);
const lastStepRef = useRef<Step | null>(null);
/**
* Résout une étape de manière asynchrone
*/
const resolveStep = useCallback(async (
step: Step,
overrideOptions?: ResolutionOptions
): Promise<StepTypeResolutionResult> => {
const finalOptions = { ...resolverOptions, ...overrideOptions };
try {
setState(prev => ({ ...prev, isLoading: true, error: null }));
const result = await stepTypeResolver.resolveParameterConfig(step, finalOptions);
setState(prev => ({
...prev,
isLoading: false,
result,
lastResolved: Date.now()
}));
// Callback de succès
if (resolverOptions.onResolutionComplete) {
resolverOptions.onResolutionComplete(result);
}
// Reset retry count
retryCountRef.current = 0;
return result;
} catch (error) {
const errorObj = error instanceof Error ? error : new Error(String(error));
// Gestion des tentatives de retry
if (retryCountRef.current < resolverOptions.retryAttempts) {
retryCountRef.current++;
console.warn(`🔄 [useStepTypeResolver] Retry ${retryCountRef.current}/${resolverOptions.retryAttempts}:`, errorObj.message);
// Retry avec délai exponentiel
const retryDelay = Math.pow(2, retryCountRef.current) * 100;
await new Promise(resolve => setTimeout(resolve, retryDelay));
return resolveStep(step, overrideOptions);
}
// Échec définitif
setState(prev => ({
...prev,
isLoading: false,
error: errorObj
}));
// Callback d'erreur
if (resolverOptions.onResolutionError) {
resolverOptions.onResolutionError(errorObj);
}
throw errorObj;
}
}, [resolverOptions]);
/**
* Résolution synchrone (depuis le cache)
*/
const resolveStepSync = useCallback((step: Step): StepTypeResolutionResult | null => {
try {
// Vérifier si le résultat est déjà en cache/état
if (state.result &&
state.result.stepType === step.type &&
Date.now() - state.lastResolved < 5000) { // Cache 5 secondes
return state.result;
}
// Tentative de résolution synchrone basique
const isVWB = stepTypeResolver.isVWBAction(step);
return {
stepType: step.type as string,
isVWBAction: isVWB,
isStandardType: !isVWB,
parameterConfig: [],
detectionMethods: { sync: true },
resolutionSource: 'fallback' as const,
timestamp: Date.now()
};
} catch (error) {
console.error('❌ [useStepTypeResolver] Erreur résolution sync:', error);
return null;
}
}, [state.result, state.lastResolved]);
/**
* Résolution automatique avec debounce
*/
const debouncedResolve = useCallback((step: Step) => {
// Annuler le timeout précédent
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Programmer la nouvelle résolution
debounceTimeoutRef.current = setTimeout(() => {
resolveStep(step).catch(error => {
console.error('❌ [useStepTypeResolver] Erreur résolution auto:', error);
});
}, resolverOptions.debounceMs);
}, [resolveStep, resolverOptions.debounceMs]);
/**
* Effet pour résolution automatique
*/
useEffect(() => {
if (!resolverOptions.autoResolve || !selectedStep) {
return;
}
// Éviter la résolution si l'étape n'a pas changé
if (lastStepRef.current &&
lastStepRef.current.id === selectedStep.id &&
lastStepRef.current.type === selectedStep.type &&
JSON.stringify(lastStepRef.current.data) === JSON.stringify(selectedStep.data)) {
return;
}
lastStepRef.current = selectedStep;
debouncedResolve(selectedStep);
// Cleanup
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, [selectedStep, resolverOptions.autoResolve, debouncedResolve]);
/**
* Vérification VWB rapide
*/
const isVWBAction = useCallback((step: Step): boolean => {
return stepTypeResolver.isVWBAction(step);
}, []);
/**
* Invalidation du cache
*/
const invalidateCache = useCallback(() => {
stepTypeResolver.invalidateCache();
setState(prev => ({
...prev,
result: null,
lastResolved: 0
}));
}, []);
/**
* Obtention des statistiques
*/
const getStats = useCallback((): ResolutionStats => {
return stepTypeResolver.getResolutionStats();
}, []);
/**
* État dérivé mémorisé
*/
const derivedState = useMemo(() => {
const result = state.result;
return {
hasParameterConfig: Boolean(result?.parameterConfig?.length),
parameterCount: result?.parameterConfig?.length || 0,
isStandardType: Boolean(result?.isStandardType),
resolutionSource: result?.resolutionSource || null
};
}, [state.result]);
/**
* Cleanup à la destruction
*/
useEffect(() => {
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, []);
return {
// État de résolution
state,
// Résultat de résolution
result: state.result,
isLoading: state.isLoading,
error: state.error,
// Méthodes de résolution
resolveStep,
resolveStepSync,
// Utilitaires
isVWBAction,
invalidateCache,
getStats,
// État dérivé
...derivedState
};
}
/**
* Hook simplifié pour vérification VWB uniquement
*/
export function useIsVWBStep(step: Step | null): boolean {
return useMemo(() => {
if (!step) return false;
return stepTypeResolver.isVWBAction(step);
}, [step]);
}
/**
* Hook pour obtenir les statistiques de résolution
*/
export function useStepTypeResolverStats(): ResolutionStats {
const [stats, setStats] = useState<ResolutionStats>(() =>
stepTypeResolver.getResolutionStats()
);
useEffect(() => {
const interval = setInterval(() => {
setStats(stepTypeResolver.getResolutionStats());
}, 1000);
return () => clearInterval(interval);
}, []);
return stats;
}
/**
* Export par défaut
*/
export default useStepTypeResolver;

View File

@@ -0,0 +1,810 @@
/**
* Hook useVWBActionDetails - Chargement lazy des détails d'actions VWB
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce hook gère le chargement lazy des détails d'actions VWB avec cache intelligent,
* gestion d'erreurs robuste, fallback vers le catalogue statique et optimisations
* de performance avec debouncing et cache multi-niveaux.
*
* Version 2.0 - Optimisations de performance et cache intelligent
*/
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { VWBCatalogAction } from '../types/catalog';
import { catalogService } from '../services/catalogService';
import { staticCatalog } from '../data/staticCatalog';
/**
* État de chargement d'une action
*/
export interface ActionLoadingState {
isLoading: boolean;
isLoaded: boolean;
error: Error | null;
lastLoaded: number;
retryCount: number;
}
/**
* Cache d'actions avec métadonnées
*/
interface ActionCacheEntry {
action: VWBCatalogAction;
loadedAt: number;
source: 'api' | 'static' | 'fallback';
isValid: boolean;
}
/**
* Options de chargement avec optimisations
*/
export interface LoadActionOptions {
forceReload?: boolean;
enableFallback?: boolean;
timeout?: number;
retryAttempts?: number;
cacheTimeout?: number;
debounceMs?: number;
priority?: 'low' | 'normal' | 'high';
batchWith?: string[]; // IDs d'actions à charger en lot
}
/**
* Résultat du hook avec optimisations
*/
export interface UseVWBActionDetailsResult {
// État global
isLoading: boolean;
hasErrors: boolean;
totalActions: number;
// Méthodes de chargement optimisées
loadAction: (actionId: string, options?: LoadActionOptions) => Promise<VWBCatalogAction | null>;
loadActionDebounced: (actionId: string, options?: LoadActionOptions) => Promise<VWBCatalogAction | null>;
loadActionsBatch: (actionIds: string[], options?: LoadActionOptions) => Promise<Map<string, VWBCatalogAction | null>>;
getAction: (actionId: string) => VWBCatalogAction | null;
preloadActions: (actionIds: string[]) => Promise<void>;
// Gestion du cache multi-niveaux
invalidateCache: (actionId?: string) => void;
warmupCache: (actionIds: string[]) => Promise<void>;
getCacheStats: () => CacheStats;
// État des actions individuelles
getActionState: (actionId: string) => ActionLoadingState;
// Validation optimisée
validateAction: (actionId: string, parameters: Record<string, any>) => Promise<boolean>;
validateActionsBatch: (actions: Array<{ id: string; parameters: Record<string, any> }>) => Promise<Map<string, boolean>>;
}
/**
* Statistiques du cache avec métriques de performance
*/
export interface CacheStats {
totalEntries: number;
apiEntries: number;
staticEntries: number;
fallbackEntries: number;
validEntries: number;
expiredEntries: number;
cacheHitRate: number;
averageLoadTime: number;
totalRequests: number;
debouncedRequests: number;
batchRequests: number;
memoryUsage: number; // Estimation en KB
}
/**
* Hook useVWBActionDetails
*/
export function useVWBActionDetails(): UseVWBActionDetailsResult {
// État du cache d'actions
const [actionCache, setActionCache] = useState<Map<string, ActionCacheEntry>>(new Map());
const [loadingStates, setLoadingStates] = useState<Map<string, ActionLoadingState>>(new Map());
// Références pour optimisation et debouncing
const loadingPromisesRef = useRef<Map<string, Promise<VWBCatalogAction | null>>>(new Map());
const loadTimesRef = useRef<number[]>([]);
const cacheAccessesRef = useRef<{ hits: number; misses: number }>({ hits: 0, misses: 0 });
const debounceTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const batchQueueRef = useRef<Map<string, { resolve: Function; reject: Function; options: LoadActionOptions }[]>>(new Map());
const performanceMetricsRef = useRef<{
totalRequests: number;
debouncedRequests: number;
batchRequests: number;
}>({ totalRequests: 0, debouncedRequests: 0, batchRequests: 0 });
/**
* Obtient l'état de chargement d'une action
*/
const getActionState = useCallback((actionId: string): ActionLoadingState => {
return loadingStates.get(actionId) || {
isLoading: false,
isLoaded: false,
error: null,
lastLoaded: 0,
retryCount: 0
};
}, [loadingStates]);
/**
* Met à jour l'état de chargement d'une action
*/
const updateActionState = useCallback((
actionId: string,
updates: Partial<ActionLoadingState>
) => {
setLoadingStates(prev => {
const current = prev.get(actionId) || {
isLoading: false,
isLoaded: false,
error: null,
lastLoaded: 0,
retryCount: 0
};
return new Map(prev).set(actionId, { ...current, ...updates });
});
}, []);
/**
* Charge une action depuis le catalogue statique (fallback)
*/
const loadFromStaticCatalog = useCallback((actionId: string): VWBCatalogAction | null => {
try {
// Recherche avec fallback intelligent
let staticAction = staticCatalog.findActionWithFallback(actionId);
if (staticAction) {
console.log('📚 [useVWBActionDetails] Action trouvée avec fallback:', {
actionId,
foundId: staticAction.id,
isFallback: staticAction.fallbackMetadata?.isFallback || false,
confidence: staticAction.fallbackMetadata?.confidence || 1.0
});
return staticAction;
}
// Créer une action de fallback générique
const fallbackAction = staticCatalog.createFallbackAction(actionId);
console.log('🔧 [useVWBActionDetails] Action de fallback générique créée:', {
actionId,
category: fallbackAction.category,
confidence: fallbackAction.fallbackMetadata?.confidence
});
return fallbackAction;
} catch (error) {
console.error('❌ [useVWBActionDetails] Erreur fallback statique:', error);
return null;
}
}, []);
/**
* Valide une entrée de cache
*/
const isCacheEntryValid = useCallback((
entry: ActionCacheEntry,
cacheTimeout: number = 300000 // 5 minutes par défaut
): boolean => {
const isNotExpired = Date.now() - entry.loadedAt < cacheTimeout;
return entry.isValid && isNotExpired;
}, []);
/**
* Charge une action avec gestion complète d'erreurs et fallback
*/
const loadAction = useCallback(async (
actionId: string,
options: LoadActionOptions = {}
): Promise<VWBCatalogAction | null> => {
const startTime = performance.now();
performanceMetricsRef.current.totalRequests++;
const loadOptions = {
forceReload: false,
enableFallback: true,
timeout: 5000,
retryAttempts: 3,
cacheTimeout: 300000, // 5 minutes
debounceMs: 0, // Pas de debounce par défaut pour loadAction direct
priority: 'normal' as const,
...options
};
try {
// Vérifier le cache si pas de rechargement forcé
if (!loadOptions.forceReload) {
const cached = actionCache.get(actionId);
if (cached && isCacheEntryValid(cached, loadOptions.cacheTimeout)) {
cacheAccessesRef.current.hits++;
console.log('🎯 [useVWBActionDetails] Cache hit:', actionId);
return cached.action;
}
cacheAccessesRef.current.misses++;
}
// Vérifier si un chargement est déjà en cours
const existingPromise = loadingPromisesRef.current.get(actionId);
if (existingPromise) {
console.log('⏳ [useVWBActionDetails] Chargement en cours, attente:', actionId);
return await existingPromise;
}
// Créer la promesse de chargement
const loadingPromise = (async (): Promise<VWBCatalogAction | null> => {
updateActionState(actionId, {
isLoading: true,
error: null
});
let lastError: Error | null = null;
// Tentatives de chargement avec retry
for (let attempt = 1; attempt <= loadOptions.retryAttempts; attempt++) {
try {
console.log(`🔄 [useVWBActionDetails] Tentative ${attempt}/${loadOptions.retryAttempts}:`, actionId);
// Chargement depuis l'API avec timeout
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Timeout de chargement')), loadOptions.timeout);
});
const loadPromise = catalogService.getActionDetails(actionId);
const action = await Promise.race([loadPromise, timeoutPromise]);
if (action) {
// Succès - mettre en cache
const cacheEntry: ActionCacheEntry = {
action,
loadedAt: Date.now(),
source: 'api',
isValid: true
};
setActionCache(prev => new Map(prev).set(actionId, cacheEntry));
updateActionState(actionId, {
isLoading: false,
isLoaded: true,
error: null,
lastLoaded: Date.now(),
retryCount: 0
});
console.log('✅ [useVWBActionDetails] Action chargée depuis l\'API:', actionId);
return action;
}
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.warn(`⚠️ [useVWBActionDetails] Tentative ${attempt} échouée:`, lastError.message);
// Délai exponentiel entre les tentatives
if (attempt < loadOptions.retryAttempts) {
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Toutes les tentatives ont échoué - essayer le fallback
if (loadOptions.enableFallback) {
console.log('🔄 [useVWBActionDetails] Tentative de fallback:', actionId);
const fallbackAction = loadFromStaticCatalog(actionId);
if (fallbackAction) {
const cacheEntry: ActionCacheEntry = {
action: fallbackAction,
loadedAt: Date.now(),
source: 'static',
isValid: true
};
setActionCache(prev => new Map(prev).set(actionId, cacheEntry));
updateActionState(actionId, {
isLoading: false,
isLoaded: true,
error: null,
lastLoaded: Date.now(),
retryCount: loadOptions.retryAttempts
});
return fallbackAction;
}
}
// Échec complet
updateActionState(actionId, {
isLoading: false,
isLoaded: false,
error: lastError,
retryCount: loadOptions.retryAttempts
});
console.error('❌ [useVWBActionDetails] Échec complet du chargement:', actionId, lastError);
return null;
})();
// Enregistrer la promesse
loadingPromisesRef.current.set(actionId, loadingPromise);
try {
const result = await loadingPromise;
// Enregistrer le temps de chargement
const loadTime = performance.now() - startTime;
loadTimesRef.current.push(loadTime);
return result;
} finally {
// Nettoyer la promesse
loadingPromisesRef.current.delete(actionId);
}
} catch (error) {
const errorObj = error instanceof Error ? error : new Error(String(error));
updateActionState(actionId, {
isLoading: false,
error: errorObj
});
console.error('❌ [useVWBActionDetails] Erreur critique:', errorObj);
return null;
}
}, [actionCache, isCacheEntryValid, updateActionState, loadFromStaticCatalog]);
/**
* Charge une action avec debouncing pour éviter les appels répétés
*/
const loadActionDebounced = useCallback(async (
actionId: string,
options: LoadActionOptions = {}
): Promise<VWBCatalogAction | null> => {
const debounceMs = options.debounceMs || 300;
performanceMetricsRef.current.debouncedRequests++;
return new Promise((resolve, reject) => {
// Annuler le timer précédent pour cette action
const existingTimer = debounceTimersRef.current.get(actionId);
if (existingTimer) {
clearTimeout(existingTimer);
}
// Créer un nouveau timer
const timer = setTimeout(async () => {
try {
const result = await loadAction(actionId, { ...options, debounceMs: 0 });
resolve(result);
} catch (error) {
reject(error);
} finally {
debounceTimersRef.current.delete(actionId);
}
}, debounceMs);
debounceTimersRef.current.set(actionId, timer);
});
}, [loadAction]);
/**
* Charge plusieurs actions en lot pour optimiser les performances
*/
const loadActionsBatch = useCallback(async (
actionIds: string[],
options: LoadActionOptions = {}
): Promise<Map<string, VWBCatalogAction | null>> => {
performanceMetricsRef.current.batchRequests++;
console.log('🚀 [useVWBActionDetails] Chargement en lot:', {
actionCount: actionIds.length,
actionIds: actionIds.slice(0, 5), // Afficher seulement les 5 premiers
hasMore: actionIds.length > 5
});
const results = new Map<string, VWBCatalogAction | null>();
const batchSize = 5; // Traiter par lots de 5 pour éviter la surcharge
// Traiter les actions par lots
for (let i = 0; i < actionIds.length; i += batchSize) {
const batch = actionIds.slice(i, i + batchSize);
// Charger le lot en parallèle
const batchPromises = batch.map(async (actionId) => {
try {
const action = await loadAction(actionId, options);
return { actionId, action };
} catch (error) {
console.error(`❌ [useVWBActionDetails] Erreur lot pour ${actionId}:`, error);
return { actionId, action: null };
}
});
const batchResults = await Promise.allSettled(batchPromises);
// Traiter les résultats du lot
batchResults.forEach((result) => {
if (result.status === 'fulfilled') {
results.set(result.value.actionId, result.value.action);
}
});
// Délai entre les lots pour éviter la surcharge
if (i + batchSize < actionIds.length) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
console.log('✅ [useVWBActionDetails] Chargement en lot terminé:', {
requested: actionIds.length,
loaded: Array.from(results.values()).filter(Boolean).length,
failed: Array.from(results.values()).filter(a => a === null).length
});
return results;
}, [loadAction]);
/**
* Préchauffe le cache avec des actions prioritaires
*/
const warmupCache = useCallback(async (actionIds: string[]): Promise<void> => {
console.log('🔥 [useVWBActionDetails] Préchauffage du cache:', actionIds.length, 'actions');
// Charger en arrière-plan avec priorité basse
const warmupPromises = actionIds.map(actionId =>
loadAction(actionId, {
enableFallback: true,
priority: 'low',
cacheTimeout: 600000 // Cache plus long pour le préchauffage (10 minutes)
}).catch(error => {
console.warn(`⚠️ [useVWBActionDetails] Échec préchauffage ${actionId}:`, error);
return null;
})
);
await Promise.allSettled(warmupPromises);
console.log('✅ [useVWBActionDetails] Préchauffage terminé');
}, [loadAction]);
/**
* Obtient une action depuis le cache
*/
const getAction = useCallback((actionId: string): VWBCatalogAction | null => {
const cached = actionCache.get(actionId);
if (cached && isCacheEntryValid(cached)) {
return cached.action;
}
return null;
}, [actionCache, isCacheEntryValid]);
/**
* Précharge plusieurs actions en parallèle
*/
const preloadActions = useCallback(async (actionIds: string[]): Promise<void> => {
console.log('🚀 [useVWBActionDetails] Préchargement de', actionIds.length, 'actions');
const loadPromises = actionIds.map(actionId =>
loadAction(actionId, { enableFallback: true })
);
try {
await Promise.allSettled(loadPromises);
console.log('✅ [useVWBActionDetails] Préchargement terminé');
} catch (error) {
console.error('❌ [useVWBActionDetails] Erreur préchargement:', error);
}
}, [loadAction]);
/**
* Invalide le cache
*/
const invalidateCache = useCallback((actionId?: string) => {
if (actionId) {
setActionCache(prev => {
const newCache = new Map(prev);
newCache.delete(actionId);
return newCache;
});
console.log('🗑️ [useVWBActionDetails] Cache invalidé pour:', actionId);
} else {
setActionCache(new Map());
setLoadingStates(new Map());
loadingPromisesRef.current.clear();
console.log('🗑️ [useVWBActionDetails] Cache complet invalidé');
}
}, []);
/**
* Obtient les statistiques du cache avec métriques de performance
*/
const getCacheStats = useCallback((): CacheStats => {
const entries = Array.from(actionCache.values());
const now = Date.now();
const apiEntries = entries.filter(e => e.source === 'api').length;
const staticEntries = entries.filter(e => e.source === 'static').length;
const fallbackEntries = entries.filter(e => e.source === 'fallback').length;
const validEntries = entries.filter(e => isCacheEntryValid(e)).length;
const expiredEntries = entries.length - validEntries;
const totalAccesses = cacheAccessesRef.current.hits + cacheAccessesRef.current.misses;
const cacheHitRate = totalAccesses > 0 ? cacheAccessesRef.current.hits / totalAccesses : 0;
const averageLoadTime = loadTimesRef.current.length > 0
? loadTimesRef.current.reduce((a, b) => a + b, 0) / loadTimesRef.current.length
: 0;
// Estimation de l'usage mémoire (approximatif)
const memoryUsage = entries.reduce((total, entry) => {
const actionSize = JSON.stringify(entry.action).length;
return total + actionSize;
}, 0) / 1024; // Convertir en KB
return {
totalEntries: entries.length,
apiEntries,
staticEntries,
fallbackEntries,
validEntries,
expiredEntries,
cacheHitRate,
averageLoadTime,
totalRequests: performanceMetricsRef.current.totalRequests,
debouncedRequests: performanceMetricsRef.current.debouncedRequests,
batchRequests: performanceMetricsRef.current.batchRequests,
memoryUsage
};
}, [actionCache, isCacheEntryValid]);
/**
* Valide une action avec ses paramètres
*/
const validateAction = useCallback(async (
actionId: string,
parameters: Record<string, any>
): Promise<boolean> => {
try {
const action = await loadAction(actionId);
if (!action) {
console.warn('⚠️ [useVWBActionDetails] Action non trouvée pour validation:', actionId);
return false;
}
// Validation de l'action elle-même si c'est une action statique
if ('fallbackMetadata' in action) {
const staticAction = action as any; // StaticCatalogAction
const validation = staticCatalog.validateStaticAction(staticAction);
if (!validation.isValid) {
console.error('❌ [useVWBActionDetails] Action invalide:', {
actionId,
errors: validation.errors
});
return false;
}
if (validation.warnings.length > 0) {
console.warn('⚠️ [useVWBActionDetails] Avertissements action:', {
actionId,
warnings: validation.warnings
});
}
}
// Validation des paramètres requis
const missingParams: string[] = [];
const invalidParams: string[] = [];
for (const [paramName, paramConfig] of Object.entries(action.parameters)) {
const paramValue = parameters[paramName];
// Vérifier les paramètres requis
if (paramConfig.required && (paramValue === undefined || paramValue === null || paramValue === '')) {
missingParams.push(paramName);
continue;
}
// Validation de type si la valeur est présente
if (paramValue !== undefined && paramValue !== null) {
const isValidType = validateParameterType(paramValue, paramConfig.type);
if (!isValidType) {
invalidParams.push(`${paramName} (attendu: ${paramConfig.type}, reçu: ${typeof paramValue})`);
}
}
}
// Rapporter les erreurs de validation
if (missingParams.length > 0) {
console.error('❌ [useVWBActionDetails] Paramètres requis manquants:', {
actionId,
missingParams
});
return false;
}
if (invalidParams.length > 0) {
console.error('❌ [useVWBActionDetails] Paramètres de type invalide:', {
actionId,
invalidParams
});
return false;
}
console.log('✅ [useVWBActionDetails] Validation réussie:', {
actionId,
parameterCount: Object.keys(parameters).length
});
return true;
} catch (error) {
console.error('❌ [useVWBActionDetails] Erreur validation:', error);
return false;
}
}, [loadAction]);
/**
* Valide le type d'un paramètre
*/
const validateParameterType = (value: any, expectedType: string): boolean => {
switch (expectedType) {
case 'string':
return typeof value === 'string';
case 'number':
return typeof value === 'number' && !isNaN(value);
case 'boolean':
return typeof value === 'boolean';
case 'VWBVisualAnchor':
return value && typeof value === 'object' && 'x' in value && 'y' in value;
default:
// Type inconnu, accepter par défaut
return true;
}
};
/**
* Valide plusieurs actions en lot
*/
const validateActionsBatch = useCallback(async (
actions: Array<{ id: string; parameters: Record<string, any> }>
): Promise<Map<string, boolean>> => {
console.log('🔍 [useVWBActionDetails] Validation en lot:', actions.length, 'actions');
const results = new Map<string, boolean>();
// Valider en parallèle avec limite de concurrence
const concurrencyLimit = 3;
for (let i = 0; i < actions.length; i += concurrencyLimit) {
const batch = actions.slice(i, i + concurrencyLimit);
const batchPromises = batch.map(async ({ id, parameters }) => {
try {
const isValid = await validateAction(id, parameters);
return { id, isValid };
} catch (error) {
console.error(`❌ [useVWBActionDetails] Erreur validation ${id}:`, error);
return { id, isValid: false };
}
});
const batchResults = await Promise.allSettled(batchPromises);
batchResults.forEach((result) => {
if (result.status === 'fulfilled') {
results.set(result.value.id, result.value.isValid);
}
});
}
const validCount = Array.from(results.values()).filter(Boolean).length;
console.log('✅ [useVWBActionDetails] Validation lot terminée:', {
total: actions.length,
valid: validCount,
invalid: actions.length - validCount
});
return results;
}, [validateAction]);
// État global dérivé
const globalState = useMemo(() => {
const states = Array.from(loadingStates.values());
return {
isLoading: states.some(s => s.isLoading),
hasErrors: states.some(s => s.error !== null),
totalActions: actionCache.size
};
}, [loadingStates, actionCache]);
// Nettoyage périodique du cache et des timers
useEffect(() => {
const cleanupInterval = setInterval(() => {
const now = Date.now();
const expiredKeys: string[] = [];
// Nettoyer le cache expiré
actionCache.forEach((entry, key) => {
if (!isCacheEntryValid(entry)) {
expiredKeys.push(key);
}
});
if (expiredKeys.length > 0) {
setActionCache(prev => {
const newCache = new Map(prev);
expiredKeys.forEach(key => newCache.delete(key));
return newCache;
});
console.log(`🧹 [useVWBActionDetails] ${expiredKeys.length} entrées expirées nettoyées`);
}
// Nettoyer les timers de debounce expirés
const expiredTimers: string[] = [];
debounceTimersRef.current.forEach((timer, actionId) => {
// Les timers sont automatiquement nettoyés, mais on peut vérifier s'il y en a trop
if (debounceTimersRef.current.size > 50) {
expiredTimers.push(actionId);
}
});
// Limiter la taille des métriques de performance
if (loadTimesRef.current.length > 1000) {
loadTimesRef.current = loadTimesRef.current.slice(-500); // Garder les 500 derniers
}
}, 60000); // Nettoyage toutes les minutes
return () => clearInterval(cleanupInterval);
}, [actionCache, isCacheEntryValid]);
// Nettoyage à la destruction du composant
useEffect(() => {
return () => {
// Nettoyer tous les timers de debounce
debounceTimersRef.current.forEach(timer => clearTimeout(timer));
debounceTimersRef.current.clear();
// Nettoyer les promesses en cours
loadingPromisesRef.current.clear();
};
}, []);
return {
// État global
...globalState,
// Méthodes de chargement optimisées
loadAction,
loadActionDebounced,
loadActionsBatch,
getAction,
preloadActions,
// Gestion du cache multi-niveaux
invalidateCache,
warmupCache,
getCacheStats,
// État des actions individuelles
getActionState,
// Validation optimisée
validateAction,
validateActionsBatch
};
}
/**
* Export par défaut
*/
export default useVWBActionDetails;

View File

@@ -13,16 +13,57 @@ import {
VWBExecutionOptions,
VWBExecutionContext
} from '../services/vwbExecutionService';
import {
Workflow,
Step,
StepExecutionState,
import {
Workflow,
Step,
StepExecutionState,
ExecutionState,
ExecutionError,
Evidence,
Variable
} from '../types';
/**
* Émet un BIP sonore d'alerte via l'API Web Audio
* Utilisé pour alerter l'utilisateur quand une erreur stoppe le workflow
*/
const playErrorBeep = async (): Promise<void> => {
try {
// Créer un contexte audio
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
if (!AudioContext) {
console.warn('Web Audio API non disponible');
return;
}
const audioCtx = new AudioContext();
// Jouer 3 bips rapides pour alerter
for (let i = 0; i < 3; i++) {
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
// Fréquence du bip (800 Hz = son d'erreur)
oscillator.frequency.value = 800;
oscillator.type = 'sine';
// Volume
gainNode.gain.value = 0.3;
const startTime = audioCtx.currentTime + (i * 0.15);
oscillator.start(startTime);
oscillator.stop(startTime + 0.1);
}
console.log('🔔 BIP BIP BIP - Alerte sonore jouée');
} catch (error) {
console.warn('Impossible de jouer le bip sonore:', error);
}
};
export interface VWBExecutionState {
status: 'idle' | 'running' | 'paused' | 'completed' | 'error';
currentStepIndex: number;
@@ -66,11 +107,15 @@ export interface UseVWBExecutionOptions {
retryAttempts?: number;
timeout?: number;
pauseOnError?: boolean;
stopOnError?: boolean; // IMPORTANT: Arrêter complètement le workflow sur erreur (par défaut: true)
skipNonVWBSteps?: boolean;
}
/**
* Hook principal pour l'exécution des workflows VWB
*
* OPTIMISATION: Utilise des refs pour les callbacks et options
* pour éviter les re-créations de fonctions à chaque render.
*/
export const useVWBExecution = (
workflow: Workflow,
@@ -84,9 +129,23 @@ export const useVWBExecution = (
retryAttempts = 3,
timeout = 30000,
pauseOnError = false,
stopOnError = true, // IMPORTANT: Par défaut, STOPPER le workflow sur erreur!
skipNonVWBSteps = false
} = options;
// OPTIMISATION: Refs pour callbacks et options (évite les re-renders)
const callbacksRef = useRef(callbacks);
const optionsRef = useRef({ autoValidate, generateEvidence, retryAttempts, timeout, pauseOnError, stopOnError, skipNonVWBSteps });
// Mettre à jour les refs quand les valeurs changent (sans causer de re-render)
useEffect(() => {
callbacksRef.current = callbacks;
}, [callbacks]);
useEffect(() => {
optionsRef.current = { autoValidate, generateEvidence, retryAttempts, timeout, pauseOnError, stopOnError, skipNonVWBSteps };
}, [autoValidate, generateEvidence, retryAttempts, timeout, pauseOnError, stopOnError, skipNonVWBSteps]);
// État d'exécution
const [executionState, setExecutionState] = useState<VWBExecutionState>({
status: 'idle',
@@ -115,6 +174,9 @@ export const useVWBExecution = (
shouldStop: false
});
// Ref pour la fonction d'exécution (évite les stale closures)
const executeWorkflowStepsRef = useRef<(steps: Step[]) => Promise<void>>(() => Promise.resolve());
// Initialiser le contexte d'exécution
useEffect(() => {
const variablesMap = variables.reduce((acc, variable) => {
@@ -170,7 +232,7 @@ export const useVWBExecution = (
return;
}
// Réinitialiser l'état
// Réinitialiser complètement l'état d'exécution
executionRef.current = {
isRunning: true,
isPaused: false,
@@ -178,29 +240,30 @@ export const useVWBExecution = (
};
const startTime = new Date();
setExecutionState(prev => ({
...prev,
startTimeRef.current = startTime; // Stocker dans la ref pour finalizeExecution
// Reset complet de l'état avant de commencer
setExecutionState({
status: 'running',
startTime,
endTime: null,
currentStepIndex: 0,
currentStep: workflow.steps[0],
totalSteps: workflow.steps.length,
completedSteps: 0,
failedSteps: 0,
startTime,
endTime: null,
duration: 0,
progress: 0,
results: [],
errors: [],
evidence: []
}));
});
try {
// Passer les steps directement pour éviter le problème de stale closure
await executeWorkflowSteps(workflow.steps);
// Utiliser la ref pour avoir toujours la dernière version de executeWorkflowSteps
await executeWorkflowStepsRef.current(workflow.steps);
} catch (error) {
console.error('Erreur lors de l\'exécution du workflow:', error);
// handleExecutionError défini plus bas, on gère l'erreur ici directement
executionRef.current.isRunning = false;
setExecutionState(prev => ({
...prev,
@@ -213,7 +276,7 @@ export const useVWBExecution = (
}]
}));
}
}, [workflow.steps]);
}, [workflow.steps, workflow.id]);
/**
* Exécuter toutes les étapes du workflow
@@ -256,16 +319,19 @@ export const useVWBExecution = (
progress: (i / steps.length) * 100
}));
// Callback de début d'étape
callbacks.onStepStart?.(step, i);
callbacks.onProgressUpdate?.(i / steps.length, step);
// Callback de début d'étape (utilise ref pour éviter stale closure)
callbacksRef.current.onStepStart?.(step, i);
callbacksRef.current.onProgressUpdate?.(i / steps.length, step);
try {
// Vérifier si c'est une étape VWB
const isVWBStep = vwbExecutionService.isVWBStep(step);
console.log(`🔍 [VWB] Étape ${step.id} isVWBStep:`, isVWBStep);
if (!isVWBStep && skipNonVWBSteps) {
// Utiliser les options depuis la ref
const opts = optionsRef.current;
if (!isVWBStep && opts.skipNonVWBSteps) {
console.log(`⏭️ [VWB] Étape ${step.id} ignorée (non-VWB)`);
continue;
}
@@ -276,10 +342,10 @@ export const useVWBExecution = (
console.log(`🎯 [VWB] Exécution VWB de l'étape ${step.id}...`);
// Exécuter l'étape VWB
const executionOptions: VWBExecutionOptions = {
timeout,
retryAttempts,
validateBeforeExecution: autoValidate,
generateEvidence
timeout: opts.timeout,
retryAttempts: opts.retryAttempts,
validateBeforeExecution: opts.autoValidate,
generateEvidence: opts.generateEvidence
};
result = await vwbExecutionService.executeStep(step, executionOptions);
@@ -303,10 +369,10 @@ export const useVWBExecution = (
// Ajouter les Evidence
if (result.evidence) {
evidence.push(...result.evidence);
callbacks.onEvidenceGenerated?.(step.id, result.evidence);
callbacksRef.current.onEvidenceGenerated?.(step.id, result.evidence);
}
callbacks.onStepComplete?.(step, result);
callbacksRef.current.onStepComplete?.(step, result);
} else {
setExecutionState(prev => ({
...prev,
@@ -315,11 +381,19 @@ export const useVWBExecution = (
if (result.error) {
errors.push(result.error);
callbacks.onStepError?.(step, result.error);
callbacksRef.current.onStepError?.(step, result.error);
}
// Arrêter si configuré pour s'arrêter sur erreur
if (pauseOnError) {
// STOPPER LE WORKFLOW SUR ERREUR (comportement par défaut)
if (opts.stopOnError) {
console.log('🛑 [VWB] ARRÊT DU WORKFLOW - Erreur détectée et stopOnError=true');
playErrorBeep(); // BIP BIP BIP pour alerter l'utilisateur
executionRef.current.shouldStop = true;
break; // Sortir immédiatement de la boucle
}
// Sinon juste mettre en pause si configuré
if (opts.pauseOnError) {
executionRef.current.isPaused = true;
}
}
@@ -330,20 +404,27 @@ export const useVWBExecution = (
const executionError: ExecutionError = {
stepId: step.id,
message: error instanceof Error ? error.message : 'Erreur inconnue',
// type: 'execution_error',
timestamp: new Date(),
// context: { stepIndex: i }
};
errors.push(executionError);
callbacks.onStepError?.(step, executionError);
callbacksRef.current.onStepError?.(step, executionError);
setExecutionState(prev => ({
...prev,
failedSteps: prev.failedSteps + 1
}));
if (pauseOnError) {
// STOPPER LE WORKFLOW SUR EXCEPTION (comportement par défaut)
if (optionsRef.current.stopOnError) {
console.log('🛑 [VWB] ARRÊT DU WORKFLOW - Exception détectée et stopOnError=true');
playErrorBeep(); // BIP BIP BIP pour alerter l'utilisateur
executionRef.current.shouldStop = true;
break; // Sortir immédiatement de la boucle
}
// Sinon juste mettre en pause si configuré
if (optionsRef.current.pauseOnError) {
console.log('⏸️ [VWB] Pause sur erreur activée');
executionRef.current.isPaused = true;
}
@@ -355,8 +436,13 @@ export const useVWBExecution = (
console.log('🏁 [VWB] Boucle terminée, finalisation...');
// Finaliser l'exécution
await finalizeExecution(results, errors, evidence);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [callbacks, autoValidate, generateEvidence, timeout, retryAttempts, pauseOnError, skipNonVWBSteps]);
// Pas de dépendances aux callbacks/options car on utilise des refs
}, []);
// Mettre à jour la ref avec la dernière version de executeWorkflowSteps
useEffect(() => {
executeWorkflowStepsRef.current = executeWorkflowSteps;
}, [executeWorkflowSteps]);
/**
* Simuler l'exécution d'une étape non-VWB
@@ -374,8 +460,18 @@ export const useVWBExecution = (
};
}, []);
// Ref pour stocker le startTime de l'exécution en cours
const startTimeRef = useRef<Date | null>(null);
const workflowStepsLengthRef = useRef(workflow.steps.length);
// Mettre à jour la ref quand le workflow change
useEffect(() => {
workflowStepsLengthRef.current = workflow.steps.length;
}, [workflow.steps.length]);
/**
* Finaliser l'exécution
* Note: Utilise des refs pour éviter les dépendances instables
*/
const finalizeExecution = useCallback(async (
results: VWBExecutionResult[],
@@ -383,8 +479,10 @@ export const useVWBExecution = (
evidence: Evidence[]
) => {
const endTime = new Date();
const duration = executionState.startTime ? endTime.getTime() - executionState.startTime.getTime() : 0;
const startTime = startTimeRef.current;
const duration = startTime ? endTime.getTime() - startTime.getTime() : 0;
const successRate = results.length > 0 ? (results.filter(r => r.success).length / results.length) * 100 : 0;
const totalSteps = workflowStepsLengthRef.current;
executionRef.current.isRunning = false;
@@ -401,10 +499,10 @@ export const useVWBExecution = (
// Créer le résumé d'exécution
const summary: VWBExecutionSummary = {
totalSteps: workflow.steps.length,
totalSteps,
completedSteps: results.filter(r => r.success).length,
failedSteps: results.filter(r => !r.success).length,
skippedSteps: workflow.steps.length - results.length,
skippedSteps: totalSteps - results.length,
duration,
successRate,
results,
@@ -412,8 +510,8 @@ export const useVWBExecution = (
evidence
};
callbacks.onExecutionComplete?.(errors.length === 0, summary);
}, [workflow.steps.length, executionState.startTime, callbacks]);
callbacksRef.current.onExecutionComplete?.(errors.length === 0, summary);
}, []); // Pas de dépendances - utilise des refs
/**
* Mettre en pause l'exécution

View File

@@ -0,0 +1,292 @@
/**
* Hook de Virtualisation - Optimisation pour les listes longues
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce hook implémente la virtualisation pour optimiser le rendu
* de listes longues en ne rendant que les éléments visibles.
*/
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
interface VirtualizationOptions {
itemHeight: number;
containerHeight: number;
overscan?: number; // Nombre d'éléments supplémentaires à rendre hors de la vue
threshold?: number; // Seuil à partir duquel activer la virtualisation
}
interface VirtualizedItem<T> {
index: number;
item: T;
style: React.CSSProperties;
}
interface VirtualizationResult<T> {
virtualItems: VirtualizedItem<T>[];
totalHeight: number;
scrollToIndex: (index: number) => void;
isVirtualized: boolean;
containerProps: {
style: React.CSSProperties;
onScroll: (event: React.UIEvent<HTMLDivElement>) => void;
ref: React.RefObject<HTMLDivElement | null>;
};
}
/**
* Hook de virtualisation pour les listes longues
*
* @param items - Liste des éléments à virtualiser
* @param options - Options de virtualisation
* @returns Résultat de la virtualisation avec éléments visibles et props
*/
export function useVirtualization<T>(
items: T[],
options: VirtualizationOptions
): VirtualizationResult<T> {
const {
itemHeight,
containerHeight,
overscan = 5,
threshold = 50,
} = options;
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
// Déterminer si la virtualisation doit être activée
const isVirtualized = items.length > threshold;
// Calculer les indices des éléments visibles
const visibleRange = useMemo(() => {
if (!isVirtualized) {
return { start: 0, end: items.length - 1 };
}
const start = Math.floor(scrollTop / itemHeight);
const visibleCount = Math.ceil(containerHeight / itemHeight);
const end = start + visibleCount - 1;
return {
start: Math.max(0, start - overscan),
end: Math.min(items.length - 1, end + overscan),
};
}, [scrollTop, itemHeight, containerHeight, overscan, items.length, isVirtualized]);
// Créer les éléments virtualisés
const virtualItems = useMemo(() => {
if (!isVirtualized) {
// Si pas de virtualisation, retourner tous les éléments
return items.map((item, index) => ({
index,
item,
style: {
height: itemHeight,
width: '100%',
},
}));
}
const result: VirtualizedItem<T>[] = [];
for (let i = visibleRange.start; i <= visibleRange.end; i++) {
if (i < items.length) {
result.push({
index: i,
item: items[i],
style: {
position: 'absolute' as const,
top: i * itemHeight,
left: 0,
right: 0,
height: itemHeight,
width: '100%',
},
});
}
}
return result;
}, [items, visibleRange, itemHeight, isVirtualized]);
// Hauteur totale du conteneur
const totalHeight = isVirtualized ? items.length * itemHeight : 'auto';
// Gestionnaire de scroll
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
setScrollTop(target.scrollTop);
}, []);
// Fonction pour scroller vers un index spécifique
const scrollToIndex = useCallback((index: number) => {
if (containerRef.current && isVirtualized) {
const scrollTop = index * itemHeight;
containerRef.current.scrollTop = scrollTop;
setScrollTop(scrollTop);
}
}, [itemHeight, isVirtualized]);
// Props pour le conteneur
const containerProps = useMemo(() => ({
style: {
height: containerHeight,
overflow: 'auto' as const,
position: 'relative' as const,
},
onScroll: handleScroll,
ref: containerRef,
}), [containerHeight, handleScroll]);
return {
virtualItems,
totalHeight: typeof totalHeight === 'number' ? totalHeight : containerHeight,
scrollToIndex,
isVirtualized,
containerProps,
};
}
/**
* Hook de virtualisation avec recherche et filtrage
*
* @param items - Liste des éléments originaux
* @param searchQuery - Requête de recherche
* @param filterFn - Fonction de filtrage
* @param searchFn - Fonction de recherche personnalisée
* @param options - Options de virtualisation
* @returns Résultat de la virtualisation avec éléments filtrés
*/
export function useVirtualizedSearch<T>(
items: T[],
searchQuery: string,
filterFn: (item: T, query: string) => boolean,
options: VirtualizationOptions,
searchFn?: (items: T[], query: string) => T[]
): VirtualizationResult<T> & {
filteredItems: T[];
totalCount: number;
filteredCount: number;
} {
// Filtrer les éléments selon la recherche
const filteredItems = useMemo(() => {
if (!searchQuery.trim()) {
return items;
}
if (searchFn) {
return searchFn(items, searchQuery);
}
return items.filter(item => filterFn(item, searchQuery));
}, [items, searchQuery, filterFn, searchFn]);
// Utiliser la virtualisation sur les éléments filtrés
const virtualizationResult = useVirtualization(filteredItems, options);
return {
...virtualizationResult,
filteredItems,
totalCount: items.length,
filteredCount: filteredItems.length,
};
}
/**
* Hook de virtualisation avec pagination
*
* @param items - Liste des éléments
* @param pageSize - Taille de la page
* @param options - Options de virtualisation
* @returns Résultat avec pagination et virtualisation
*/
export function useVirtualizedPagination<T>(
items: T[],
pageSize: number = 50,
options: VirtualizationOptions
): VirtualizationResult<T> & {
currentPage: number;
totalPages: number;
setCurrentPage: (page: number) => void;
nextPage: () => void;
prevPage: () => void;
canNextPage: boolean;
canPrevPage: boolean;
} {
const [currentPage, setCurrentPage] = useState(0);
// Calculer les éléments de la page actuelle
const paginatedItems = useMemo(() => {
const start = currentPage * pageSize;
const end = start + pageSize;
return items.slice(start, end);
}, [items, currentPage, pageSize]);
const totalPages = Math.ceil(items.length / pageSize);
// Utiliser la virtualisation sur les éléments paginés
const virtualizationResult = useVirtualization(paginatedItems, options);
// Fonctions de navigation
const nextPage = useCallback(() => {
setCurrentPage(prev => Math.min(prev + 1, totalPages - 1));
}, [totalPages]);
const prevPage = useCallback(() => {
setCurrentPage(prev => Math.max(prev - 1, 0));
}, []);
const canNextPage = currentPage < totalPages - 1;
const canPrevPage = currentPage > 0;
// Réinitialiser la page si elle dépasse le nombre total
useEffect(() => {
if (currentPage >= totalPages && totalPages > 0) {
setCurrentPage(totalPages - 1);
}
}, [currentPage, totalPages]);
return {
...virtualizationResult,
currentPage,
totalPages,
setCurrentPage,
nextPage,
prevPage,
canNextPage,
canPrevPage,
};
}
/**
* Hook de virtualisation avec tri
*
* @param items - Liste des éléments
* @param sortFn - Fonction de tri
* @param options - Options de virtualisation
* @returns Résultat avec tri et virtualisation
*/
export function useVirtualizedSort<T>(
items: T[],
sortFn: (a: T, b: T) => number,
options: VirtualizationOptions
): VirtualizationResult<T> & {
sortedItems: T[];
setSortFn: (fn: (a: T, b: T) => number) => void;
} {
const [currentSortFn, setCurrentSortFn] = useState(() => sortFn);
// Trier les éléments
const sortedItems = useMemo(() => {
return [...items].sort(currentSortFn);
}, [items, currentSortFn]);
// Utiliser la virtualisation sur les éléments triés
const virtualizationResult = useVirtualization(sortedItems, options);
return {
...virtualizationResult,
sortedItems,
setSortFn: setCurrentSortFn,
};
}

View File

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
// Suppression des erreurs ResizeObserver (doit être importé en premier)
import './utils/suppressResizeObserverErrors';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -0,0 +1,533 @@
/**
* Service StepTypeResolver - Résolution unifiée des types d'étapes
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce service fournit une logique de mapping unifiée pour résoudre les configurations
* de paramètres des étapes, avec gestion robuste des types standard et VWB.
*/
import { Step, StepType, Variable } from '../types';
import { VWBCatalogAction } from '../types/catalog';
/**
* Configuration d'un paramètre d'étape
*/
export interface ParameterConfig {
name: string;
label: string;
type: 'text' | 'number' | 'boolean' | 'select' | 'visual';
required?: boolean;
description?: string;
supportVariables?: boolean;
options?: { value: string; label: string }[];
defaultValue?: any;
min?: number;
max?: number;
step?: number;
placeholder?: string;
multiline?: boolean;
group?: string;
order?: number;
conditional?: ConditionalRule;
}
/**
* Règle conditionnelle pour l'affichage de paramètres
*/
export interface ConditionalRule {
dependsOn: string;
condition: 'equals' | 'not_equals' | 'greater_than' | 'less_than';
value: any;
}
/**
* Résultat de la résolution d'un type d'étape
*/
export interface StepTypeResolutionResult {
stepType: string;
isVWBAction: boolean;
isStandardType: boolean;
parameterConfig: ParameterConfig[];
vwbAction?: VWBCatalogAction;
detectionMethods: Record<string, boolean>;
resolutionSource: 'stepParametersConfig' | 'vwbCatalog' | 'fallback';
timestamp: number;
}
/**
* Options de résolution
*/
export interface ResolutionOptions {
enableCache?: boolean;
enableLogging?: boolean;
fallbackToEmpty?: boolean;
vwbDetectionMethods?: string[];
}
/**
* Interface du résolveur de types d'étapes
*/
export interface IStepTypeResolver {
/**
* Résout la configuration des paramètres pour une étape
*/
resolveParameterConfig(step: Step, options?: ResolutionOptions): Promise<StepTypeResolutionResult>;
/**
* Vérifie si une étape est une action VWB
*/
isVWBAction(step: Step): boolean;
/**
* Obtient les détails d'une action VWB
*/
getVWBActionDetails(step: Step): Promise<VWBCatalogAction | null>;
/**
* Invalide le cache de résolution
*/
invalidateCache(): void;
/**
* Obtient les statistiques de résolution
*/
getResolutionStats(): ResolutionStats;
}
/**
* Statistiques de résolution
*/
export interface ResolutionStats {
totalResolutions: number;
cacheHits: number;
cacheMisses: number;
vwbDetections: number;
standardDetections: number;
fallbackUsed: number;
averageResolutionTime: number;
}
/**
* Implémentation du résolveur de types d'étapes
*/
export class StepTypeResolver implements IStepTypeResolver {
private cache = new Map<string, StepTypeResolutionResult>();
private stats: ResolutionStats = {
totalResolutions: 0,
cacheHits: 0,
cacheMisses: 0,
vwbDetections: 0,
standardDetections: 0,
fallbackUsed: 0,
averageResolutionTime: 0
};
private resolutionTimes: number[] = [];
/**
* Configuration des paramètres par type d'étape standard
*/
private readonly stepParametersConfig: Record<StepType, ParameterConfig[]> = {
click: [
{
name: 'target',
label: 'Élément cible',
type: 'visual',
required: true,
description: 'Sélectionner l\'élément à cliquer',
},
{
name: 'clickType',
label: 'Type de clic',
type: 'select',
options: [
{ value: 'left', label: 'Clic gauche' },
{ value: 'right', label: 'Clic droit' },
{ value: 'double', label: 'Double-clic' },
],
defaultValue: 'left',
},
],
type: [
{
name: 'target',
label: 'Champ de saisie',
type: 'visual',
required: true,
description: 'Sélectionner le champ où saisir le texte',
},
{
name: 'text',
label: 'Texte à saisir',
type: 'text',
required: true,
supportVariables: true,
},
{
name: 'clearFirst',
label: 'Vider le champ d\'abord',
type: 'boolean',
defaultValue: true,
},
],
wait: [
{
name: 'duration',
label: 'Durée (secondes)',
type: 'number',
required: true,
min: 0.1,
max: 60,
defaultValue: 1,
},
],
condition: [
{
name: 'condition',
label: 'Condition',
type: 'text',
required: true,
supportVariables: true,
description: 'Expression conditionnelle à évaluer',
},
],
extract: [
{
name: 'target',
label: 'Élément source',
type: 'visual',
required: true,
description: 'Sélectionner l\'élément dont extraire les données',
},
{
name: 'attribute',
label: 'Attribut à extraire',
type: 'select',
options: [
{ value: 'text', label: 'Texte' },
{ value: 'value', label: 'Valeur' },
{ value: 'href', label: 'Lien (href)' },
{ value: 'src', label: 'Source (src)' },
],
defaultValue: 'text',
},
],
scroll: [
{
name: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'up', label: 'Vers le haut' },
{ value: 'down', label: 'Vers le bas' },
{ value: 'left', label: 'Vers la gauche' },
{ value: 'right', label: 'Vers la droite' },
],
defaultValue: 'down',
},
{
name: 'amount',
label: 'Quantité (pixels)',
type: 'number',
defaultValue: 300,
min: 1,
},
],
navigate: [
{
name: 'url',
label: 'URL de destination',
type: 'text',
required: true,
supportVariables: true,
},
],
screenshot: [
{
name: 'filename',
label: 'Nom du fichier',
type: 'text',
supportVariables: true,
description: 'Nom du fichier de capture (optionnel)',
},
],
};
/**
* Actions VWB connues
*/
private readonly knownVWBActions = [
'click_anchor', 'type_text', 'type_secret', 'wait_for_anchor',
'extract_text', 'screenshot_evidence', 'scroll_to_anchor',
'focus_anchor', 'hotkey', 'navigate_to_url', 'browser_back',
'verify_element_exists', 'verify_text_content'
];
/**
* Résout la configuration des paramètres pour une étape
*/
async resolveParameterConfig(
step: Step,
options: ResolutionOptions = {}
): Promise<StepTypeResolutionResult> {
const startTime = performance.now();
try {
// Options par défaut
const resolveOptions = {
enableCache: true,
enableLogging: true,
fallbackToEmpty: true,
vwbDetectionMethods: ['all'],
...options
};
// Vérifier le cache
const cacheKey = this.generateCacheKey(step, resolveOptions);
if (resolveOptions.enableCache && this.cache.has(cacheKey)) {
this.stats.cacheHits++;
const cached = this.cache.get(cacheKey)!;
if (resolveOptions.enableLogging) {
console.log('🎯 [StepTypeResolver] Cache hit:', {
stepId: step.id,
stepType: step.type,
cacheKey
});
}
return cached;
}
this.stats.cacheMisses++;
// Analyser le type d'étape
const stepTypeString = step.type as string;
// Détecter si c'est une action VWB
const vwbDetectionResult = this.detectVWBAction(step, resolveOptions);
let result: StepTypeResolutionResult;
if (vwbDetectionResult.isVWBAction) {
// Résolution pour action VWB
result = await this.resolveVWBAction(step, vwbDetectionResult, resolveOptions);
this.stats.vwbDetections++;
} else {
// Résolution pour type standard
result = this.resolveStandardType(step, resolveOptions);
this.stats.standardDetections++;
}
// Mettre en cache le résultat
if (resolveOptions.enableCache) {
this.cache.set(cacheKey, result);
}
// Logging détaillé
if (resolveOptions.enableLogging) {
console.log('🔍 [StepTypeResolver] Résolution complète:', {
stepId: step.id,
stepType: stepTypeString,
isVWBAction: result.isVWBAction,
parameterCount: result.parameterConfig.length,
resolutionSource: result.resolutionSource,
detectionMethods: result.detectionMethods
});
}
// Mettre à jour les statistiques
const resolutionTime = performance.now() - startTime;
this.resolutionTimes.push(resolutionTime);
this.stats.totalResolutions++;
this.stats.averageResolutionTime =
this.resolutionTimes.reduce((a, b) => a + b, 0) / this.resolutionTimes.length;
return result;
} catch (error) {
console.error('❌ [StepTypeResolver] Erreur de résolution:', error);
// Fallback en cas d'erreur
const fallbackResult: StepTypeResolutionResult = {
stepType: step.type as string,
isVWBAction: false,
isStandardType: false,
parameterConfig: [],
detectionMethods: { error: true },
resolutionSource: 'fallback',
timestamp: Date.now()
};
this.stats.fallbackUsed++;
return fallbackResult;
}
}
/**
* Vérifie si une étape est une action VWB
*/
isVWBAction(step: Step): boolean {
const detection = this.detectVWBAction(step, { enableLogging: false });
return detection.isVWBAction;
}
/**
* Détecte si une étape est une action VWB avec méthodes multiples
*/
private detectVWBAction(step: Step, options: ResolutionOptions): {
isVWBAction: boolean;
detectionMethods: Record<string, boolean>;
confidence: number;
} {
const stepTypeString = step.type as string;
// Méthodes de détection multiples pour robustesse
const detectionMethods = {
hasVWBFlag: Boolean(step.data?.isVWBCatalogAction),
hasVWBActionId: Boolean(step.data?.vwbActionId),
typeStartsWithVWB: stepTypeString.startsWith('vwb_'),
typeContainsAnchor: stepTypeString.includes('_anchor'),
typeContainsText: stepTypeString.includes('_text'),
typeContainsSecret: stepTypeString.includes('_secret'),
isKnownVWBAction: this.knownVWBActions.includes(stepTypeString),
hasVWBPattern: /^(click|type|wait|extract|scroll|focus|hotkey|navigate|browser|verify)_/.test(stepTypeString)
};
// Calculer la confiance basée sur le nombre de méthodes positives
const positiveDetections = Object.values(detectionMethods).filter(Boolean).length;
const confidence = positiveDetections / Object.keys(detectionMethods).length;
// Une action est considérée VWB si au moins une méthode la détecte
const isVWBAction = positiveDetections > 0;
if (options.enableLogging) {
console.log('🎯 [StepTypeResolver] Détection VWB:', {
stepType: stepTypeString,
detectionMethods,
positiveDetections,
confidence: `${(confidence * 100).toFixed(1)}%`,
isVWBAction
});
}
return {
isVWBAction,
detectionMethods,
confidence
};
}
/**
* Résout une action VWB
*/
private async resolveVWBAction(
step: Step,
vwbDetection: any,
options: ResolutionOptions
): Promise<StepTypeResolutionResult> {
try {
// Charger les détails de l'action VWB si disponible
const vwbAction = await this.getVWBActionDetails(step);
return {
stepType: step.type as string,
isVWBAction: true,
isStandardType: false,
parameterConfig: [], // Les actions VWB utilisent VWBActionProperties
vwbAction: vwbAction || undefined,
detectionMethods: vwbDetection.detectionMethods,
resolutionSource: 'vwbCatalog',
timestamp: Date.now()
};
} catch (error) {
console.error('❌ [StepTypeResolver] Erreur résolution VWB:', error);
// Fallback pour action VWB
return {
stepType: step.type as string,
isVWBAction: true,
isStandardType: false,
parameterConfig: [],
detectionMethods: vwbDetection.detectionMethods,
resolutionSource: 'fallback',
timestamp: Date.now()
};
}
}
/**
* Résout un type d'étape standard
*/
private resolveStandardType(step: Step, options: ResolutionOptions): StepTypeResolutionResult {
const stepTypeString = step.type as string;
const config = this.stepParametersConfig[stepTypeString as StepType] || [];
return {
stepType: stepTypeString,
isVWBAction: false,
isStandardType: config.length > 0,
parameterConfig: config,
detectionMethods: { standardType: config.length > 0 },
resolutionSource: 'stepParametersConfig',
timestamp: Date.now()
};
}
/**
* Obtient les détails d'une action VWB
*/
async getVWBActionDetails(step: Step): Promise<VWBCatalogAction | null> {
try {
// Simuler le chargement depuis le catalogue VWB
// Dans une implémentation réelle, ceci ferait appel au service de catalogue
const vwbActionId = step.data?.vwbActionId || step.type;
// Pour l'instant, retourner null - sera implémenté avec le service de catalogue
return null;
} catch (error) {
console.error('❌ [StepTypeResolver] Erreur chargement action VWB:', error);
return null;
}
}
/**
* Génère une clé de cache pour une résolution
*/
private generateCacheKey(step: Step, options: ResolutionOptions): string {
const stepData = {
type: step.type,
isVWBCatalogAction: step.data?.isVWBCatalogAction,
vwbActionId: step.data?.vwbActionId
};
return `${JSON.stringify(stepData)}_${JSON.stringify(options)}`;
}
/**
* Invalide le cache de résolution
*/
invalidateCache(): void {
this.cache.clear();
console.log('🗑️ [StepTypeResolver] Cache invalidé');
}
/**
* Obtient les statistiques de résolution
*/
getResolutionStats(): ResolutionStats {
return { ...this.stats };
}
}
/**
* Instance singleton du résolveur
*/
export const stepTypeResolver = new StepTypeResolver();
/**
* Export par défaut
*/
export default stepTypeResolver;

View File

@@ -0,0 +1,530 @@
/**
* Service de Capture Visuelle - Configuration des paramètres d'étapes
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Service de capture visuelle pour l'interface frontend.
* Gère la communication avec l'API backend pour la capture d'écran,
* la détection d'éléments et la génération d'embeddings visuels.
*/
import { BoundingBox } from '../types';
interface VisualMetadata {
element_type: string;
relative_position?: string;
text_content?: string;
visual_description?: string;
size_description?: string;
contextual_elements_count?: number;
accessibility_info?: Record<string, any>;
}
interface VisualTarget {
screenshot: string;
bounding_box: BoundingBox;
metadata: VisualMetadata;
confidence?: number;
signature?: string;
}
export interface CaptureOptions {
includeContext?: boolean;
highQuality?: boolean;
timeout?: number;
}
export interface DetectedElement {
id: string;
bounds: BoundingBox;
type: string;
text?: string;
confidence: number;
metadata?: Partial<VisualMetadata>;
}
export interface CaptureResult {
screenshot: string; // Base64 encoded
elements: DetectedElement[];
timestamp: string;
screenSize: { width: number; height: number };
}
export interface ValidationResult {
isValid: boolean;
confidence: number;
issues: string[];
suggestions: string[];
timestamp: string;
}
class VisualCaptureService {
private baseUrl: string;
private timeout: number;
private cache: Map<string, any>;
private cacheTimeout: number;
constructor(baseUrl: string = 'http://localhost:8000') {
this.baseUrl = baseUrl;
this.timeout = 30000; // 30 secondes
this.cache = new Map();
this.cacheTimeout = 60000; // 1 minute
}
/**
* Capture l'écran et détecte les éléments UI
*/
async captureScreen(options: CaptureOptions = {}): Promise<CaptureResult> {
const startTime = performance.now();
try {
console.log('🔍 Début de capture d\'écran...');
const response = await fetch(`${this.baseUrl}/api/visual/capture`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
include_context: options.includeContext ?? true,
high_quality: options.highQuality ?? true,
timeout: options.timeout ?? this.timeout,
}),
signal: AbortSignal.timeout(options.timeout ?? this.timeout),
});
if (!response.ok) {
throw new Error(`Erreur de capture: ${response.status} ${response.statusText}`);
}
const result: CaptureResult = await response.json();
const duration = performance.now() - startTime;
console.log(`✅ Capture terminée en ${duration.toFixed(0)}ms - ${result.elements.length} éléments détectés`);
// Mettre en cache le résultat
this.setCacheItem('last_capture', result);
return result;
} catch (error) {
const duration = performance.now() - startTime;
console.error(`❌ Erreur de capture après ${duration.toFixed(0)}ms:`, error);
// Fallback vers une capture simulée en cas d'erreur
return this.createMockCapture();
}
}
/**
* Crée une cible visuelle à partir d'un élément détecté
*/
async createVisualTarget(element: DetectedElement, screenshot: string): Promise<VisualTarget> {
const startTime = performance.now();
try {
console.log(`🎯 Création de cible visuelle pour élément ${element.type}...`);
const response = await fetch(`${this.baseUrl}/api/visual/create-target`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
element: element,
screenshot: screenshot,
}),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
throw new Error(`Erreur de création de cible: ${response.status}`);
}
const target: VisualTarget = await response.json();
const duration = performance.now() - startTime;
console.log(`✅ Cible visuelle créée en ${duration.toFixed(0)}ms (confiance: ${Math.round((target.confidence || 0) * 100)}%)`);
return target;
} catch (error) {
console.error('❌ Erreur lors de la création de cible visuelle:', error);
// Fallback vers une cible simulée
return this.createMockTarget(element, screenshot);
}
}
/**
* Valide une cible visuelle existante
*/
async validateTarget(target: VisualTarget): Promise<ValidationResult> {
const startTime = performance.now();
try {
console.log(`🔍 Validation de la cible ${target.signature}...`);
const response = await fetch(`${this.baseUrl}/api/visual/validate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
target: target,
}),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
throw new Error(`Erreur de validation: ${response.status}`);
}
const result: ValidationResult = await response.json();
const duration = performance.now() - startTime;
console.log(`✅ Validation terminée en ${duration.toFixed(0)}ms (valide: ${result.isValid})`);
return result;
} catch (error) {
console.error('❌ Erreur lors de la validation:', error);
// Fallback vers une validation simulée
return {
isValid: false,
confidence: 0,
issues: ['Erreur de connexion au service de validation'],
suggestions: ['Vérifier la connexion réseau'],
timestamp: new Date().toISOString(),
};
}
}
/**
* Met à jour la capture d'écran d'une cible
*/
async updateTargetScreenshot(target: VisualTarget): Promise<VisualTarget> {
try {
console.log(`📸 Mise à jour de la capture pour ${target.signature}...`);
const response = await fetch(`${this.baseUrl}/api/visual/update-screenshot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
target: target,
}),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
throw new Error(`Erreur de mise à jour: ${response.status}`);
}
const updatedTarget: VisualTarget = await response.json();
console.log('✅ Capture mise à jour avec succès');
return updatedTarget;
} catch (error) {
console.error('❌ Erreur lors de la mise à jour de capture:', error);
throw error;
}
}
/**
* Recherche des éléments similaires
*/
async findSimilarElements(target: VisualTarget): Promise<DetectedElement[]> {
try {
console.log(`🔍 Recherche d'éléments similaires à ${target.signature}...`);
const response = await fetch(`${this.baseUrl}/api/visual/find-similar`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
target: target,
}),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
throw new Error(`Erreur de recherche: ${response.status}`);
}
const elements: DetectedElement[] = await response.json();
console.log(`${elements.length} éléments similaires trouvés`);
return elements;
} catch (error) {
console.error('❌ Erreur lors de la recherche d\'éléments similaires:', error);
return [];
}
}
/**
* Crée une capture simulée pour les tests
*/
private createMockCapture(): CaptureResult {
console.log('🎭 Création d\'une capture simulée...');
const mockElements: DetectedElement[] = [
{
id: 'mock_button_1',
bounds: { x: 100, y: 100, width: 120, height: 40 },
type: 'button',
text: 'Connexion',
confidence: 0.95,
metadata: {
element_type: 'Bouton',
visual_description: 'Bouton avec le texte "Connexion"',
relative_position: 'en haut à gauche de l\'écran',
text_content: 'Connexion',
size_description: 'moyenne',
contextual_elements_count: 2,
accessibility_info: {
has_text: true,
tag_name: 'button',
attributes_count: 3,
is_interactive: true
}
}
},
{
id: 'mock_input_1',
bounds: { x: 300, y: 150, width: 200, height: 30 },
type: 'input',
text: 'Email',
confidence: 0.88,
metadata: {
element_type: 'Champ de saisie',
visual_description: 'Champ de saisie pour l\'email',
relative_position: 'au centre de l\'écran',
text_content: 'Email',
size_description: 'moyenne',
contextual_elements_count: 1,
accessibility_info: {
has_text: true,
tag_name: 'input',
attributes_count: 5,
is_interactive: true
}
}
},
{
id: 'mock_link_1',
bounds: { x: 400, y: 300, width: 150, height: 25 },
type: 'link',
text: 'Mot de passe oublié ?',
confidence: 0.82,
metadata: {
element_type: 'Lien',
visual_description: 'Lien "Mot de passe oublié ?"',
relative_position: 'en bas au centre de l\'écran',
text_content: 'Mot de passe oublié ?',
size_description: 'petite',
contextual_elements_count: 0,
accessibility_info: {
has_text: true,
tag_name: 'a',
attributes_count: 2,
is_interactive: true
}
}
}
];
// Créer une image simulée
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 600;
const ctx = canvas.getContext('2d')!;
// Fond blanc
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, 800, 600);
// Dessiner les éléments simulés
mockElements.forEach(element => {
const color = this.getElementColor(element.type);
// Élément
ctx.fillStyle = color;
ctx.fillRect(element.bounds.x, element.bounds.y, element.bounds.width, element.bounds.height);
// Bordure
ctx.strokeStyle = '#333333';
ctx.lineWidth = 1;
ctx.strokeRect(element.bounds.x, element.bounds.y, element.bounds.width, element.bounds.height);
// Texte
if (element.text) {
ctx.fillStyle = color === '#f5f5f5' ? '#333333' : '#ffffff';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(
element.text,
element.bounds.x + element.bounds.width / 2,
element.bounds.y + element.bounds.height / 2 + 5
);
}
});
const screenshot = canvas.toDataURL().split(',')[1]; // Enlever le préfixe data:image/png;base64,
return {
screenshot,
elements: mockElements,
timestamp: new Date().toISOString(),
screenSize: { width: 800, height: 600 }
};
}
/**
* Crée une cible visuelle simulée
*/
private createMockTarget(element: DetectedElement, screenshot: string): VisualTarget {
console.log('🎭 Création d\'une cible simulée...');
// Créer une image de l'élément avec contour
const canvas = document.createElement('canvas');
const margin = 10;
canvas.width = element.bounds.width + margin * 2;
canvas.height = element.bounds.height + margin * 2;
const ctx = canvas.getContext('2d')!;
// Fond blanc
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Élément
const color = this.getElementColor(element.type);
ctx.fillStyle = color;
ctx.fillRect(margin, margin, element.bounds.width, element.bounds.height);
// Contour vert pour la sélection
ctx.strokeStyle = '#4CAF50';
ctx.lineWidth = 3;
ctx.strokeRect(margin - 1, margin - 1, element.bounds.width + 2, element.bounds.height + 2);
// Texte
if (element.text) {
ctx.fillStyle = color === '#f5f5f5' ? '#333333' : '#ffffff';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(
element.text,
margin + element.bounds.width / 2,
margin + element.bounds.height / 2 + 5
);
}
const elementScreenshot = canvas.toDataURL().split(',')[1];
return {
screenshot: elementScreenshot,
bounding_box: element.bounds,
confidence: element.confidence,
signature: `visual_${element.id}_${Date.now()}`,
metadata: element.metadata as VisualMetadata || {
element_type: this.getElementTypeLabel(element.type),
visual_description: `${this.getElementTypeLabel(element.type)} ${element.text ? `avec le texte "${element.text}"` : ''}`,
relative_position: 'au centre de l\'écran',
text_content: element.text,
size_description: 'moyenne',
contextual_elements_count: 0,
accessibility_info: {
has_text: !!element.text,
tag_name: element.type,
attributes_count: 0,
is_interactive: ['button', 'input', 'link'].includes(element.type)
}
}
};
}
/**
* Obtient la couleur d'un type d'élément
*/
private getElementColor(type: string): string {
const colors: Record<string, string> = {
button: '#1976d2',
input: '#f5f5f5',
link: '#f59e0b',
text: '#333333',
image: '#9c27b0',
div: '#e0e0e0',
span: '#bdbdbd'
};
return colors[type] || '#666666';
}
/**
* Obtient le label d'un type d'élément
*/
private getElementTypeLabel(type: string): string {
const labels: Record<string, string> = {
button: 'Bouton',
input: 'Champ de saisie',
link: 'Lien',
text: 'Texte',
image: 'Image',
div: 'Zone de contenu',
span: 'Texte'
};
return labels[type] || 'Élément';
}
/**
* Gestion du cache
*/
private setCacheItem(key: string, value: any): void {
this.cache.set(key, {
value,
timestamp: Date.now()
});
}
private getCacheItem(key: string): any | null {
const item = this.cache.get(key);
if (!item) return null;
if (Date.now() - item.timestamp > this.cacheTimeout) {
this.cache.delete(key);
return null;
}
return item.value;
}
/**
* Nettoie le cache expiré
*/
public clearExpiredCache(): void {
const now = Date.now();
const entries = Array.from(this.cache.entries());
for (const [key, item] of entries) {
if (now - item.timestamp > this.cacheTimeout) {
this.cache.delete(key);
}
}
}
/**
* Obtient les statistiques du service
*/
public getStats(): { cacheSize: number; cacheHits: number } {
return {
cacheSize: this.cache.size,
cacheHits: 0 // À implémenter si nécessaire
};
}
}
// Instance singleton du service
export const visualCaptureService = new VisualCaptureService();
export default VisualCaptureService;

View File

@@ -0,0 +1,274 @@
/**
* Service de gestion des images d'ancres visuelles côté serveur.
*
* Auteur : Dom, Alice, Kiro - 21 janvier 2026
*
* Ce service gère l'upload et la récupération des images d'ancres
* via l'API backend, évitant le stockage base64 dans les workflows.
*/
import { BoundingBox } from '../types';
const API_BASE = 'http://localhost:5001';
export interface AnchorImageUploadResult {
success: boolean;
anchor_id: string;
thumbnail_url: string;
original_url: string;
metadata: {
anchor_id: string;
bounding_box: BoundingBox;
original_size: { width: number; height: number };
thumbnail_size: { width: number; height: number };
created_at: string;
original_file_size: number;
thumbnail_file_size: number;
extra?: Record<string, any>;
};
}
export interface AnchorMetadata {
anchor_id: string;
bounding_box: BoundingBox;
original_size: { width: number; height: number };
thumbnail_size: { width: number; height: number };
created_at: string;
original_file_size: number;
thumbnail_file_size: number;
}
export interface StorageStats {
anchor_count: number;
total_size_bytes: number;
total_size_mb: number;
data_directory: string;
}
/**
* Upload une image d'ancre vers le serveur.
*
* @param imageBase64 - Screenshot complet en base64 (avec ou sans préfixe data:)
* @param boundingBox - Zone de sélection sur l'image
* @param anchorId - ID optionnel (généré automatiquement si absent)
* @param metadata - Métadonnées additionnelles optionnelles
* @returns Résultat avec anchor_id et URLs
*/
export async function uploadAnchorImage(
imageBase64: string,
boundingBox: BoundingBox,
anchorId?: string,
metadata?: Record<string, any>
): Promise<AnchorImageUploadResult> {
console.log('📤 [anchorImageService] Upload image d\'ancre...', {
hasImage: !!imageBase64,
imageLength: imageBase64?.length || 0,
boundingBox,
anchorId
});
const response = await fetch(`${API_BASE}/api/anchor-images`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
image_base64: imageBase64,
bounding_box: boundingBox,
anchor_id: anchorId,
metadata,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Erreur HTTP ${response.status}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Erreur lors de l\'upload');
}
console.log('✅ [anchorImageService] Upload réussi:', {
anchor_id: result.anchor_id,
thumbnail_url: result.thumbnail_url,
});
return result;
}
/**
* Obtenir l'URL complète de la miniature d'une ancre.
*
* @param anchorId - ID de l'ancre
* @returns URL complète de la miniature
*/
export function getThumbnailUrl(anchorId: string): string {
return `${API_BASE}/api/anchor-images/${anchorId}/thumbnail`;
}
/**
* Obtenir l'URL complète de l'image originale d'une ancre.
*
* @param anchorId - ID de l'ancre
* @returns URL complète de l'image originale
*/
export function getOriginalUrl(anchorId: string): string {
return `${API_BASE}/api/anchor-images/${anchorId}/original`;
}
/**
* Obtenir les métadonnées d'une ancre.
*
* @param anchorId - ID de l'ancre
* @returns Métadonnées de l'ancre
*/
export async function getAnchorMetadata(anchorId: string): Promise<AnchorMetadata> {
const response = await fetch(`${API_BASE}/api/anchor-images/${anchorId}/metadata`);
if (!response.ok) {
throw new Error(`Ancre '${anchorId}' non trouvée`);
}
const result = await response.json();
return result.metadata;
}
/**
* Supprimer une image d'ancre du serveur.
*
* @param anchorId - ID de l'ancre à supprimer
* @returns true si supprimé avec succès
*/
export async function deleteAnchorImage(anchorId: string): Promise<boolean> {
const response = await fetch(`${API_BASE}/api/anchor-images/${anchorId}`, {
method: 'DELETE',
});
if (!response.ok && response.status !== 404) {
throw new Error(`Erreur lors de la suppression de l'ancre '${anchorId}'`);
}
return response.ok;
}
/**
* Lister toutes les images d'ancres stockées.
*
* @param limit - Nombre maximum d'ancres à retourner
* @param offset - Décalage pour la pagination
* @returns Liste des métadonnées des ancres
*/
export async function listAnchorImages(
limit: number = 100,
offset: number = 0
): Promise<{ anchors: AnchorMetadata[]; total: number }> {
const response = await fetch(
`${API_BASE}/api/anchor-images?limit=${limit}&offset=${offset}`
);
if (!response.ok) {
throw new Error('Erreur lors de la récupération de la liste des ancres');
}
const result = await response.json();
return {
anchors: result.anchors,
total: result.total,
};
}
/**
* Obtenir les statistiques de stockage.
*
* @returns Statistiques de stockage
*/
export async function getStorageStats(): Promise<StorageStats> {
const response = await fetch(`${API_BASE}/api/anchor-images/stats`);
if (!response.ok) {
throw new Error('Erreur lors de la récupération des statistiques');
}
const result = await response.json();
return result.stats;
}
/**
* Vérifier si une ancre existe sur le serveur.
*
* @param anchorId - ID de l'ancre
* @returns true si l'ancre existe
*/
export async function anchorExists(anchorId: string): Promise<boolean> {
try {
const response = await fetch(
`${API_BASE}/api/anchor-images/${anchorId}/metadata`,
{ method: 'HEAD' }
);
return response.ok;
} catch {
return false;
}
}
/**
* Obtenir l'URL de prévisualisation d'une ancre.
* Retourne l'URL de la miniature si disponible, sinon null.
*
* @param anchor - Ancre visuelle avec potentiellement thumbnail_url ou reference_image_base64
* @returns URL de prévisualisation ou chaîne data: pour base64 legacy
*/
export function getPreviewImageUrl(anchor: {
anchor_id?: string;
thumbnail_url?: string;
reference_image_url?: string;
reference_image_base64?: string;
}): string | null {
// Priorité 1: URL de miniature serveur
if (anchor.thumbnail_url) {
// Si l'URL est relative, ajouter le préfixe API
return anchor.thumbnail_url.startsWith('http')
? anchor.thumbnail_url
: `${API_BASE}${anchor.thumbnail_url}`;
}
// Priorité 2: URL d'image originale serveur
if (anchor.reference_image_url) {
return anchor.reference_image_url.startsWith('http')
? anchor.reference_image_url
: `${API_BASE}${anchor.reference_image_url}`;
}
// Priorité 3: Construire l'URL depuis anchor_id si présent
if (anchor.anchor_id && anchor.anchor_id.startsWith('anchor_')) {
return getThumbnailUrl(anchor.anchor_id);
}
// Fallback: base64 legacy
if (anchor.reference_image_base64) {
if (anchor.reference_image_base64.startsWith('data:')) {
return anchor.reference_image_base64;
}
return `data:image/png;base64,${anchor.reference_image_base64}`;
}
return null;
}
// Export par défaut pour compatibilité
const anchorImageService = {
uploadAnchorImage,
getThumbnailUrl,
getOriginalUrl,
getAnchorMetadata,
deleteAnchorImage,
listAnchorImages,
getStorageStats,
anchorExists,
getPreviewImageUrl,
};
export default anchorImageService;

View File

@@ -54,12 +54,12 @@ const getApiHost = (): string => {
const hostname = window.location.hostname;
// Si c'est localhost ou 127.0.0.1, garder localhost
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return 'http://localhost:5002/api';
return 'http://localhost:5001/api';
}
// Sinon utiliser le même hostname (IP) avec le port 5000
return `http://${hostname}:5000/api`;
}
return 'http://localhost:5002/api';
return 'http://localhost:5001/api';
};
// Configuration par défaut

View File

@@ -0,0 +1,326 @@
/**
* Service de Bibliothèque de Captures d'Écran
* Auteur : Dom, Alice, Kiro - 13 janvier 2026
*
* Permet de sauvegarder, organiser et réutiliser des captures d'écran
* pour éviter de refaire des captures lors du travail sur le même logiciel.
*/
export interface SavedCapture {
id: string;
name: string;
description?: string;
application?: string;
screenshot: string; // base64
thumbnailSmall: string; // base64 miniature 100px
createdAt: string;
updatedAt: string;
dimensions: {
width: number;
height: number;
};
tags: string[];
}
export interface CaptureLibraryState {
captures: SavedCapture[];
lastUpdated: string;
version: string;
}
const STORAGE_KEY = 'vwb_capture_library';
const LIBRARY_VERSION = '1.0.0';
const MAX_CAPTURES = 50; // Limite pour éviter de saturer le localStorage
/**
* Service de gestion de la bibliothèque de captures
* Utilise localStorage pour la persistance entre sessions
*/
class CaptureLibraryService {
private state: CaptureLibraryState;
constructor() {
this.state = this.loadFromStorage();
console.log('📚 [CaptureLibrary] Service initialisé avec', this.state.captures.length, 'captures');
}
/**
* Recharger les données depuis localStorage (utile après un refresh)
*/
public reload(): void {
this.state = this.loadFromStorage();
console.log('🔄 [CaptureLibrary] Rechargé:', this.state.captures.length, 'captures');
}
/**
* Charger la bibliothèque depuis localStorage
*/
private loadFromStorage(): CaptureLibraryState {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as CaptureLibraryState;
// Vérifier la version
if (parsed.version === LIBRARY_VERSION) {
return parsed;
}
}
} catch (error) {
console.warn('Erreur lors du chargement de la bibliothèque de captures:', error);
}
// Retourner un état vide par défaut
return {
captures: [],
lastUpdated: new Date().toISOString(),
version: LIBRARY_VERSION,
};
}
/**
* Sauvegarder la bibliothèque dans localStorage
*/
private saveToStorage(): void {
try {
this.state.lastUpdated = new Date().toISOString();
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
} catch (error) {
console.error('Erreur lors de la sauvegarde de la bibliothèque:', error);
// Si localStorage est plein, supprimer les plus anciennes captures
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
this.cleanupOldCaptures(10);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
} catch {
console.error('Impossible de sauvegarder même après nettoyage');
}
}
}
}
/**
* Créer une miniature d'une image base64
*/
private async createThumbnail(base64Image: string, maxSize: number = 100): Promise<string> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(base64Image);
return;
}
// Calculer les dimensions proportionnelles
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxSize) {
height = (height * maxSize) / width;
width = maxSize;
}
} else {
if (height > maxSize) {
width = (width * maxSize) / height;
height = maxSize;
}
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL('image/jpeg', 0.7));
};
img.onerror = () => resolve(base64Image);
if (base64Image.startsWith('data:')) {
img.src = base64Image;
} else {
img.src = `data:image/png;base64,${base64Image}`;
}
});
}
/**
* Obtenir les dimensions d'une image base64
*/
private async getImageDimensions(base64Image: string): Promise<{ width: number; height: number }> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve({ width: img.width, height: img.height });
img.onerror = () => resolve({ width: 0, height: 0 });
if (base64Image.startsWith('data:')) {
img.src = base64Image;
} else {
img.src = `data:image/png;base64,${base64Image}`;
}
});
}
/**
* Sauvegarder une nouvelle capture dans la bibliothèque
*/
async saveCapture(
screenshot: string,
name: string,
options: {
description?: string;
application?: string;
tags?: string[];
} = {}
): Promise<SavedCapture> {
// Vérifier la limite
if (this.state.captures.length >= MAX_CAPTURES) {
this.cleanupOldCaptures(5);
}
const id = `capture_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const dimensions = await this.getImageDimensions(screenshot);
const thumbnailSmall = await this.createThumbnail(screenshot, 100);
const capture: SavedCapture = {
id,
name,
description: options.description || '',
application: options.application || '',
screenshot,
thumbnailSmall,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
dimensions,
tags: options.tags || [],
};
this.state.captures.unshift(capture); // Ajouter en premier
this.saveToStorage();
return capture;
}
/**
* Obtenir toutes les captures
*/
getAllCaptures(): SavedCapture[] {
return [...this.state.captures];
}
/**
* Obtenir une capture par ID
*/
getCaptureById(id: string): SavedCapture | null {
return this.state.captures.find((c) => c.id === id) || null;
}
/**
* Rechercher des captures par nom ou application
*/
searchCaptures(query: string): SavedCapture[] {
const lowerQuery = query.toLowerCase();
return this.state.captures.filter(
(c) =>
c.name.toLowerCase().includes(lowerQuery) ||
c.application?.toLowerCase().includes(lowerQuery) ||
c.description?.toLowerCase().includes(lowerQuery) ||
c.tags.some((t) => t.toLowerCase().includes(lowerQuery))
);
}
/**
* Obtenir les captures par application
*/
getCapturesByApplication(application: string): SavedCapture[] {
return this.state.captures.filter(
(c) => c.application?.toLowerCase() === application.toLowerCase()
);
}
/**
* Mettre à jour une capture
*/
updateCapture(
id: string,
updates: Partial<Pick<SavedCapture, 'name' | 'description' | 'application' | 'tags'>>
): SavedCapture | null {
const index = this.state.captures.findIndex((c) => c.id === id);
if (index === -1) return null;
this.state.captures[index] = {
...this.state.captures[index],
...updates,
updatedAt: new Date().toISOString(),
};
this.saveToStorage();
return this.state.captures[index];
}
/**
* Supprimer une capture
*/
deleteCapture(id: string): boolean {
const index = this.state.captures.findIndex((c) => c.id === id);
if (index === -1) return false;
this.state.captures.splice(index, 1);
this.saveToStorage();
return true;
}
/**
* Supprimer les captures les plus anciennes
*/
private cleanupOldCaptures(count: number): void {
// Trier par date (les plus anciennes à la fin) et supprimer
this.state.captures.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
this.state.captures = this.state.captures.slice(0, MAX_CAPTURES - count);
}
/**
* Obtenir la liste des applications uniques
*/
getApplications(): string[] {
const apps = new Set<string>();
this.state.captures.forEach((c) => {
if (c.application) apps.add(c.application);
});
return Array.from(apps).sort();
}
/**
* Vider la bibliothèque
*/
clearLibrary(): void {
this.state.captures = [];
this.saveToStorage();
}
/**
* Obtenir les statistiques de la bibliothèque
*/
getStats(): {
totalCaptures: number;
applications: number;
oldestCapture: string | null;
newestCapture: string | null;
} {
return {
totalCaptures: this.state.captures.length,
applications: this.getApplications().length,
oldestCapture:
this.state.captures.length > 0
? this.state.captures[this.state.captures.length - 1].createdAt
: null,
newestCapture:
this.state.captures.length > 0 ? this.state.captures[0].createdAt : null,
};
}
}
// Export singleton
export const captureLibraryService = new CaptureLibraryService();
export default captureLibraryService;

View File

@@ -0,0 +1,976 @@
/**
* Service Catalogue VWB - Communication avec l'API Catalogue d'Actions VisionOnly
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Ce service gère toutes les communications avec l'API du catalogue d'actions VisionOnly
* du Visual Workflow Builder, incluant la liste des actions, l'exécution, et la validation.
*
* ARCHITECTURE:
* - Détection automatique de l'URL du backend (cross-machine)
* - Fallback automatique vers catalogue statique hors ligne
* - Gestion d'erreurs robuste avec messages en français
* - Types TypeScript stricts pour toutes les données
* - Cache intelligent pour optimiser les performances
* - Support du mode hors ligne gracieux
*
* NOUVEAUTÉS v2.0:
* - URL configurable et détection automatique
* - Catalogue statique de secours (5 actions de base)
* - Persistance de configuration dans localStorage
* - Gestion cross-machine robuste
*/
// Import des types du catalogue
import {
VWBCatalogAction,
VWBActionParameter,
VWBActionExample,
VWBActionCategory,
VWBActionExecutionRequest,
VWBActionExecutionResult,
VWBActionEvidence,
VWBActionError,
VWBActionValidationRequest,
VWBActionValidationResult,
VWBCatalogHealth,
VWBServiceStatus
} from '../types/catalog';
// Import du catalogue statique de secours
import {
getStaticCatalogActions,
getStaticActionsByCategory,
getStaticActionById,
searchStaticActions,
getStaticCatalogCategories,
getStaticCatalogStats,
} from '../data/staticCatalog';
// Configuration du service catalogue
interface CatalogServiceConfig {
urls: string[];
timeout: number;
retryCount: number;
cacheKey: string;
fallbackToStatic: boolean;
}
// État du service catalogue
interface CatalogServiceState {
mode: 'dynamic' | 'static' | 'offline';
currentUrl: string | null;
lastError: string | null;
lastCheck: number;
isOnline: boolean;
}
// Alias pour éviter les conflits de noms
type CatalogAction = VWBCatalogAction;
type CatalogActionCategory = VWBActionCategory;
// Types pour l'API interne (compatibles avec les types VWB)
export interface ActionExecutionRequest extends VWBActionExecutionRequest {}
export interface ActionExecutionResult extends VWBActionExecutionResult {}
export interface ActionEvidence extends VWBActionEvidence {}
export interface ActionError extends VWBActionError {}
// Types pour la validation
export interface ActionValidationRequest extends VWBActionValidationRequest {}
export interface ActionValidationResult extends VWBActionValidationResult {}
// Types pour les réponses API
interface CatalogApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
offline?: boolean;
}
interface CatalogListResponse {
actions: CatalogAction[];
total: number;
categories: string[];
screen_capturer_available: boolean;
}
interface CatalogExecutionResponse {
result: ActionExecutionResult;
}
interface CatalogValidationResponse {
validation: ActionValidationResult;
}
interface CatalogHealthResponse {
status: string;
services: {
screen_capturer: boolean;
actions: number;
screen_capturer_method: string;
};
timestamp: string;
version: string;
}
/**
* Service de gestion du catalogue d'actions VisionOnly pour le VWB
* Fournit une interface TypeScript typée pour l'API du catalogue
* avec détection automatique d'URL et fallback statique
*/
class CatalogService {
private cache: Map<string, { data: any; timestamp: number }> = new Map();
private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
private readonly API_BASE_PATH = '/api/vwb/catalog';
// Configuration par défaut
private config: CatalogServiceConfig = {
urls: [],
timeout: 2000,
retryCount: 3,
cacheKey: 'vwb_catalog_config',
fallbackToStatic: true,
};
// État du service
private state: CatalogServiceState = {
mode: 'offline',
currentUrl: null,
lastError: null,
lastCheck: 0,
isOnline: false,
};
constructor() {
this.initializeConfig();
this.loadPersistedConfig();
}
/**
* Initialiser la configuration avec détection automatique d'URLs
*/
private initializeConfig(): void {
// URLs à tester par ordre de priorité
const candidateUrls: string[] = [];
// 1. Variable d'environnement (priorité maximale)
const envUrl = process.env.REACT_APP_CATALOG_URL;
if (envUrl) {
candidateUrls.push(envUrl);
}
// 2. Paramètre URL
const urlParams = new URLSearchParams(window.location.search);
const paramUrl = urlParams.get('catalogUrl');
if (paramUrl) {
candidateUrls.push(paramUrl);
}
// 3. Même origine que le frontend (pour déploiements intégrés)
const currentOrigin = window.location.origin;
candidateUrls.push(currentOrigin);
// 4. Localhost standard (développement) - Port 5001 en priorité
if (!candidateUrls.includes('http://localhost:5001')) {
candidateUrls.push('http://localhost:5001');
}
if (!candidateUrls.includes('http://localhost:5001')) {
candidateUrls.push('http://localhost:5001');
}
// 5. IP locale détectée (cross-machine)
try {
const localIp = this.detectLocalIp();
if (localIp && localIp !== '127.0.0.1') {
candidateUrls.push(`http://${localIp}:5000`);
candidateUrls.push(`http://${localIp}:5004`);
}
} catch (error) {
console.warn('Impossible de détecter l\'IP locale:', error);
}
this.config.urls = candidateUrls;
console.log('🔍 URLs candidates pour le catalogue:', candidateUrls);
}
/**
* Détecter l'IP locale (approximation basée sur WebRTC)
*/
private detectLocalIp(): string | null {
// Note: Cette méthode est limitée par les restrictions de sécurité des navigateurs
// Elle fournit une estimation basée sur l'URL courante
try {
const hostname = window.location.hostname;
// Si on est déjà sur une IP, l'utiliser
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
return hostname;
}
// Sinon, retourner null pour utiliser les autres méthodes
return null;
} catch (error) {
return null;
}
}
/**
* Charger la configuration persistée depuis localStorage
*/
private loadPersistedConfig(): void {
try {
const stored = localStorage.getItem(this.config.cacheKey);
if (stored) {
const parsed = JSON.parse(stored);
// Vérifier si la configuration n'est pas expirée (24h)
const age = Date.now() - parsed.timestamp;
const maxAge = 24 * 60 * 60 * 1000; // 24 heures
if (age < maxAge && parsed.url) {
// Mettre l'URL fonctionnelle en première position
this.config.urls = [
parsed.url,
...this.config.urls.filter(url => url !== parsed.url)
];
console.log('📦 Configuration persistée chargée:', parsed.url);
}
}
} catch (error) {
console.warn('Erreur lors du chargement de la configuration persistée:', error);
}
}
/**
* Persister la configuration fonctionnelle
*/
private persistConfig(workingUrl: string): void {
try {
const config = {
url: workingUrl,
timestamp: Date.now(),
};
localStorage.setItem(this.config.cacheKey, JSON.stringify(config));
console.log('💾 Configuration persistée:', workingUrl);
} catch (error) {
console.warn('Erreur lors de la persistance de configuration:', error);
}
}
/**
* Détecter automatiquement l'URL du backend fonctionnelle
*/
private async detectBackendUrl(): Promise<string | null> {
console.log('🔍 Détection automatique de l\'URL du backend...');
for (const url of this.config.urls) {
try {
console.log(`⏳ Test de ${url}...`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
const response = await fetch(`${url}/health`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
console.log(`✅ Backend trouvé sur ${url}`);
this.state.currentUrl = url;
this.state.isOnline = true;
this.state.mode = 'dynamic';
this.state.lastError = null;
// Persister la configuration fonctionnelle
this.persistConfig(url);
return url;
}
} catch (error) {
console.log(`${url} non accessible:`, error instanceof Error ? error.message : error);
continue;
}
}
console.log('🔴 Aucun backend accessible, passage en mode statique');
this.state.mode = 'static';
this.state.isOnline = false;
this.state.currentUrl = null;
this.state.lastError = 'Aucun backend catalogue accessible';
return null;
}
/**
* Forcer une nouvelle détection d'URL
*/
async forceUrlDetection(): Promise<boolean> {
console.log('🔄 Détection forcée de l\'URL du backend...');
// Réinitialiser l'état
this.state.lastCheck = 0;
// Relancer la détection
const url = await this.detectBackendUrl();
this.state.lastCheck = Date.now();
return url !== null;
}
/**
* Obtenir l'état actuel du service
*/
getServiceState(): CatalogServiceState {
return { ...this.state };
}
/**
* Effectuer une requête vers l'API catalogue avec gestion d'erreurs et fallback
*/
private async makeRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
// Vérifier si on a une URL fonctionnelle ou tenter la détection
if (!this.state.currentUrl || !this.state.isOnline) {
const detectedUrl = await this.detectBackendUrl();
if (!detectedUrl) {
throw new Error('Service catalogue hors ligne - Mode statique activé');
}
}
try {
const fullEndpoint = `${this.API_BASE_PATH}${endpoint}`;
const response = await fetch(`${this.state.currentUrl}${fullEndpoint}`, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
const errorText = await response.text();
let errorData: any = {};
try {
errorData = JSON.parse(errorText);
} catch {
errorData = { message: errorText };
}
throw new Error(
errorData.error ||
errorData.message ||
`Erreur API catalogue: ${response.status}`
);
}
const data = await response.json();
if (!data.success && data.error) {
throw new Error(data.error);
}
// Marquer comme en ligne si la requête réussit
this.state.isOnline = true;
this.state.lastError = null;
return data;
} catch (error) {
console.error(`Erreur requête catalogue ${endpoint}:`, error);
// Marquer comme hors ligne
this.state.isOnline = false;
this.state.lastError = error instanceof Error ? error.message : 'Erreur inconnue';
// Gestion gracieuse des erreurs réseau
if (error instanceof TypeError && error.message.includes('fetch')) {
this.state.mode = 'static';
throw new Error('Service catalogue hors ligne - Mode statique activé');
}
throw error;
}
}
/**
* Vérifier si une donnée en cache est encore valide
*/
private isCacheValid(cacheKey: string): boolean {
const cached = this.cache.get(cacheKey);
if (!cached) return false;
return Date.now() - cached.timestamp < this.CACHE_DURATION;
}
/**
* Obtenir une donnée du cache ou null si invalide
*/
private getCachedData<T>(cacheKey: string): T | null {
if (!this.isCacheValid(cacheKey)) {
this.cache.delete(cacheKey);
return null;
}
return this.cache.get(cacheKey)?.data || null;
}
/**
* Mettre en cache une donnée
*/
private setCachedData(cacheKey: string, data: any): void {
this.cache.set(cacheKey, {
data,
timestamp: Date.now(),
});
}
/**
* Récupérer la liste des actions disponibles dans le catalogue
* Avec fallback automatique vers le catalogue statique
*
* @param category - Filtrer par catégorie (optionnel)
* @param search - Terme de recherche (optionnel)
* @returns Liste des actions avec métadonnées
*/
async getActions(
category?: CatalogActionCategory,
search?: string
): Promise<{
actions: CatalogAction[];
total: number;
categories: string[];
screenCapturerAvailable: boolean;
mode: 'dynamic' | 'static';
}> {
// Tenter de charger depuis le backend dynamique
const cacheKey = `actions_${category || 'all'}_${search || ''}`;
const cached = this.getCachedData<{
actions: CatalogAction[];
total: number;
categories: string[];
screenCapturerAvailable: boolean;
}>(cacheKey);
if (cached) {
return { ...cached, mode: 'dynamic' };
}
try {
let endpoint = '/actions';
const params = new URLSearchParams();
if (category) {
params.append('category', category);
}
if (search) {
params.append('search', search);
}
if (params.toString()) {
endpoint += `?${params.toString()}`;
}
const response = await this.makeRequest<CatalogListResponse>(endpoint);
const result = {
actions: response.actions,
total: response.total,
categories: response.categories,
screenCapturerAvailable: response.screen_capturer_available,
};
this.setCachedData(cacheKey, result);
return { ...result, mode: 'dynamic' as const };
} catch (error) {
console.warn('Erreur catalogue dynamique, utilisation du catalogue statique:', error);
return this.getStaticActions(category, search);
}
}
/**
* Récupérer les actions du catalogue statique (mode hors ligne)
*/
private getStaticActions(
category?: CatalogActionCategory,
search?: string
): {
actions: CatalogAction[];
total: number;
categories: string[];
screenCapturerAvailable: boolean;
mode: 'static';
} {
let actions: CatalogAction[];
if (search) {
actions = searchStaticActions(search);
} else if (category) {
actions = getStaticActionsByCategory(category);
} else {
actions = getStaticCatalogActions();
}
const categories = getStaticCatalogCategories().map(cat => cat.id);
return {
actions,
total: actions.length,
categories,
screenCapturerAvailable: false, // Pas de capture d'écran en mode statique
mode: 'static',
};
}
/**
* Récupérer les détails d'une action spécifique
* Avec fallback vers le catalogue statique
*
* @param actionId - Identifiant de l'action
* @returns Détails complets de l'action
*/
async getActionDetails(actionId: string): Promise<CatalogAction | null> {
if (!actionId || actionId.trim().length === 0) {
throw new Error('L\'identifiant de l\'action est obligatoire');
}
try {
// Vérifier le cache
const cacheKey = `action_details_${actionId}`;
const cached = this.getCachedData<{ action: CatalogAction }>(cacheKey);
if (cached) {
return cached.action;
}
// Effectuer la requête
const response = await this.makeRequest<{ action: CatalogAction }>(
`/actions/${actionId}`
);
// Mettre en cache
this.setCachedData(cacheKey, response);
return response.action;
} catch (error) {
console.warn(`Erreur catalogue dynamique pour action ${actionId}, recherche en mode statique:`, error);
// Fallback vers le catalogue statique
return getStaticActionById(actionId);
}
}
/**
* Exécuter une action du catalogue
*
* @param request - Configuration de l'action à exécuter
* @returns Résultat de l'exécution avec evidence
*/
async executeAction(request: ActionExecutionRequest): Promise<ActionExecutionResult> {
// Validation des paramètres
if (!request.type || request.type.trim().length === 0) {
throw new Error('Le type d\'action est obligatoire');
}
if (!request.parameters || typeof request.parameters !== 'object') {
throw new Error('Les paramètres de l\'action sont obligatoires');
}
try {
console.log(`🚀 Exécution de l'action ${request.type}...`);
// Effectuer la requête d'exécution
const response = await this.makeRequest<CatalogExecutionResponse>('/execute', {
method: 'POST',
body: JSON.stringify(request),
});
const result = response.result;
// Log du résultat
const statusEmoji = result.status === 'success' ? '✅' : '❌';
console.log(
`${statusEmoji} Action ${request.type} terminée en ${result.execution_time_ms}ms`
);
return result;
} catch (error) {
console.error(`Erreur lors de l'exécution de l'action ${request.type}:`, error);
// Créer un résultat d'erreur standardisé
const errorResult: ActionExecutionResult = {
action_id: request.action_id || `error_${Date.now()}`,
step_id: request.step_id || `step_${Date.now()}`,
status: 'error',
start_time: new Date().toISOString(),
end_time: new Date().toISOString(),
execution_time_ms: 0,
output_data: {},
evidence_list: [],
error: {
error_id: `error_${Date.now()}`,
error_type: 'action_execution_failed',
severity: 'high',
message: error instanceof Error ? error.message : 'Erreur inconnue',
timestamp: new Date().toISOString(),
},
retry_count: 0,
workflow_id: request.workflow_id,
user_id: request.user_id,
};
return errorResult;
}
}
/**
* Valider la configuration d'une action sans l'exécuter
*
* @param request - Configuration de l'action à valider
* @returns Résultat de la validation avec erreurs et suggestions
*/
async validateAction(request: ActionValidationRequest): Promise<ActionValidationResult> {
// Validation des paramètres
if (!request.type || request.type.trim().length === 0) {
throw new Error('Le type d\'action est obligatoire');
}
try {
// Effectuer la requête de validation
const response = await this.makeRequest<CatalogValidationResponse>('/validate', {
method: 'POST',
body: JSON.stringify(request),
});
// S'assurer que la réponse contient une validation valide
if (response && response.validation) {
return response.validation;
}
// Si pas de validation dans la réponse, retourner un résultat valide par défaut
return {
is_valid: true,
errors: [],
warnings: [],
suggestions: [],
};
} catch (error) {
console.error(`Erreur lors de la validation de l'action ${request.type}:`, error);
// Retourner un résultat d'erreur
return {
is_valid: false,
errors: [{
parameter: 'general',
message: error instanceof Error ? error.message : 'Erreur de validation',
code: 'validation_error',
severity: 'error',
}],
warnings: [],
suggestions: [{
type: 'best_practice',
message: 'Vérifiez la configuration de l\'action',
priority: 'medium',
}],
};
}
}
/**
* Vérifier la santé du service catalogue
*
* @returns État du service avec informations de diagnostic
*/
async getHealth(): Promise<{
status: string;
services: {
screenCapturer: boolean;
actions: number;
screenCapturerMethod: string;
};
timestamp: string;
version: string;
}> {
try {
const response = await this.makeRequest<CatalogHealthResponse>('/health');
return {
status: response.status,
services: {
screenCapturer: response.services.screen_capturer,
actions: response.services.actions,
screenCapturerMethod: response.services.screen_capturer_method,
},
timestamp: response.timestamp,
version: response.version,
};
} catch (error) {
console.error('Erreur lors de la vérification de santé du catalogue:', error);
return {
status: 'offline',
services: {
screenCapturer: false,
actions: 0,
screenCapturerMethod: 'unavailable',
},
timestamp: new Date().toISOString(),
version: 'unknown',
};
}
}
/**
* Obtenir les catégories d'actions disponibles
* Avec fallback vers le catalogue statique
*
* @returns Liste des catégories avec métadonnées
*/
async getCategories(): Promise<Array<{
id: CatalogActionCategory;
name: string;
description: string;
icon: string;
actionCount: number;
mode: 'dynamic' | 'static';
}>> {
try {
// Récupérer toutes les actions pour calculer les statistiques
const { actions, categories } = await this.getActions();
// Définir les métadonnées des catégories
const categoryMetadata: Record<CatalogActionCategory, {
name: string;
description: string;
icon: string;
}> = {
vision_ui: {
name: 'Interactions Visuelles',
description: 'Cliquer, saisir, glisser-déposer sur des éléments visuels',
icon: '🖱️',
},
control: {
name: 'Contrôle de Flux',
description: 'Conditions, boucles et synchronisation basées sur la vision',
icon: '🔀',
},
data: {
name: 'Extraction de Données',
description: 'Extraire texte, tableaux, télécharger des fichiers',
icon: '📊',
},
intelligence: {
name: 'Intelligence IA',
description: 'Analyse et traitement intelligent par IA',
icon: '🤖',
},
database: {
name: 'Base de Données',
description: 'Lire et enregistrer des données en base',
icon: '💾',
},
validation: {
name: 'Validation',
description: 'Vérifier la présence et le contenu des éléments',
icon: '✅',
},
};
// Construire la liste des catégories avec statistiques
return categories.map(categoryId => {
const category = categoryId as CatalogActionCategory;
const metadata = categoryMetadata[category] || {
name: category,
description: `Actions de type ${category}`,
icon: '📋',
};
const actionCount = actions.filter(action => action.category === category).length;
return {
id: category,
name: metadata.name,
description: metadata.description,
icon: metadata.icon,
actionCount,
mode: this.state.mode === 'offline' ? 'static' : this.state.mode,
};
});
} catch (error) {
console.warn('Erreur catalogue dynamique, utilisation du catalogue statique:', error);
// Fallback vers le catalogue statique
return getStaticCatalogCategories().map(cat => ({
...cat,
mode: 'static' as const,
}));
}
}
/**
* Rechercher des actions par terme
* Avec fallback vers le catalogue statique
*
* @param searchTerm - Terme de recherche
* @returns Actions correspondant au terme de recherche
*/
async searchActions(searchTerm: string): Promise<CatalogAction[]> {
if (!searchTerm || searchTerm.trim().length === 0) {
return [];
}
try {
const { actions } = await this.getActions(undefined, searchTerm.trim());
return actions;
} catch (error) {
console.warn('Erreur recherche catalogue dynamique, utilisation du catalogue statique:', error);
// Fallback vers le catalogue statique
return searchStaticActions(searchTerm.trim());
}
}
/**
* Vider le cache du service
*/
clearCache(): void {
this.cache.clear();
console.log('Cache du service catalogue vidé');
}
/**
* Obtenir les statistiques du cache
*/
getCacheStats(): {
size: number;
keys: string[];
oldestEntry: string | null;
newestEntry: string | null;
} {
const entries = Array.from(this.cache.entries());
if (entries.length === 0) {
return {
size: 0,
keys: [],
oldestEntry: null,
newestEntry: null,
};
}
// Trier par timestamp
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
return {
size: entries.length,
keys: entries.map(([key]) => key),
oldestEntry: entries[0][0],
newestEntry: entries[entries.length - 1][0],
};
}
/**
* Obtenir les statistiques complètes du service
*/
async getServiceStats(): Promise<{
mode: 'dynamic' | 'static' | 'offline';
isOnline: boolean;
currentUrl: string | null;
lastError: string | null;
lastCheck: number;
cacheStats: {
size: number;
keys: string[];
oldestEntry: string | null;
newestEntry: string | null;
};
catalogStats: any;
}> {
const cacheStats = this.getCacheStats();
let catalogStats;
try {
if (this.state.mode === 'static') {
catalogStats = getStaticCatalogStats();
} else {
const { actions, total } = await this.getActions();
catalogStats = {
totalActions: total,
mode: this.state.mode,
actionsLoaded: actions.length,
};
}
} catch (error) {
catalogStats = { error: 'Impossible de récupérer les statistiques' };
}
return {
mode: this.state.mode,
isOnline: this.state.isOnline,
currentUrl: this.state.currentUrl,
lastError: this.state.lastError,
lastCheck: this.state.lastCheck,
cacheStats,
catalogStats,
};
}
/**
* Réinitialiser complètement le service
*/
async reset(): Promise<void> {
console.log('🔄 Réinitialisation complète du service catalogue...');
// Vider le cache
this.clearCache();
// Supprimer la configuration persistée
try {
localStorage.removeItem(this.config.cacheKey);
} catch (error) {
console.warn('Erreur lors de la suppression de la configuration persistée:', error);
}
// Réinitialiser l'état
this.state = {
mode: 'offline',
currentUrl: null,
lastError: null,
lastCheck: 0,
isOnline: false,
};
// Réinitialiser la configuration
this.initializeConfig();
// Relancer la détection
await this.forceUrlDetection();
console.log('✅ Service catalogue réinitialisé');
}
}
// Instance singleton du service catalogue
export const catalogService = new CatalogService();
// Export des types pour utilisation externe (sans conflits)
export type {
CatalogAction,
CatalogActionCategory,
};
export default CatalogService;

View File

@@ -0,0 +1,504 @@
/**
* Service Evidence d'Exécution - Gestion centralisée des Evidence VWB
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Ce service centralise la gestion des Evidence pendant l'exécution des workflows VWB,
* avec persistance, synchronisation et optimisations de performance.
*/
import { Evidence, Step } from '../types';
import { VWBExecutionResult } from './vwbExecutionService';
export interface EvidenceExecutionConfig {
maxEvidencePerStep: number;
maxTotalEvidence: number;
autoCleanupInterval: number;
persistToStorage: boolean;
compressionEnabled: boolean;
}
export interface EvidenceMetrics {
totalEvidence: number;
evidenceByStep: Record<string, number>;
evidenceByType: Record<string, number>;
averageSize: number;
oldestTimestamp: Date | null;
newestTimestamp: Date | null;
}
export interface EvidenceQuery {
stepId?: string;
type?: string;
action_id?: string;
dateFrom?: Date;
dateTo?: Date;
limit?: number;
offset?: number;
sortBy?: 'timestamp' | 'type' | 'size';
sortOrder?: 'asc' | 'desc';
}
/**
* Service principal pour la gestion des Evidence d'exécution
*/
export class EvidenceExecutionService {
private static instance: EvidenceExecutionService;
private config: EvidenceExecutionConfig;
private evidenceStore: Map<string, Evidence[]> = new Map();
private allEvidence: Evidence[] = [];
private listeners: Set<(evidence: Evidence[], stepId?: string) => void> = new Set();
private cleanupTimer: NodeJS.Timeout | null = null;
private constructor(config: Partial<EvidenceExecutionConfig> = {}) {
this.config = {
maxEvidencePerStep: 50,
maxTotalEvidence: 200,
autoCleanupInterval: 5 * 60 * 1000, // 5 minutes
persistToStorage: true,
compressionEnabled: false,
...config,
};
this.initializeService();
}
public static getInstance(config?: Partial<EvidenceExecutionConfig>): EvidenceExecutionService {
if (!EvidenceExecutionService.instance) {
EvidenceExecutionService.instance = new EvidenceExecutionService(config);
}
return EvidenceExecutionService.instance;
}
/**
* Initialiser le service
*/
private initializeService(): void {
// Charger les Evidence depuis le stockage
this.loadFromStorage();
// Démarrer le nettoyage automatique
if (this.config.autoCleanupInterval > 0) {
this.startAutoCleanup();
}
// Écouter les changements de visibilité pour optimiser les performances
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
}
}
/**
* Ajouter une Evidence pour une étape
*/
public addEvidence(stepId: string, evidence: Evidence): void {
// Vérifier les limites
if (this.allEvidence.length >= this.config.maxTotalEvidence) {
this.performCleanup();
}
// Obtenir les Evidence de l'étape
let stepEvidence = this.evidenceStore.get(stepId) || [];
// Vérifier la limite par étape
if (stepEvidence.length >= this.config.maxEvidencePerStep) {
stepEvidence = stepEvidence.slice(1); // Supprimer la plus ancienne
}
// Ajouter la nouvelle Evidence
stepEvidence.push(evidence);
this.evidenceStore.set(stepId, stepEvidence);
// Mettre à jour la liste globale
this.updateAllEvidence();
// Sauvegarder si activé
if (this.config.persistToStorage) {
this.saveToStorage();
}
// Notifier les listeners
this.notifyListeners(stepEvidence, stepId);
}
/**
* Ajouter plusieurs Evidence pour une étape
*/
public addMultipleEvidence(stepId: string, evidenceList: Evidence[]): void {
evidenceList.forEach(evidence => this.addEvidence(stepId, evidence));
}
/**
* Traiter les résultats d'exécution VWB
*/
public processExecutionResult(result: VWBExecutionResult): void {
if (result.evidence && result.evidence.length > 0) {
this.addMultipleEvidence(result.stepId, result.evidence);
}
}
/**
* Obtenir les Evidence d'une étape
*/
public getEvidenceByStep(stepId: string): Evidence[] {
return this.evidenceStore.get(stepId) || [];
}
/**
* Obtenir toutes les Evidence
*/
public getAllEvidence(): Evidence[] {
return [...this.allEvidence];
}
/**
* Rechercher des Evidence
*/
public searchEvidence(query: EvidenceQuery): Evidence[] {
let results = this.allEvidence;
// Filtrer par étape
if (query.stepId) {
results = this.getEvidenceByStep(query.stepId);
}
// Filtrer par type
if (query.action_id) {
results = results.filter(evidence => evidence.action_id === query.action_id);
}
// Filtrer par date
if (query.dateFrom) {
results = results.filter(evidence =>
new Date(evidence.captured_at) >= query.dateFrom!
);
}
if (query.dateTo) {
results = results.filter(evidence =>
new Date(evidence.captured_at) <= query.dateTo!
);
}
// Trier
if (query.sortBy) {
results.sort((a, b) => {
let comparison = 0;
switch (query.sortBy) {
case 'timestamp':
comparison = new Date(a.captured_at).getTime() - new Date(b.captured_at).getTime();
break;
case 'type':
comparison = a.action_id.localeCompare(b.action_id);
break;
case 'size':
const sizeA = this.getEvidenceSize(a);
const sizeB = this.getEvidenceSize(b);
comparison = sizeA - sizeB;
break;
}
return query.sortOrder === 'desc' ? -comparison : comparison;
});
}
// Pagination
if (query.offset || query.limit) {
const start = query.offset || 0;
const end = query.limit ? start + query.limit : undefined;
results = results.slice(start, end);
}
return results;
}
/**
* Obtenir les métriques des Evidence
*/
public getMetrics(): EvidenceMetrics {
const evidenceByStep: Record<string, number> = {};
const evidenceByType: Record<string, number> = {};
let totalSize = 0;
let oldestTimestamp: Date | null = null;
let newestTimestamp: Date | null = null;
// Calculer les métriques par étape
this.evidenceStore.forEach((evidence, stepId) => {
evidenceByStep[stepId] = evidence.length;
});
// Calculer les métriques globales
this.allEvidence.forEach(evidence => {
// Par type
evidenceByType[evidence.action_id] = (evidenceByType[evidence.action_id] || 0) + 1;
// Taille
totalSize += this.getEvidenceSize(evidence);
// Timestamps
const timestamp = new Date(evidence.captured_at);
if (!oldestTimestamp || timestamp < oldestTimestamp) {
oldestTimestamp = timestamp;
}
if (!newestTimestamp || timestamp > newestTimestamp) {
newestTimestamp = timestamp;
}
});
return {
totalEvidence: this.allEvidence.length,
evidenceByStep,
evidenceByType,
averageSize: this.allEvidence.length > 0 ? totalSize / this.allEvidence.length : 0,
oldestTimestamp,
newestTimestamp,
};
}
/**
* Nettoyer les Evidence d'une étape
*/
public clearStepEvidence(stepId: string): void {
this.evidenceStore.delete(stepId);
this.updateAllEvidence();
if (this.config.persistToStorage) {
this.saveToStorage();
}
this.notifyListeners([], stepId);
}
/**
* Nettoyer toutes les Evidence
*/
public clearAllEvidence(): void {
this.evidenceStore.clear();
this.allEvidence = [];
if (this.config.persistToStorage) {
this.clearStorage();
}
this.notifyListeners([]);
}
/**
* Ajouter un listener pour les changements
*/
public addListener(listener: (evidence: Evidence[], stepId?: string) => void): void {
this.listeners.add(listener);
}
/**
* Supprimer un listener
*/
public removeListener(listener: (evidence: Evidence[], stepId?: string) => void): void {
this.listeners.delete(listener);
}
/**
* Exporter les Evidence
*/
public exportEvidence(stepId?: string): string {
const data = stepId
? { [stepId]: this.getEvidenceByStep(stepId) }
: Object.fromEntries(this.evidenceStore);
return JSON.stringify({
timestamp: new Date().toISOString(),
stepId,
evidence: data,
metrics: this.getMetrics(),
}, null, 2);
}
/**
* Importer des Evidence
*/
public importEvidence(jsonData: string): void {
try {
const data = JSON.parse(jsonData);
if (data.evidence) {
Object.entries(data.evidence).forEach(([stepId, evidence]) => {
if (Array.isArray(evidence)) {
this.evidenceStore.set(stepId, evidence as Evidence[]);
}
});
this.updateAllEvidence();
if (this.config.persistToStorage) {
this.saveToStorage();
}
this.notifyListeners(this.allEvidence);
}
} catch (error) {
console.error('Erreur lors de l\'importation des Evidence:', error);
throw new Error('Format de données invalide');
}
}
/**
* Mettre à jour la liste globale des Evidence
*/
private updateAllEvidence(): void {
this.allEvidence = [];
this.evidenceStore.forEach(stepEvidence => {
this.allEvidence.push(...stepEvidence);
});
// Trier par timestamp
this.allEvidence.sort((a, b) =>
new Date(a.captured_at).getTime() - new Date(b.captured_at).getTime()
);
}
/**
* Notifier les listeners
*/
private notifyListeners(evidence: Evidence[], stepId?: string): void {
this.listeners.forEach(listener => {
try {
listener(evidence, stepId);
} catch (error) {
console.error('Erreur dans le listener Evidence:', error);
}
});
}
/**
* Obtenir la taille d'une Evidence
*/
private getEvidenceSize(evidence: Evidence): number {
return JSON.stringify(evidence).length;
}
/**
* Effectuer le nettoyage automatique
*/
private performCleanup(): void {
const cutoffTime = Date.now() - (30 * 60 * 1000); // 30 minutes
this.evidenceStore.forEach((stepEvidence, stepId) => {
const filtered = stepEvidence.filter(evidence =>
new Date(evidence.captured_at).getTime() > cutoffTime
);
if (filtered.length !== stepEvidence.length) {
this.evidenceStore.set(stepId, filtered);
}
});
this.updateAllEvidence();
if (this.config.persistToStorage) {
this.saveToStorage();
}
}
/**
* Démarrer le nettoyage automatique
*/
private startAutoCleanup(): void {
this.cleanupTimer = setInterval(() => {
this.performCleanup();
}, this.config.autoCleanupInterval);
}
/**
* Gérer les changements de visibilité
*/
private handleVisibilityChange(): void {
if (document.hidden) {
// Sauvegarder quand la page devient invisible
if (this.config.persistToStorage) {
this.saveToStorage();
}
}
}
/**
* Sauvegarder dans le stockage local
*/
private saveToStorage(): void {
try {
const data = {
evidenceStore: Object.fromEntries(this.evidenceStore),
timestamp: Date.now(),
config: this.config,
};
localStorage.setItem('vwb_evidence_execution', JSON.stringify(data));
} catch (error) {
console.warn('Erreur lors de la sauvegarde des Evidence:', error);
}
}
/**
* Charger depuis le stockage local
*/
private loadFromStorage(): void {
try {
const stored = localStorage.getItem('vwb_evidence_execution');
if (stored) {
const data = JSON.parse(stored);
if (data.evidenceStore) {
this.evidenceStore = new Map(Object.entries(data.evidenceStore));
this.updateAllEvidence();
}
}
} catch (error) {
console.warn('Erreur lors du chargement des Evidence:', error);
}
}
/**
* Nettoyer le stockage local
*/
private clearStorage(): void {
try {
localStorage.removeItem('vwb_evidence_execution');
} catch (error) {
console.warn('Erreur lors du nettoyage du stockage:', error);
}
}
/**
* Nettoyer les ressources
*/
public cleanup(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
this.listeners.clear();
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
}
}
}
// Instance singleton
export const evidenceExecutionService = EvidenceExecutionService.getInstance();
// Hook pour utiliser le service
export const useEvidenceExecutionService = () => {
return {
service: evidenceExecutionService,
addEvidence: (stepId: string, evidence: Evidence) =>
evidenceExecutionService.addEvidence(stepId, evidence),
getEvidenceByStep: (stepId: string) =>
evidenceExecutionService.getEvidenceByStep(stepId),
getAllEvidence: () => evidenceExecutionService.getAllEvidence(),
searchEvidence: (query: EvidenceQuery) =>
evidenceExecutionService.searchEvidence(query),
getMetrics: () => evidenceExecutionService.getMetrics(),
clearAllEvidence: () => evidenceExecutionService.clearAllEvidence(),
};
};

View File

@@ -0,0 +1,351 @@
/**
* Service pour la gestion des Evidence VWB
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import { VWBEvidence, EvidenceFilters, EvidenceExportOptions, EvidenceStats, EvidenceUtils } from '../types/evidence';
export class EvidenceService {
private baseUrl: string;
private cache: Map<string, VWBEvidence[]> = new Map();
private cacheTimeout: number = 5 * 60 * 1000; // 5 minutes
constructor(baseUrl: string = 'http://localhost:5001') {
this.baseUrl = baseUrl;
}
/**
* Récupère toutes les Evidence disponibles
*/
async getEvidences(workflowId?: string): Promise<VWBEvidence[]> {
try {
const cacheKey = `evidences_${workflowId || 'all'}`;
const cached = this.cache.get(cacheKey);
if (cached) {
return cached;
}
const url = workflowId
? `${this.baseUrl}/api/vwb/evidences?workflow_id=${workflowId}`
: `${this.baseUrl}/api/vwb/evidences`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Erreur lors de la récupération des Evidence : ${response.statusText}`);
}
const evidences: VWBEvidence[] = await response.json();
// Mise en cache
this.cache.set(cacheKey, evidences);
setTimeout(() => this.cache.delete(cacheKey), this.cacheTimeout);
return evidences;
} catch (error) {
console.error('Erreur EvidenceService.getEvidences:', error);
throw new Error(`Impossible de récupérer les Evidence : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
}
}
/**
* Récupère une Evidence spécifique par ID
*/
async getEvidence(evidenceId: string): Promise<VWBEvidence | null> {
try {
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/${evidenceId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Erreur lors de la récupération de l'Evidence : ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Erreur EvidenceService.getEvidence:', error);
throw new Error(`Impossible de récupérer l'Evidence ${evidenceId} : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
}
}
/**
* Sauvegarde une Evidence
*/
async saveEvidence(evidence: VWBEvidence): Promise<VWBEvidence> {
try {
const response = await fetch(`${this.baseUrl}/api/vwb/evidences`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(evidence),
});
if (!response.ok) {
throw new Error(`Erreur lors de la sauvegarde de l'Evidence : ${response.statusText}`);
}
const savedEvidence = await response.json();
// Invalider le cache
this.cache.clear();
return savedEvidence;
} catch (error) {
console.error('Erreur EvidenceService.saveEvidence:', error);
throw new Error(`Impossible de sauvegarder l'Evidence : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
}
}
/**
* Supprime une Evidence
*/
async deleteEvidence(evidenceId: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/${evidenceId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Erreur lors de la suppression de l'Evidence : ${response.statusText}`);
}
// Invalider le cache
this.cache.clear();
} catch (error) {
console.error('Erreur EvidenceService.deleteEvidence:', error);
throw new Error(`Impossible de supprimer l'Evidence ${evidenceId} : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
}
}
/**
* Filtre les Evidence selon les critères spécifiés
*/
filterEvidences(evidences: VWBEvidence[], filters: EvidenceFilters): VWBEvidence[] {
return EvidenceUtils.filterEvidences(evidences, filters);
}
/**
* Trie les Evidence
*/
sortEvidences(evidences: VWBEvidence[], sortBy: string, sortOrder: 'asc' | 'desc' = 'desc'): VWBEvidence[] {
return EvidenceUtils.sortEvidences(evidences, sortBy, sortOrder);
}
/**
* Calcule les statistiques des Evidence
*/
calculateStats(evidences: VWBEvidence[]): EvidenceStats {
return EvidenceUtils.calculateStats(evidences);
}
/**
* Exporte les Evidence selon les options spécifiées
*/
async exportEvidences(evidences: VWBEvidence[], options: EvidenceExportOptions): Promise<Blob> {
try {
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
evidences,
options,
format: options.format || 'json'
}),
});
if (!response.ok) {
throw new Error(`Erreur lors de l'export des Evidence : ${response.statusText}`);
}
return await response.blob();
} catch (error) {
console.error('Erreur EvidenceService.exportEvidences:', error);
// Fallback : export côté client
return this.exportEvidencesClientSide(evidences, options);
}
}
/**
* Export côté client en cas d'échec du serveur
*/
private exportEvidencesClientSide(evidences: VWBEvidence[], options: EvidenceExportOptions): Blob {
if (options.format === 'json') {
const exportData = {
metadata: {
exportDate: new Date().toISOString(),
totalEvidences: evidences.length,
options
},
evidences: evidences.map(evidence => ({
...evidence,
screenshot_base64: options.includeScreenshots ? evidence.screenshot_base64 : undefined
}))
};
return new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
}
if (options.format === 'html') {
const html = this.generateHtmlReport(evidences, options);
return new Blob([html], { type: 'text/html' });
}
if (options.format === 'pdf') {
// Pour PDF, on génère du HTML qui peut être converti
const html = this.generateHtmlReport(evidences, options);
return new Blob([html], { type: 'text/html' });
}
// Format par défaut
const html = this.generateHtmlReport(evidences, options);
return new Blob([html], { type: 'text/html' });
}
/**
* Génère un rapport HTML des Evidence
*/
private generateHtmlReport(evidences: VWBEvidence[], options: EvidenceExportOptions): string {
const stats = this.calculateStats(evidences);
const html = `
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rapport Evidence VWB - ${new Date().toLocaleDateString('fr-FR')}</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.header { border-bottom: 2px solid #1976d2; padding-bottom: 10px; margin-bottom: 20px; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 30px; }
.stat-card { background: #f5f5f5; padding: 15px; border-radius: 8px; text-align: center; }
.stat-value { font-size: 24px; font-weight: bold; color: #1976d2; }
.evidence-item { border: 1px solid #ddd; margin-bottom: 20px; padding: 15px; border-radius: 8px; }
.evidence-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.evidence-status { padding: 4px 8px; border-radius: 4px; color: white; font-size: 12px; }
.status-success { background: #4caf50; }
.status-error { background: #f44336; }
.evidence-screenshot { max-width: 300px; max-height: 200px; border: 1px solid #ddd; margin: 10px 0; }
.evidence-metadata { background: #f9f9f9; padding: 10px; border-radius: 4px; font-size: 12px; }
</style>
</head>
<body>
<div class="header">
<h1>Rapport Evidence VWB</h1>
<p>Généré le ${new Date().toLocaleString('fr-FR')}</p>
<p>Nombre d'Evidence : ${evidences.length}</p>
</div>
<div class="stats">
<div class="stat-card">
<div class="stat-value">${stats.total}</div>
<div>Total</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.successful}</div>
<div>Réussies</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.failed}</div>
<div>Échouées</div>
</div>
<div class="stat-card">
<div class="stat-value">${EvidenceUtils.formatExecutionTime(stats.averageExecutionTime)}</div>
<div>Temps moyen</div>
</div>
<div class="stat-card">
<div class="stat-value">${EvidenceUtils.formatConfidence(stats.averageConfidence)}</div>
<div>Confiance moyenne</div>
</div>
</div>
<h2>Détail des Evidence</h2>
${evidences.map(evidence => `
<div class="evidence-item">
<div class="evidence-header">
<h3>${evidence.action_name || evidence.action_id}</h3>
<span class="evidence-status ${evidence.success ? 'status-success' : 'status-error'}">
${evidence.success ? 'SUCCÈS' : 'ERREUR'}
</span>
</div>
<p><strong>Date :</strong> ${EvidenceUtils.formatDate(evidence.captured_at)}</p>
<p><strong>Temps d'exécution :</strong> ${EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}</p>
${evidence.confidence_score ? `<p><strong>Confiance :</strong> ${EvidenceUtils.formatConfidence(evidence.confidence_score)}</p>` : ''}
${evidence.error ? `
<div style="background: #ffebee; padding: 10px; border-radius: 4px; margin: 10px 0;">
<strong>Erreur :</strong> ${evidence.error.message}
</div>
` : ''}
${options.includeScreenshots && evidence.screenshot_base64 ? `
<img src="data:image/png;base64,${evidence.screenshot_base64}"
alt="Screenshot Evidence" class="evidence-screenshot">
` : ''}
${options.includeMetadata && evidence.metadata ? `
<div class="evidence-metadata">
<strong>Métadonnées :</strong>
<pre>${JSON.stringify(evidence.metadata, null, 2)}</pre>
</div>
` : ''}
</div>
`).join('')}
</body>
</html>`;
// Utilisation de URL.createObjectURL pour la génération
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
URL.revokeObjectURL(url); // Nettoyage immédiat pour les tests
return html;
}
/**
* Valide la santé du service Evidence
*/
async healthCheck(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/health`, {
method: 'GET',
timeout: 5000
} as RequestInit);
return response.ok;
} catch (error) {
console.warn('Service Evidence non disponible:', error);
return false;
}
}
/**
* Nettoie le cache
*/
clearCache(): void {
this.cache.clear();
}
}
// Instance singleton du service
export const evidenceService = new EvidenceService();
export default EvidenceService;

View File

@@ -0,0 +1,388 @@
/**
* Service de Capture d'Écran Réelle - Interface avec l'API Backend
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
*
* Ce service gère la capture d'écran réelle avec détection d'éléments UI
* en utilisant le service RealScreenCaptureService du backend.
*/
// Configuration du service
const BACKEND_BASE_URL = 'http://localhost:5001/api';
const REQUEST_TIMEOUT = 20000; // 20 secondes pour la capture avec détection
// Types pour les réponses API
interface Monitor {
id: number;
width: number;
height: number;
top: number;
left: number;
}
interface UIElement {
id: string;
type: string;
text: string;
bbox: {
x: number;
y: number;
width: number;
height: number;
};
confidence: number;
attributes: Record<string, any>;
}
interface CaptureStatus {
is_capturing: boolean;
selected_monitor: number;
monitors_count: number;
capture_interval: number;
elements_detected: number;
has_screenshot: boolean;
}
interface RealScreenCaptureResponse {
success: boolean;
screenshot?: string;
elements?: UIElement[];
monitors?: Monitor[];
status?: CaptureStatus;
timestamp?: string;
method?: string;
error?: string;
}
interface CaptureControlResponse {
success: boolean;
message?: string;
status?: CaptureStatus;
error?: string;
}
interface StatusResponse {
success: boolean;
status?: CaptureStatus;
monitors?: Monitor[];
error?: string;
}
/**
* Service de capture d'écran réelle avec détection d'éléments UI
*/
class RealScreenCaptureService {
private abortController: AbortController | null = null;
/**
* Capturer l'écran avec détection d'éléments UI
*
* @param monitorId ID du moniteur à capturer (0 par défaut)
* @param detectElements Détecter les éléments UI (true par défaut)
* @returns Promise avec les données de capture ou null si erreur
*/
async captureWithElements(
monitorId: number = 0,
detectElements: boolean = true
): Promise<RealScreenCaptureResponse | null> {
try {
// Annuler toute requête précédente
this.cancelRequest();
// Créer un nouveau contrôleur d'abort
this.abortController = new AbortController();
// Timeout pour la requête
const timeoutId = setTimeout(() => {
if (this.abortController) {
this.abortController.abort();
}
}, REQUEST_TIMEOUT);
console.log(`🔧 Capture d'écran réelle (moniteur ${monitorId}, détection: ${detectElements})...`);
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
monitor_id: monitorId,
detect_elements: detectElements,
}),
signal: this.abortController.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
return {
success: false,
error: `Erreur HTTP ${response.status}: ${errorText}`,
};
}
const data: RealScreenCaptureResponse = await response.json();
if (data.success) {
console.log(`✅ Capture réelle réussie - ${data.elements?.length || 0} éléments détectés`);
} else {
console.error('❌ Capture réelle échouée:', data.error);
}
return data;
} catch (error) {
console.error('❌ Erreur lors de la capture d\'écran réelle:', error);
if (error instanceof Error) {
if (error.name === 'AbortError') {
return {
success: false,
error: 'Capture annulée (timeout)',
};
}
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: 'Erreur inconnue lors de la capture réelle',
};
} finally {
this.abortController = null;
}
}
/**
* Démarrer la capture en temps réel
*
* @param interval Intervalle entre les captures en secondes (1.0 par défaut)
* @returns Promise avec le résultat de l'opération
*/
async startRealTimeCapture(interval: number = 1.0): Promise<CaptureControlResponse | null> {
try {
console.log(`🔧 Démarrage capture temps réel (intervalle: ${interval}s)...`);
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
interval,
}),
signal: AbortSignal.timeout(10000), // 10 secondes max
});
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
return {
success: false,
error: `Erreur HTTP ${response.status}: ${errorText}`,
};
}
const data: CaptureControlResponse = await response.json();
if (data.success) {
console.log('✅ Capture temps réel démarrée');
} else {
console.error('❌ Échec démarrage capture temps réel:', data.error);
}
return data;
} catch (error) {
console.error('❌ Erreur lors du démarrage de la capture temps réel:', error);
if (error instanceof Error) {
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: 'Erreur inconnue lors du démarrage',
};
}
}
/**
* Arrêter la capture en temps réel
*
* @returns Promise avec le résultat de l'opération
*/
async stopRealTimeCapture(): Promise<CaptureControlResponse | null> {
try {
console.log('🔧 Arrêt capture temps réel...');
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture/stop`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(5000), // 5 secondes max
});
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
return {
success: false,
error: `Erreur HTTP ${response.status}: ${errorText}`,
};
}
const data: CaptureControlResponse = await response.json();
if (data.success) {
console.log('✅ Capture temps réel arrêtée');
} else {
console.error('❌ Échec arrêt capture temps réel:', data.error);
}
return data;
} catch (error) {
console.error('❌ Erreur lors de l\'arrêt de la capture temps réel:', error);
if (error instanceof Error) {
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: 'Erreur inconnue lors de l\'arrêt',
};
}
}
/**
* Obtenir le statut du service de capture réelle
*
* @returns Promise avec le statut du service
*/
async getStatus(): Promise<StatusResponse | null> {
try {
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture/status`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
signal: AbortSignal.timeout(5000), // 5 secondes max
});
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
return {
success: false,
error: `Erreur HTTP ${response.status}: ${errorText}`,
};
}
const data: StatusResponse = await response.json();
if (data.success) {
console.log('✅ Statut obtenu:', data.status);
} else {
console.error('❌ Échec obtention statut:', data.error);
}
return data;
} catch (error) {
console.error('❌ Erreur lors de l\'obtention du statut:', error);
if (error instanceof Error) {
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: 'Erreur inconnue lors de l\'obtention du statut',
};
}
}
/**
* Vérifier la disponibilité du service de capture réelle
*
* @returns Promise<boolean> true si le service est disponible
*/
async checkAvailability(): Promise<boolean> {
try {
const statusResponse = await this.getStatus();
return statusResponse?.success === true;
} catch (error) {
console.warn('⚠️ Service de capture réelle indisponible:', error);
return false;
}
}
/**
* Obtenir la liste des moniteurs disponibles
*
* @returns Promise avec la liste des moniteurs ou null si erreur
*/
async getMonitors(): Promise<Monitor[] | null> {
try {
const statusResponse = await this.getStatus();
if (statusResponse?.success && statusResponse.monitors) {
return statusResponse.monitors;
}
return null;
} catch (error) {
console.error('❌ Erreur lors de l\'obtention des moniteurs:', error);
return null;
}
}
/**
* Annuler la requête en cours
*/
cancelRequest(): void {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
/**
* Nettoyer les ressources
*/
cleanup(): void {
this.cancelRequest();
}
}
// Instance singleton du service
export const realScreenCaptureService = new RealScreenCaptureService();
// Export des types
export type {
RealScreenCaptureResponse,
CaptureControlResponse,
StatusResponse,
Monitor,
UIElement,
CaptureStatus
};
export default RealScreenCaptureService;

View File

@@ -0,0 +1,371 @@
/**
* Service de Capture d'Écran - Interface avec l'API Backend Ultra Stable
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
*
* Ce service gère la capture d'écran et la création d'embeddings visuels
* en utilisant l'Option A (MSS créé à chaque capture) pour une stabilité maximale.
*/
import { BoundingBox, VisualSelection } from '../types';
// Configuration du service
const BACKEND_BASE_URL = 'http://localhost:5001/api';
const REQUEST_TIMEOUT = 15000; // 15 secondes pour la capture d'écran
// Types pour les réponses API
interface ScreenCaptureResponse {
success: boolean;
screenshot?: string;
width?: number;
height?: number;
timestamp?: string;
method?: string;
error?: string;
}
interface VisualEmbeddingResponse {
success: boolean;
embedding?: number[];
embedding_id?: string;
dimension?: number;
reference_image?: string;
bounding_box?: BoundingBox;
error?: string;
}
/**
* Service de capture d'écran utilisant l'API Backend ultra stable
*/
class ScreenCaptureService {
private abortController: AbortController | null = null;
/**
* Capturer l'écran actuel via l'API Backend (Option A - ultra stable)
*
* @param format Format de l'image ('png' ou 'jpeg')
* @param quality Qualité pour JPEG (1-100)
* @returns Promise avec les données de capture ou null si erreur
*/
async captureScreen(
format: 'png' | 'jpeg' = 'png',
quality: number = 90
): Promise<ScreenCaptureResponse | null> {
try {
// Annuler toute requête précédente
this.cancelRequest();
// Créer un nouveau contrôleur d'abort
this.abortController = new AbortController();
// Timeout pour la requête
const timeoutId = setTimeout(() => {
if (this.abortController) {
this.abortController.abort();
}
}, REQUEST_TIMEOUT);
console.log('🔧 Capture d\'écran via API Backend (Option A - ultra stable)...');
const response = await fetch(`${BACKEND_BASE_URL}/screen-capture/capture`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
format,
quality,
}),
signal: this.abortController.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
return {
success: false,
error: `Erreur HTTP ${response.status}: ${errorText}`,
};
}
const data: ScreenCaptureResponse = await response.json();
if (data.success) {
console.log(`✅ Capture réussie - ${data.width}x${data.height} (${data.method})`);
} else {
console.error('❌ Capture échouée:', data.error);
}
return data;
} catch (error) {
console.error('❌ Erreur lors de la capture d\'écran:', error);
if (error instanceof Error) {
if (error.name === 'AbortError') {
return {
success: false,
error: 'Capture annulée (timeout)',
};
}
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: 'Erreur inconnue lors de la capture',
};
} finally {
this.abortController = null;
}
}
/**
* Créer un embedding visuel à partir d'une capture et d'une zone sélectionnée
*
* @param screenshot Image en base64
* @param boundingBox Zone sélectionnée
* @param stepId Identifiant de l'étape
* @returns Promise avec les données d'embedding ou null si erreur
*/
async createVisualEmbedding(
screenshot: string,
boundingBox: BoundingBox,
stepId: string
): Promise<VisualEmbeddingResponse | null> {
try {
// Annuler toute requête précédente
this.cancelRequest();
// Créer un nouveau contrôleur d'abort
this.abortController = new AbortController();
// Timeout pour la requête
const timeoutId = setTimeout(() => {
if (this.abortController) {
this.abortController.abort();
}
}, REQUEST_TIMEOUT);
console.log('🎯 Création d\'embedding visuel via API Backend...');
const response = await fetch(`${BACKEND_BASE_URL}/visual-embedding`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
screenshot,
boundingBox,
stepId,
}),
signal: this.abortController.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
return {
success: false,
error: `Erreur HTTP ${response.status}: ${errorText}`,
};
}
const data: VisualEmbeddingResponse = await response.json();
if (data.success) {
console.log(`✅ Embedding créé - ID: ${data.embedding_id}, Dimension: ${data.dimension}`);
} else {
console.error('❌ Création d\'embedding échouée:', data.error);
}
return data;
} catch (error) {
console.error('❌ Erreur lors de la création d\'embedding:', error);
if (error instanceof Error) {
if (error.name === 'AbortError') {
return {
success: false,
error: 'Création d\'embedding annulée (timeout)',
};
}
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: 'Erreur inconnue lors de la création d\'embedding',
};
} finally {
this.abortController = null;
}
}
/**
* Capturer l'écran et créer une sélection visuelle complète
*
* @param boundingBox Zone sélectionnée sur la capture
* @param stepId Identifiant de l'étape
* @param description Description de la sélection
* @returns Promise avec la sélection visuelle complète ou null si erreur
*/
async captureAndCreateSelection(
boundingBox: BoundingBox,
stepId: string,
description?: string
): Promise<VisualSelection | null> {
try {
// Étape 1: Capturer l'écran
console.log('📷 Étape 1/2: Capture d\'écran...');
const captureResult = await this.captureScreen('png', 90);
if (!captureResult || !captureResult.success || !captureResult.screenshot) {
console.error('❌ Échec de la capture d\'écran');
return null;
}
// Étape 2: Créer l'embedding visuel
console.log('🎯 Étape 2/2: Création d\'embedding visuel...');
const embeddingResult = await this.createVisualEmbedding(
captureResult.screenshot,
boundingBox,
stepId
);
if (!embeddingResult || !embeddingResult.success || !embeddingResult.embedding) {
console.error('❌ Échec de la création d\'embedding');
return null;
}
// Créer la sélection visuelle complète
const visualSelection: VisualSelection = {
id: `visual_${stepId}_${Date.now()}`,
screenshot: captureResult.screenshot,
boundingBox: embeddingResult.bounding_box || boundingBox,
embedding: embeddingResult.embedding,
description: description || `Élément sélectionné pour l'étape ${stepId}`,
metadata: {
embedding_id: embeddingResult.embedding_id,
dimension: embeddingResult.dimension,
reference_image: embeddingResult.reference_image,
capture_method: captureResult.method,
capture_timestamp: captureResult.timestamp,
screen_resolution: {
width: captureResult.width,
height: captureResult.height,
},
},
};
console.log('✅ Sélection visuelle créée avec succès');
return visualSelection;
} catch (error) {
console.error('❌ Erreur lors de la création de sélection visuelle:', error);
return null;
}
}
/**
* Vérifier la disponibilité de l'API Backend
*
* @returns Promise<boolean> true si l'API est disponible
*/
async checkApiAvailability(): Promise<boolean> {
try {
const response = await fetch(`${BACKEND_BASE_URL}/health`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
signal: AbortSignal.timeout(5000), // 5 secondes max
});
if (response.ok) {
const data = await response.json();
console.log('✅ API Backend disponible:', data);
return true;
} else {
console.warn('⚠️ API Backend indisponible - HTTP', response.status);
return false;
}
} catch (error) {
console.warn('⚠️ API Backend indisponible:', error);
return false;
}
}
/**
* Obtenir les informations sur les capacités de capture
*
* @returns Promise avec les informations de capacité
*/
async getCapabilities(): Promise<{
screen_capture: boolean;
visual_embedding: boolean;
methods: string[];
} | null> {
try {
const response = await fetch(`${BACKEND_BASE_URL}/health`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
signal: AbortSignal.timeout(5000),
});
if (response.ok) {
const data = await response.json();
return {
screen_capture: data.features?.screen_capture || false,
visual_embedding: data.features?.visual_embedding || false,
methods: data.methods || ['unknown'],
};
}
return null;
} catch (error) {
console.warn('⚠️ Impossible d\'obtenir les capacités:', error);
return null;
}
}
/**
* Annuler la requête en cours
*/
cancelRequest(): void {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
/**
* Nettoyer les ressources
*/
cleanup(): void {
this.cancelRequest();
}
}
// Instance singleton du service
export const screenCaptureService = new ScreenCaptureService();
// Export des types
export type { ScreenCaptureResponse, VisualEmbeddingResponse };
export default ScreenCaptureService;

View File

@@ -7,14 +7,20 @@
*/
import { catalogService } from './catalogService';
import {
Step,
StepExecutionState,
ExecutionResult,
import {
Step,
StepExecutionState,
ExecutionResult,
ExecutionError,
Evidence
Evidence
} from '../types';
import { VWBCatalogAction } from '../types/catalog';
import {
enforceActionContract,
ContractValidationError,
getRequiredParams,
isKnownActionType
} from '../contracts';
interface VWBActionValidationResult {
is_valid: boolean;
@@ -110,12 +116,57 @@ export class VWBExecutionService {
'verify_text_content',
]);
/**
* NORMALISATION CRITIQUE: Résout les incohérences entre step.type et step.data.stepType
*
* Cette fonction détecte et corrige le problème où le type d'action peut être différent
* entre step.type (source principale) et step.data.stepType (doublon historique).
*
* @returns Le type d'action normalisé (VWB si possible, sinon le type disponible)
*/
public normalizeStepType(step: Step): string {
const typeFromStep = step.type;
const typeFromData = step.data?.stepType;
const typeFromVwbAction = step.data?.vwbActionId;
const typeFromActionId = step.action_id;
// Détection d'incohérence
if (typeFromStep && typeFromData && typeFromStep !== typeFromData) {
console.warn(
`⚠️ [VWB NORMALISATION] Incohérence détectée pour étape ${step.id}:`,
`\n step.type = "${typeFromStep}"`,
`\n step.data.stepType = "${typeFromData}"`,
`\n → Utilisation de la valeur VWB valide`
);
}
// Priorité: Type VWB valide > step.type > step.data.stepType > vwbActionId > action_id
const candidates = [typeFromStep, typeFromData, typeFromVwbAction, typeFromActionId];
// Chercher d'abord un type VWB valide
for (const candidate of candidates) {
if (candidate && VWBExecutionService.VWB_ACTION_TYPES.has(candidate)) {
if (candidate !== typeFromStep) {
console.log(
`🔧 [VWB NORMALISATION] Correction: "${typeFromStep}" → "${candidate}" pour étape ${step.id}`
);
}
return candidate;
}
}
// Sinon, prendre le premier disponible
const fallback = typeFromStep || typeFromData || typeFromVwbAction || typeFromActionId || 'unknown';
console.log(`📋 [VWB NORMALISATION] Type résolu: "${fallback}" pour étape ${step.id}`);
return fallback;
}
/**
* Vérifier si une étape est une action VWB
*/
public isVWBStep(step: Step): boolean {
// Vérifier d'abord si le type est dans la liste des actions VWB connues
const stepType = step.type || step.data?.stepType;
// Utiliser la normalisation pour obtenir le vrai type
const stepType = this.normalizeStepType(step);
if (stepType && VWBExecutionService.VWB_ACTION_TYPES.has(stepType)) {
return true;
}
@@ -137,8 +188,8 @@ export class VWBExecutionService {
throw new Error(`L'étape ${step.id} n'est pas une action VWB`);
}
// Extraire le type d'action depuis plusieurs sources possibles
const actionId = step.type || step.data?.stepType || step.data?.vwbActionId || step.action_id || "unknown";
// UTILISER LA NORMALISATION pour obtenir le type correct
const actionId = this.normalizeStepType(step);
const parameters = step.data?.parameters || {};
try {
@@ -217,23 +268,55 @@ export class VWBExecutionService {
}
}
// Extraire le type d'action depuis plusieurs sources possibles
const actionId = step.type || step.data?.stepType || step.data?.vwbActionId || step.action_id || "unknown";
// UTILISER LA NORMALISATION pour obtenir le type correct
const actionId = this.normalizeStepType(step);
const parameters = this.prepareParameters(step);
console.log(`🎯 [VWB] Exécution étape ${step.id}: type normalisé = "${actionId}"`);
console.log(` step.type = "${step.type}", step.data?.stepType = "${step.data?.stepType}"`);
console.log(` Paramètres: ${Object.keys(parameters).join(', ')}`);
// === VALIDATION CONTRAT STRICT ===
// BLOQUE l'exécution si le contrat n'est pas respecté
try {
enforceActionContract(actionId, parameters);
} catch (contractError) {
if (contractError instanceof ContractValidationError) {
console.error(`🚫 [CONTRAT VIOLÉ] Action '${actionId}' bloquée!`);
console.error(` Paramètres requis: ${getRequiredParams(actionId).join(', ')}`);
console.error(` Paramètres fournis: ${Object.keys(parameters).join(', ')}`);
throw new Error(
`Contrat violé pour '${actionId}': ${contractError.violations.map(v => v.message).join('; ')}`
);
}
throw contractError;
}
// Exécuter l'action avec retry
let lastError: Error | null = null;
for (let attempt = 1; attempt <= retryAttempts; attempt++) {
try {
const result = await this.executeActionWithTimeout(
actionId,
parameters,
actionId,
parameters,
timeout,
this.currentExecution.signal
);
const duration = Date.now() - startTime;
// IMPORTANT: Vérifier si le résultat indique une erreur (ex: ancre non trouvée)
// Le backend retourne status='error' ou should_stop=true quand l'action échoue
const isError = result?.status === 'error' ||
result?.should_stop === true ||
result?.success === false;
if (isError) {
console.log('🛑 [VWB] Erreur détectée dans le résultat:', result);
const errorMessage = result?.error?.message || result?.error || result?.message || 'Erreur retournée par le backend';
throw new Error(errorMessage);
}
// Traiter les Evidence si disponibles
const evidence = generateEvidence ? await this.processEvidence(result, startTime) : undefined;
@@ -274,7 +357,7 @@ export class VWBExecutionService {
return {
success: false,
stepId: step.id,
actionId: step.type || step.data?.stepType || step.data?.vwbActionId || step.action_id || "unknown",
actionId: this.normalizeStepType(step),
duration,
error: executionError
};
@@ -315,10 +398,40 @@ export class VWBExecutionService {
/**
* Préparer les paramètres pour l'exécution
* Gère les différents formats de données possibles (workflows sauvegardés vs créés en direct)
*/
private prepareParameters(step: Step): Record<string, any> {
// Récupérer les paramètres depuis plusieurs sources possibles
let parameters = { ...step.data?.parameters || {} };
// Si visual_anchor est vide mais qu'il y a un target avec des données visuelles, l'utiliser
if (!parameters.visual_anchor && step.data?.visualSelection) {
parameters.visual_anchor = step.data.visualSelection;
}
// Si target contient des données visuelles, fusionner avec visual_anchor
if (parameters.target && !parameters.visual_anchor) {
parameters.visual_anchor = parameters.target;
}
// S'assurer que visual_anchor a les bonnes propriétés pour les actions de clic
if (parameters.visual_anchor) {
const anchor = parameters.visual_anchor;
// Normaliser le nom de la propriété de l'image
if (!anchor.reference_image_base64 && anchor.screenshot) {
anchor.reference_image_base64 = anchor.screenshot;
}
if (!anchor.reference_image_base64 && anchor.image) {
anchor.reference_image_base64 = anchor.image;
}
// Normaliser bounding_box
if (!anchor.bounding_box && anchor.boundingBox) {
anchor.bounding_box = anchor.boundingBox;
}
}
// Résoudre les variables si contexte disponible
if (this.executionContext?.variables) {
parameters = this.resolveVariables(parameters, this.executionContext.variables);

View File

@@ -0,0 +1,162 @@
/**
* Service de Stockage Local des Workflows
* Auteur : Dom, Alice, Kiro - 13 janvier 2026
*
* Permet de sauvegarder et charger des workflows en localStorage
* pour fonctionner sans backend.
*/
import { Workflow, Step, WorkflowConnection, Variable } from '../types';
export interface StoredWorkflow {
id: string;
name: string;
description?: string;
steps: Step[];
connections: WorkflowConnection[];
variables: Variable[];
createdAt: string;
updatedAt: string;
version: number;
}
interface WorkflowStorageState {
workflows: StoredWorkflow[];
lastUpdated: string;
version: string;
}
const STORAGE_KEY = 'vwb_workflows';
const STORAGE_VERSION = '1.0.0';
/**
* Service de gestion du stockage local des workflows
*/
class WorkflowStorageService {
private state: WorkflowStorageState;
constructor() {
this.state = this.loadFromStorage();
console.log('💾 [WorkflowStorage] Service initialisé avec', this.state.workflows.length, 'workflows');
}
/**
* Recharger les données depuis localStorage
*/
public reload(): void {
this.state = this.loadFromStorage();
console.log('🔄 [WorkflowStorage] Rechargé:', this.state.workflows.length, 'workflows');
}
/**
* Charger depuis localStorage
*/
private loadFromStorage(): WorkflowStorageState {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as WorkflowStorageState;
if (parsed.version === STORAGE_VERSION) {
return parsed;
}
}
} catch (error) {
console.warn('Erreur lors du chargement des workflows:', error);
}
return {
workflows: [],
lastUpdated: new Date().toISOString(),
version: STORAGE_VERSION,
};
}
/**
* Sauvegarder dans localStorage
*/
private saveToStorage(): void {
try {
this.state.lastUpdated = new Date().toISOString();
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
} catch (error) {
console.error('Erreur lors de la sauvegarde des workflows:', error);
}
}
/**
* Obtenir tous les workflows
*/
getAllWorkflows(): StoredWorkflow[] {
return [...this.state.workflows];
}
/**
* Obtenir un workflow par ID
*/
getWorkflowById(id: string): StoredWorkflow | null {
return this.state.workflows.find((w) => w.id === id) || null;
}
/**
* Sauvegarder un workflow
*/
saveWorkflow(workflow: Workflow): StoredWorkflow {
const now = new Date().toISOString();
const existingIndex = this.state.workflows.findIndex((w) => w.id === workflow.id);
const storedWorkflow: StoredWorkflow = {
id: workflow.id || `workflow_${Date.now()}`,
name: workflow.name,
description: workflow.description,
steps: workflow.steps || [],
connections: workflow.connections || [],
variables: workflow.variables || [],
createdAt: existingIndex >= 0 ? this.state.workflows[existingIndex].createdAt : now,
updatedAt: now,
version: existingIndex >= 0 ? this.state.workflows[existingIndex].version + 1 : 1,
};
if (existingIndex >= 0) {
this.state.workflows[existingIndex] = storedWorkflow;
} else {
this.state.workflows.unshift(storedWorkflow);
}
this.saveToStorage();
console.log('💾 [WorkflowStorage] Workflow sauvegardé:', storedWorkflow.name);
return storedWorkflow;
}
/**
* Supprimer un workflow
*/
deleteWorkflow(id: string): boolean {
const index = this.state.workflows.findIndex((w) => w.id === id);
if (index === -1) return false;
this.state.workflows.splice(index, 1);
this.saveToStorage();
console.log('🗑️ [WorkflowStorage] Workflow supprimé:', id);
return true;
}
/**
* Convertir en format Workflow pour l'application
*/
toWorkflow(stored: StoredWorkflow): Workflow {
return {
id: stored.id,
name: stored.name,
description: stored.description,
steps: stored.steps,
connections: stored.connections,
variables: stored.variables,
createdAt: new Date(stored.createdAt),
updatedAt: new Date(stored.updatedAt),
};
}
}
// Export singleton
export const workflowStorageService = new WorkflowStorageService();
export default workflowStorageService;

View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@@ -0,0 +1,31 @@
/**
* Store Redux pour la gestion d'état globale
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Configuration du store Redux avec Redux Toolkit pour la gestion
* centralisée de l'état de l'application.
*/
import { configureStore } from '@reduxjs/toolkit';
import workflowReducer from './slices/workflowSlice';
import uiReducer from './slices/uiSlice';
// Configuration du store Redux
export const store = configureStore({
reducer: {
workflow: workflowReducer,
ui: uiReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
// Ignorer ces chemins pour les actions non-sérialisables
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
},
}),
devTools: process.env.NODE_ENV !== 'production',
});
// Types pour TypeScript
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Some files were not shown because too many files have changed in this diff Show More