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,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,
};
}