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:
428
visual_workflow_builder/frontend/src/hooks/useApiClient.ts
Normal file
428
visual_workflow_builder/frontend/src/hooks/useApiClient.ts
Normal 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 };
|
||||
546
visual_workflow_builder/frontend/src/hooks/useAutoSave.ts
Normal file
546
visual_workflow_builder/frontend/src/hooks/useAutoSave.ts
Normal 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;
|
||||
421
visual_workflow_builder/frontend/src/hooks/useCatalogActions.ts
Normal file
421
visual_workflow_builder/frontend/src/hooks/useCatalogActions.ts
Normal 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;
|
||||
@@ -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,
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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;
|
||||
@@ -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[]>([]);
|
||||
|
||||
262
visual_workflow_builder/frontend/src/hooks/useDebounce.ts
Normal file
262
visual_workflow_builder/frontend/src/hooks/useDebounce.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
259
visual_workflow_builder/frontend/src/hooks/useEvidenceViewer.ts
Normal file
259
visual_workflow_builder/frontend/src/hooks/useEvidenceViewer.ts
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
292
visual_workflow_builder/frontend/src/hooks/useVirtualization.ts
Normal file
292
visual_workflow_builder/frontend/src/hooks/useVirtualization.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user