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:
Dom
2026-01-20 21:38:47 +01:00
parent d2955ec1a1
commit a9a53991bc
3 changed files with 1987 additions and 2 deletions

View File

@@ -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

View 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;