fix(vwb): Corriger auto-save et coordonnées miniature
- Ajouter méthode updateWorkflow (PUT) dans apiClient pour les workflows existants - Utiliser PUT au lieu de POST pour l'auto-sauvegarde des workflows - Ajouter tracking du scale dans VisualSelector pour convertir les coordonnées du canvas vers l'image originale - Corriger le bounding_box pour correspondre aux dimensions réelles de l'image capturée Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
* avec sélection visuelle basée sur la vision et terminologie française.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect, Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect, useRef, Component, ErrorInfo, ReactNode } from 'react';
|
||||
import {
|
||||
Box,
|
||||
CssBaseline,
|
||||
@@ -46,6 +46,9 @@ import VWBIntegrationTest from './components/VWBIntegrationTest';
|
||||
import { useKeyboardNavigation } from './hooks/useKeyboardNavigation';
|
||||
import { useResponsiveLayout } from './hooks/useResponsiveLayout';
|
||||
|
||||
// Service API pour l'auto-sauvegarde
|
||||
import { apiClient } from './services/apiClient';
|
||||
|
||||
// Import des types partagés
|
||||
import {
|
||||
Step,
|
||||
@@ -184,6 +187,105 @@ function App() {
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
// État pour l'auto-sauvegarde
|
||||
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSavedWorkflowRef = useRef<string>('');
|
||||
const isInitialLoadRef = useRef(true);
|
||||
|
||||
// Auto-sauvegarde vers le backend avec debounce
|
||||
useEffect(() => {
|
||||
// Ignorer la sauvegarde initiale (premier render)
|
||||
if (isInitialLoadRef.current) {
|
||||
isInitialLoadRef.current = false;
|
||||
lastSavedWorkflowRef.current = JSON.stringify({
|
||||
steps: workflow.steps,
|
||||
connections: workflow.connections,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Ne pas sauvegarder si le workflow n'a pas d'ID valide (pas encore créé sur le backend)
|
||||
if (!workflow.id || workflow.id === 'workflow_1' || !workflow.id.startsWith('wf_')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Créer une représentation du workflow pour comparer
|
||||
const currentWorkflowState = JSON.stringify({
|
||||
steps: workflow.steps,
|
||||
connections: workflow.connections,
|
||||
});
|
||||
|
||||
// Ne pas sauvegarder si rien n'a changé
|
||||
if (currentWorkflowState === lastSavedWorkflowRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Annuler le timeout précédent
|
||||
if (autoSaveTimeoutRef.current) {
|
||||
clearTimeout(autoSaveTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Programmer la sauvegarde avec debounce (2 secondes)
|
||||
autoSaveTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
setAutoSaveStatus('saving');
|
||||
console.log('💾 [AutoSave] Sauvegarde automatique en cours...', workflow.id);
|
||||
|
||||
// Préparer les données pour l'API (format compatible avec le backend)
|
||||
const workflowData = {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description || '',
|
||||
// Champs requis par WorkflowApiData
|
||||
steps: workflow.steps,
|
||||
connections: workflow.connections,
|
||||
variables: workflow.variables || [],
|
||||
// Format alternatif pour le backend (nodes/edges)
|
||||
nodes: workflow.steps.map(step => ({
|
||||
id: step.id,
|
||||
type: step.type,
|
||||
position: step.position,
|
||||
data: {
|
||||
...step.data,
|
||||
stepType: step.type,
|
||||
},
|
||||
})),
|
||||
edges: workflow.connections.map(conn => ({
|
||||
id: conn.id,
|
||||
source: conn.source,
|
||||
target: conn.target,
|
||||
sourceHandle: (conn as any).sourceHandle,
|
||||
targetHandle: (conn as any).targetHandle,
|
||||
})),
|
||||
};
|
||||
|
||||
// Utiliser PUT (updateWorkflow) pour les workflows existants
|
||||
await apiClient.updateWorkflow(workflow.id, workflowData);
|
||||
|
||||
lastSavedWorkflowRef.current = currentWorkflowState;
|
||||
setAutoSaveStatus('saved');
|
||||
console.log('✅ [AutoSave] Sauvegarde automatique réussie');
|
||||
|
||||
// Remettre à idle après 3 secondes
|
||||
setTimeout(() => setAutoSaveStatus('idle'), 3000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [AutoSave] Erreur de sauvegarde automatique:', error);
|
||||
setAutoSaveStatus('error');
|
||||
// Remettre à idle après 5 secondes en cas d'erreur
|
||||
setTimeout(() => setAutoSaveStatus('idle'), 5000);
|
||||
}
|
||||
}, 2000); // Debounce de 2 secondes
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (autoSaveTimeoutRef.current) {
|
||||
clearTimeout(autoSaveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [workflow.steps, workflow.connections, workflow.id, workflow.name, workflow.description, workflow.variables]);
|
||||
|
||||
// Configuration de la navigation au clavier
|
||||
const keyboardNavigation = useKeyboardNavigation({
|
||||
onStepSelect: (stepId: string) => {
|
||||
@@ -445,7 +547,30 @@ function App() {
|
||||
onWorkflowSave={handleWorkflowSave}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Indicateur d'auto-sauvegarde */}
|
||||
{autoSaveStatus !== 'idle' && (
|
||||
<Box
|
||||
sx={{
|
||||
mr: 2,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
borderRadius: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
bgcolor: autoSaveStatus === 'saving' ? 'rgba(255,255,255,0.1)' :
|
||||
autoSaveStatus === 'saved' ? 'rgba(76,175,80,0.2)' :
|
||||
'rgba(244,67,54,0.2)',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{autoSaveStatus === 'saving' && '💾 Sauvegarde...'}
|
||||
{autoSaveStatus === 'saved' && '✅ Sauvegardé'}
|
||||
{autoSaveStatus === 'error' && '❌ Erreur sauvegarde'}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Indicateur de connexion API */}
|
||||
<Box sx={{ mr: 2 }}>
|
||||
<ConnectionIndicator compact showRefreshButton />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
756
visual_workflow_builder/frontend/src/services/apiClient.ts
Normal file
756
visual_workflow_builder/frontend/src/services/apiClient.ts
Normal file
@@ -0,0 +1,756 @@
|
||||
/**
|
||||
* Client API - Gestion centralisée des communications avec le Backend_VWB
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce service centralise toutes les communications avec le backend,
|
||||
* incluant la gestion d'erreurs, retry automatique, validation des données
|
||||
* et gestion gracieuse du mode hors ligne.
|
||||
*
|
||||
* IMPORTANT: Ce client utilise une initialisation paresseuse (lazy) pour
|
||||
* éviter les boucles infinies de re-render au chargement de la page.
|
||||
*/
|
||||
|
||||
import { WorkflowApiData } from '../types';
|
||||
|
||||
// Configuration du client API
|
||||
interface ApiClientConfig {
|
||||
baseUrl: string;
|
||||
timeout: number;
|
||||
maxRetries: number;
|
||||
retryDelay: number;
|
||||
enableRetry: boolean;
|
||||
healthCheckInterval: number;
|
||||
}
|
||||
|
||||
// Types pour les réponses API
|
||||
interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
code?: string;
|
||||
timestamp?: string;
|
||||
offline?: boolean;
|
||||
}
|
||||
|
||||
interface ApiError {
|
||||
message: string;
|
||||
code?: string;
|
||||
status?: number;
|
||||
details?: any;
|
||||
offline?: boolean;
|
||||
}
|
||||
|
||||
// État de connexion - 'offline' par défaut pour éviter les appels au montage
|
||||
type ConnectionState = 'online' | 'offline' | 'checking';
|
||||
|
||||
// Callbacks pour les changements d'état
|
||||
type ConnectionStateCallback = (state: ConnectionState) => void;
|
||||
|
||||
// Détection automatique de l'hôte pour support multi-machines
|
||||
// Si on accède via une IP (ex: 192.168.1.40), utiliser cette IP pour l'API
|
||||
// Sinon utiliser localhost
|
||||
const getApiHost = (): string => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const hostname = window.location.hostname;
|
||||
// Si c'est localhost ou 127.0.0.1, garder localhost
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
return 'http://localhost:5002/api';
|
||||
}
|
||||
// Sinon utiliser le même hostname (IP) avec le port 5000
|
||||
return `http://${hostname}:5000/api`;
|
||||
}
|
||||
return 'http://localhost:5002/api';
|
||||
};
|
||||
|
||||
// Configuration par défaut
|
||||
const DEFAULT_CONFIG: ApiClientConfig = {
|
||||
baseUrl: getApiHost(), // Backend Flask - détection auto de l'hôte
|
||||
timeout: 3000, // 3 secondes (réduit pour éviter les attentes longues)
|
||||
maxRetries: 1, // Réduit pour éviter les délais
|
||||
retryDelay: 500, // 500ms
|
||||
enableRetry: false, // Désactivé par défaut pour éviter les boucles
|
||||
healthCheckInterval: 60000, // 60 secondes (augmenté pour réduire les appels)
|
||||
};
|
||||
|
||||
/**
|
||||
* Client API centralisé pour les communications avec le Backend_VWB
|
||||
* Gère automatiquement le mode hors ligne sans provoquer de re-rendus excessifs
|
||||
*
|
||||
* ARCHITECTURE:
|
||||
* - État initial: 'offline' (pas de vérification automatique au démarrage)
|
||||
* - Initialisation paresseuse: la vérification se fait au premier appel API
|
||||
* - Pas de timer de health check automatique (évite les re-renders)
|
||||
*/
|
||||
class ApiClient {
|
||||
private config: ApiClientConfig;
|
||||
private abortController: AbortController | null = null;
|
||||
// État initial 'online' - on suppose que l'API est disponible et on gère les erreurs au cas par cas
|
||||
private connectionState: ConnectionState = 'online';
|
||||
private stateCallbacks: Set<ConnectionStateCallback> = new Set();
|
||||
private healthCheckTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastHealthCheck: number = 0;
|
||||
private isInitialized: boolean = false;
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(config: Partial<ApiClientConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialiser le client et vérifier la connexion
|
||||
* Appelé une seule fois au premier appel API (initialisation paresseuse)
|
||||
* Utilise un pattern singleton pour éviter les initialisations multiples
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
// Si déjà initialisé, retourner immédiatement
|
||||
if (this.isInitialized) return;
|
||||
|
||||
// Si une initialisation est en cours, attendre qu'elle se termine
|
||||
if (this.initializationPromise) {
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
// Créer la promesse d'initialisation
|
||||
this.initializationPromise = this.doInitialize();
|
||||
|
||||
try {
|
||||
await this.initializationPromise;
|
||||
} finally {
|
||||
this.initializationPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectuer l'initialisation réelle
|
||||
*/
|
||||
private async doInitialize(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
this.isInitialized = true;
|
||||
|
||||
// Vérification initiale silencieuse (une seule fois)
|
||||
await this.checkConnectionSilently();
|
||||
|
||||
// NE PAS démarrer le timer automatique pour éviter les re-renders
|
||||
// Le timer peut être démarré manuellement si nécessaire
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérification silencieuse de la connexion (sans logs excessifs)
|
||||
* Utilise un debounce pour éviter les vérifications trop fréquentes
|
||||
*/
|
||||
private async checkConnectionSilently(): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
|
||||
// TEMPORAIRE: Désactiver le debounce pour debug
|
||||
// if (now - this.lastHealthCheck < 10000) {
|
||||
// console.log('[ApiClient] Debounce actif, skip vérification');
|
||||
// return this.connectionState === 'online';
|
||||
// }
|
||||
|
||||
this.lastHealthCheck = now;
|
||||
console.log('[ApiClient] checkConnectionSilently appelé');
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000); // 2 secondes max
|
||||
|
||||
// Utiliser /api/health selon la configuration
|
||||
const healthUrl = `${this.config.baseUrl}/health`;
|
||||
console.log('[ApiClient] Vérification de santé:', healthUrl);
|
||||
|
||||
const response = await fetch(healthUrl, {
|
||||
signal: controller.signal,
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
console.log('[ApiClient] Réponse:', response.status, response.headers.get('content-type'));
|
||||
|
||||
if (response.ok) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
console.log('[ApiClient] ✅ API en ligne!');
|
||||
this.setConnectionState('online');
|
||||
return true;
|
||||
} else {
|
||||
console.log('[ApiClient] ❌ Content-type invalide:', contentType);
|
||||
}
|
||||
} else {
|
||||
console.log('[ApiClient] ❌ Réponse non-OK:', response.status);
|
||||
}
|
||||
|
||||
this.setConnectionState('offline');
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.log('[ApiClient] ❌ Erreur fetch:', error);
|
||||
this.setConnectionState('offline');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarrer le timer de vérification de santé (optionnel)
|
||||
* À appeler manuellement si nécessaire
|
||||
*/
|
||||
startHealthCheckTimer(): void {
|
||||
if (this.healthCheckTimer) return;
|
||||
|
||||
this.healthCheckTimer = setInterval(() => {
|
||||
this.checkConnectionSilently();
|
||||
}, this.config.healthCheckInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrêter le timer de vérification
|
||||
*/
|
||||
stopHealthCheck(): void {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour l'état de connexion et notifier les listeners
|
||||
* Utilise un mécanisme de batch pour éviter les notifications multiples
|
||||
*/
|
||||
private setConnectionState(state: ConnectionState): void {
|
||||
if (this.connectionState !== state) {
|
||||
this.connectionState = state;
|
||||
// Notifier les callbacks de manière asynchrone pour éviter les boucles
|
||||
setTimeout(() => {
|
||||
this.stateCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(state);
|
||||
} catch (e) {
|
||||
console.warn('Erreur dans le callback de connexion:', e);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* S'abonner aux changements d'état de connexion
|
||||
* NE notifie PAS immédiatement l'état actuel pour éviter les re-renders au montage
|
||||
*/
|
||||
onConnectionStateChange(callback: ConnectionStateCallback): () => void {
|
||||
this.stateCallbacks.add(callback);
|
||||
|
||||
// NE PAS notifier immédiatement - cela évite les re-renders au montage
|
||||
// L'état sera mis à jour lors du premier appel API ou forceConnectionCheck
|
||||
|
||||
// Retourner une fonction de désabonnement
|
||||
return () => {
|
||||
this.stateCallbacks.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir l'état de connexion actuel
|
||||
*/
|
||||
getConnectionState(): ConnectionState {
|
||||
return this.connectionState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si l'API est en ligne
|
||||
*/
|
||||
isOnline(): boolean {
|
||||
return this.connectionState === 'online';
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectuer une requête HTTP avec gestion d'erreurs et retry
|
||||
* Initialisation paresseuse au premier appel
|
||||
*/
|
||||
private async makeRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
retryCount = 0
|
||||
): Promise<ApiResponse<T>> {
|
||||
// Initialisation paresseuse au premier appel API
|
||||
if (!this.isInitialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
// NOTE: On n'utilise plus le blocage offline - on essaie toujours l'API
|
||||
// Les erreurs seront gérées par le catch si l'API est vraiment indisponible
|
||||
|
||||
// Créer un nouveau AbortController pour cette requête
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const url = `${this.config.baseUrl}${endpoint}`;
|
||||
const requestOptions: RequestInit = {
|
||||
...options,
|
||||
signal: this.abortController.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
};
|
||||
|
||||
// Ajouter un timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
}, this.config.timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, requestOptions);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Vérifier si la réponse est du JSON
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
// Le serveur retourne du HTML (probablement le serveur React)
|
||||
this.setConnectionState('offline');
|
||||
return {
|
||||
success: false,
|
||||
error: 'API hors ligne - Le backend n\'est pas démarré',
|
||||
code: 'OFFLINE',
|
||||
offline: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Marquer comme en ligne si la réponse est valide
|
||||
this.setConnectionState('online');
|
||||
|
||||
// Vérifier le statut de la réponse
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let errorData: any = {};
|
||||
|
||||
try {
|
||||
errorData = JSON.parse(errorText);
|
||||
} catch {
|
||||
errorData = { message: errorText };
|
||||
}
|
||||
|
||||
const apiError: ApiError = {
|
||||
message: errorData.message || `Erreur HTTP ${response.status}`,
|
||||
code: errorData.code || `HTTP_${response.status}`,
|
||||
status: response.status,
|
||||
details: errorData,
|
||||
};
|
||||
|
||||
// Retry pour certaines erreurs (5xx, timeouts, network errors)
|
||||
if (this.shouldRetry(response.status) && retryCount < this.config.maxRetries) {
|
||||
await this.delay(this.config.retryDelay * Math.pow(2, retryCount));
|
||||
return this.makeRequest<T>(endpoint, options, retryCount + 1);
|
||||
}
|
||||
|
||||
throw apiError;
|
||||
}
|
||||
|
||||
// Parser la réponse JSON
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Gestion des erreurs d'abort
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
this.setConnectionState('offline');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Requête annulée (timeout)',
|
||||
code: 'TIMEOUT',
|
||||
offline: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Gestion des erreurs réseau
|
||||
if (error instanceof TypeError && (error.message.includes('fetch') || error.message.includes('network'))) {
|
||||
this.setConnectionState('offline');
|
||||
|
||||
// Retry pour les erreurs réseau
|
||||
if (this.config.enableRetry && retryCount < this.config.maxRetries) {
|
||||
await this.delay(this.config.retryDelay * Math.pow(2, retryCount));
|
||||
return this.makeRequest<T>(endpoint, options, retryCount + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Erreur de connexion réseau - API hors ligne',
|
||||
code: 'NETWORK_ERROR',
|
||||
offline: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Re-lancer les autres erreurs
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Déterminer si une erreur justifie un retry
|
||||
*/
|
||||
private shouldRetry(status: number): boolean {
|
||||
if (!this.config.enableRetry) return false;
|
||||
return status >= 500 || status === 408 || status === 429;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attendre un délai spécifié
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Annuler la requête en cours
|
||||
*/
|
||||
public cancelRequest(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valider les données d'un workflow avant envoi
|
||||
*/
|
||||
private validateWorkflowData(workflow: WorkflowApiData): void {
|
||||
if (!workflow.name || workflow.name.trim().length === 0) {
|
||||
throw new Error('Le nom du workflow est obligatoire');
|
||||
}
|
||||
|
||||
if (workflow.name.length > 100) {
|
||||
throw new Error('Le nom du workflow ne peut pas dépasser 100 caractères');
|
||||
}
|
||||
|
||||
if (workflow.description && workflow.description.length > 500) {
|
||||
throw new Error('La description ne peut pas dépasser 500 caractères');
|
||||
}
|
||||
|
||||
if (!Array.isArray(workflow.steps)) {
|
||||
throw new Error('Les étapes du workflow doivent être un tableau');
|
||||
}
|
||||
|
||||
if (!Array.isArray(workflow.connections)) {
|
||||
throw new Error('Les connexions du workflow doivent être un tableau');
|
||||
}
|
||||
|
||||
if (!Array.isArray(workflow.variables)) {
|
||||
throw new Error('Les variables du workflow doivent être un tableau');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valider les données d'une étape avant exécution
|
||||
*/
|
||||
private validateStepData(stepData: any): void {
|
||||
if (!stepData.stepId || typeof stepData.stepId !== 'string') {
|
||||
throw new Error('L\'ID de l\'étape est obligatoire');
|
||||
}
|
||||
|
||||
if (!stepData.stepType || typeof stepData.stepType !== 'string') {
|
||||
throw new Error('Le type d\'étape est obligatoire');
|
||||
}
|
||||
|
||||
if (!stepData.parameters || typeof stepData.parameters !== 'object') {
|
||||
throw new Error('Les paramètres de l\'étape doivent être un objet');
|
||||
}
|
||||
}
|
||||
|
||||
// === MÉTHODES PUBLIQUES POUR LES WORKFLOWS ===
|
||||
|
||||
/**
|
||||
* Récupérer la liste des workflows
|
||||
* Retourne un tableau vide si hors ligne
|
||||
*/
|
||||
async getWorkflows(): Promise<any[]> {
|
||||
try {
|
||||
const response = await this.makeRequest<any[]>('/workflows/');
|
||||
// Retourner les données même si offline - laisser le composant gérer le fallback
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors du chargement des workflows:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer un workflow par ID
|
||||
*/
|
||||
async getWorkflow(workflowId: string): Promise<any | null> {
|
||||
if (!workflowId || workflowId.trim().length === 0) {
|
||||
throw new Error('L\'ID du workflow est obligatoire');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest<{ workflow: any }>(`/workflows/${workflowId}`);
|
||||
// Retourner les données même si offline est true - laisser le composant gérer
|
||||
return response.data?.workflow || response.data || null;
|
||||
} catch (error) {
|
||||
console.warn(`Erreur lors du chargement du workflow ${workflowId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarder un workflow
|
||||
* Retourne null si hors ligne
|
||||
*/
|
||||
async saveWorkflow(workflowData: WorkflowApiData): Promise<string | null> {
|
||||
// Validation côté client
|
||||
this.validateWorkflowData(workflowData);
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest<{ workflowId: string; id: string }>('/workflows/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(workflowData),
|
||||
});
|
||||
|
||||
if (response.offline) {
|
||||
console.warn('Sauvegarde impossible - API hors ligne');
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.data?.workflowId || response.data?.id || '';
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde du workflow:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour un workflow existant (auto-save)
|
||||
* Utilise PUT au lieu de POST
|
||||
*/
|
||||
async updateWorkflow(workflowId: string, workflowData: Partial<WorkflowApiData>): Promise<boolean> {
|
||||
if (!workflowId || workflowId.trim().length === 0) {
|
||||
throw new Error('L\'ID du workflow est obligatoire');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest<{ success: boolean }>(`/workflows/${workflowId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(workflowData),
|
||||
});
|
||||
|
||||
if (response.offline) {
|
||||
console.warn('Mise à jour impossible - API hors ligne');
|
||||
return false;
|
||||
}
|
||||
|
||||
return response.success;
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du workflow:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprimer un workflow
|
||||
*/
|
||||
async deleteWorkflow(workflowId: string): Promise<boolean> {
|
||||
if (!workflowId || workflowId.trim().length === 0) {
|
||||
throw new Error('L\'ID du workflow est obligatoire');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest(`/workflows/${workflowId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return !response.offline && response.success;
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors de la suppression du workflow ${workflowId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// === MÉTHODES POUR L'EXÉCUTION ===
|
||||
|
||||
/**
|
||||
* Exécuter une étape de workflow
|
||||
*/
|
||||
async executeStep(stepData: {
|
||||
stepId: string;
|
||||
stepType: string;
|
||||
parameters: any;
|
||||
workflowId?: string;
|
||||
}): Promise<{ success: boolean; output?: any; error?: string; offline?: boolean }> {
|
||||
// Validation côté client
|
||||
this.validateStepData(stepData);
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest<{
|
||||
success: boolean;
|
||||
output?: any;
|
||||
error?: string;
|
||||
}>('/workflow/execute-step', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(stepData),
|
||||
});
|
||||
|
||||
if (response.offline) {
|
||||
return { success: false, error: 'API hors ligne', offline: true };
|
||||
}
|
||||
|
||||
return response.data || { success: false, error: 'Réponse invalide du serveur' };
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'exécution de l\'étape:', error);
|
||||
return { success: false, error: (error as ApiError).message || 'Erreur inconnue' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécuter un workflow complet
|
||||
*/
|
||||
async executeWorkflow(workflowId: string, parameters?: any): Promise<{
|
||||
success: boolean;
|
||||
results?: any[];
|
||||
error?: string;
|
||||
offline?: boolean;
|
||||
}> {
|
||||
if (!workflowId || workflowId.trim().length === 0) {
|
||||
throw new Error('L\'ID du workflow est obligatoire');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest<{
|
||||
success: boolean;
|
||||
results?: any[];
|
||||
error?: string;
|
||||
}>('/workflow/execute', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
workflowId,
|
||||
parameters: parameters || {},
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.offline) {
|
||||
return { success: false, error: 'API hors ligne', offline: true };
|
||||
}
|
||||
|
||||
return response.data || { success: false, error: 'Réponse invalide du serveur' };
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors de l'exécution du workflow ${workflowId}:`, error);
|
||||
return { success: false, error: (error as ApiError).message || 'Erreur inconnue' };
|
||||
}
|
||||
}
|
||||
|
||||
// === MÉTHODES POUR LA VALIDATION ===
|
||||
|
||||
/**
|
||||
* Valider un workflow
|
||||
*/
|
||||
async validateWorkflow(workflowData: WorkflowApiData): Promise<{
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
offline?: boolean;
|
||||
}> {
|
||||
// Validation côté client d'abord
|
||||
try {
|
||||
this.validateWorkflowData(workflowData);
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
errors: [(error as ApiError).message],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest<{
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}>('/workflow/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(workflowData),
|
||||
});
|
||||
|
||||
if (response.offline) {
|
||||
// En mode hors ligne, faire une validation locale basique
|
||||
return {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: ['Validation serveur non disponible (mode hors ligne)'],
|
||||
offline: true,
|
||||
};
|
||||
}
|
||||
|
||||
return response.data || {
|
||||
isValid: false,
|
||||
errors: ['Erreur de validation du serveur'],
|
||||
warnings: [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors de la validation du workflow:', error);
|
||||
return {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: ['Validation serveur non disponible'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// === MÉTHODES UTILITAIRES ===
|
||||
|
||||
/**
|
||||
* Vérifier la santé de l'API
|
||||
*/
|
||||
async healthCheck(): Promise<{ status: string; timestamp: string; offline?: boolean }> {
|
||||
try {
|
||||
const response = await this.makeRequest<{ status: string; timestamp: string }>('/health');
|
||||
if (response.offline) {
|
||||
return { status: 'offline', timestamp: new Date().toISOString(), offline: true };
|
||||
}
|
||||
return response.data || { status: 'unknown', timestamp: new Date().toISOString() };
|
||||
} catch (error) {
|
||||
return { status: 'offline', timestamp: new Date().toISOString(), offline: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forcer une vérification de connexion
|
||||
*/
|
||||
async forceConnectionCheck(): Promise<boolean> {
|
||||
this.lastHealthCheck = 0; // Réinitialiser pour forcer la vérification
|
||||
return this.checkConnectionSilently();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les statistiques de l'API
|
||||
*/
|
||||
async getApiStats(): Promise<any> {
|
||||
try {
|
||||
const response = await this.makeRequest<any>('/stats');
|
||||
if (response.offline) {
|
||||
return { offline: true };
|
||||
}
|
||||
return response.data || {};
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors de la récupération des statistiques:', error);
|
||||
return { offline: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton du client API
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
// NOTE: L'initialisation est maintenant paresseuse (lazy)
|
||||
// Elle se fait automatiquement lors du premier appel API
|
||||
// Cela évite les boucles infinies au chargement de la page
|
||||
|
||||
// Export des types pour utilisation externe
|
||||
export type { ApiError, ApiResponse, ApiClientConfig, ConnectionState };
|
||||
export default ApiClient;
|
||||
Reference in New Issue
Block a user