Simplify the onboardinf flow components 2

This commit is contained in:
Urtzi Alfaro
2025-09-08 21:44:04 +02:00
parent 2e1e696cb5
commit c8b1a941f8
6 changed files with 726 additions and 269 deletions

View File

@@ -3,9 +3,11 @@
* Provides data fetching, caching, and state management for training operations
*/
import React from 'react';
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { trainingService } from '../services/training';
import { ApiError } from '../client/apiClient';
import { useAuthStore } from '../../stores/auth.store';
import type {
TrainingJobRequest,
TrainingJobResponse,
@@ -240,89 +242,240 @@ export const useTrainingWebSocket = (
}
) => {
const queryClient = useQueryClient();
const authToken = useAuthStore((state) => state.token);
const [isConnected, setIsConnected] = React.useState(false);
const [connectionError, setConnectionError] = React.useState<string | null>(null);
return useQuery({
queryKey: ['training-websocket', tenantId, jobId],
queryFn: () => {
return new Promise((resolve, reject) => {
try {
const ws = trainingService.createWebSocketConnection(tenantId, jobId, token);
// Memoize options to prevent unnecessary effect re-runs
const memoizedOptions = React.useMemo(() => options, [
options?.onProgress,
options?.onCompleted,
options?.onError,
options?.onStarted,
options?.onCancelled
]);
ws.onopen = () => {
console.log('Training WebSocket connected');
};
React.useEffect(() => {
if (!tenantId || !jobId || !memoizedOptions) {
return;
}
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
// Update job status in cache
queryClient.setQueryData(
trainingKeys.jobs.status(tenantId, jobId),
(oldData: TrainingJobStatus | undefined) => ({
...oldData,
job_id: jobId,
status: message.status || oldData?.status || 'running',
progress: message.progress?.percentage || oldData?.progress || 0,
message: message.message || oldData?.message || '',
current_step: message.progress?.current_step || oldData?.current_step,
estimated_time_remaining: message.progress?.estimated_time_remaining || oldData?.estimated_time_remaining,
})
);
let ws: WebSocket | null = null;
let reconnectTimer: NodeJS.Timeout | null = null;
let isManuallyDisconnected = false;
let reconnectAttempts = 0;
const maxReconnectAttempts = 3;
// Call appropriate callback based on message type
switch (message.type) {
case 'progress':
options?.onProgress?.(message);
break;
case 'completed':
options?.onCompleted?.(message);
// Invalidate models and statistics
queryClient.invalidateQueries({ queryKey: trainingKeys.models.all() });
queryClient.invalidateQueries({ queryKey: trainingKeys.statistics(tenantId) });
resolve(message);
break;
case 'error':
options?.onError?.(message);
reject(new Error(message.error));
break;
case 'started':
options?.onStarted?.(message);
break;
case 'cancelled':
options?.onCancelled?.(message);
resolve(message);
break;
const connect = () => {
try {
setConnectionError(null);
const effectiveToken = token || authToken;
console.log(`🔄 Attempting WebSocket connection (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts + 1}):`, {
tenantId,
jobId,
hasToken: !!effectiveToken
});
ws = trainingService.createWebSocketConnection(tenantId, jobId, token || authToken || undefined);
ws.onopen = () => {
console.log('✅ Training WebSocket connected successfully');
setIsConnected(true);
reconnectAttempts = 0; // Reset on successful connection
// Request current status on connection
try {
ws?.send('get_status');
console.log('📤 Requested current training status');
} catch (e) {
console.warn('Failed to request status on connection:', e);
}
// Set up periodic ping to keep connection alive
const pingInterval = setInterval(() => {
if (ws?.readyState === WebSocket.OPEN && !isManuallyDisconnected) {
try {
ws?.send('ping');
console.log('💓 Sent ping to server');
} catch (e) {
console.warn('Failed to send ping:', e);
clearInterval(pingInterval);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
reject(error);
} else {
clearInterval(pingInterval);
}
};
}, 30000); // Ping every 30 seconds
// Store interval for cleanup
(ws as any).pingInterval = pingInterval;
};
ws.onerror = (error) => {
console.error('Training WebSocket error:', error);
reject(error);
};
ws.onmessage = (event) => {
try {
// Handle non-JSON messages (like pong responses)
if (typeof event.data === 'string' && event.data === 'pong') {
console.log('🏓 Pong received from server');
return;
}
ws.onclose = () => {
console.log('Training WebSocket disconnected');
};
const message = JSON.parse(event.data);
console.log('🔔 Training WebSocket message received:', message);
// Return cleanup function
return () => {
ws.close();
};
} catch (error) {
reject(error);
}
});
},
enabled: !!tenantId && !!jobId,
refetchOnWindowFocus: false,
retry: false,
staleTime: Infinity,
});
// Handle heartbeat messages
if (message.type === 'heartbeat') {
console.log('💓 Heartbeat received from server');
return; // Don't process heartbeats further
}
// Extract data from backend message structure
const eventData = message.data || {};
const progress = eventData.progress || 0;
const currentStep = eventData.current_step || eventData.step_name || '';
const statusMessage = eventData.message || eventData.status || '';
// Update job status in cache with backend structure
queryClient.setQueryData(
trainingKeys.jobs.status(tenantId, jobId),
(oldData: TrainingJobStatus | undefined) => ({
...oldData,
job_id: jobId,
status: message.type === 'completed' ? 'completed' :
message.type === 'failed' ? 'failed' :
message.type === 'started' ? 'running' :
oldData?.status || 'running',
progress: typeof progress === 'number' ? progress : oldData?.progress || 0,
message: statusMessage || oldData?.message || '',
current_step: currentStep || oldData?.current_step,
estimated_time_remaining: eventData.estimated_time_remaining || oldData?.estimated_time_remaining,
})
);
// Call appropriate callback based on message type (exact backend mapping)
switch (message.type) {
case 'started':
memoizedOptions?.onStarted?.(message);
break;
case 'progress':
memoizedOptions?.onProgress?.(message);
break;
case 'step_completed':
memoizedOptions?.onProgress?.(message); // Treat step completion as progress
break;
case 'completed':
console.log('✅ Training completed successfully');
memoizedOptions?.onCompleted?.(message);
// Invalidate models and statistics
queryClient.invalidateQueries({ queryKey: trainingKeys.models.all() });
queryClient.invalidateQueries({ queryKey: trainingKeys.statistics(tenantId) });
isManuallyDisconnected = true; // Don't reconnect after completion
break;
case 'failed':
console.log('❌ Training failed');
memoizedOptions?.onError?.(message);
isManuallyDisconnected = true; // Don't reconnect after failure
break;
case 'cancelled':
console.log('🛑 Training cancelled');
memoizedOptions?.onCancelled?.(message);
isManuallyDisconnected = true; // Don't reconnect after cancellation
break;
case 'current_status':
console.log('📊 Received current training status');
// Treat current status as progress update if it has progress data
if (message.data) {
memoizedOptions?.onProgress?.(message);
}
break;
default:
console.log(`🔍 Received unknown message type: ${message.type}`);
break;
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
setConnectionError('Error parsing message from server');
}
};
ws.onerror = (error) => {
console.error('Training WebSocket error:', error);
setConnectionError('WebSocket connection error');
setIsConnected(false);
};
ws.onclose = (event) => {
console.log(`❌ Training WebSocket disconnected. Code: ${event.code}, Reason: "${event.reason}"`);
setIsConnected(false);
// Detailed logging for different close codes
switch (event.code) {
case 1000:
console.log('🔒 WebSocket closed normally');
break;
case 1006:
console.log('⚠️ WebSocket closed abnormally (1006) - likely server-side issue or network problem');
break;
case 1001:
console.log('🔄 WebSocket endpoint going away');
break;
case 1003:
console.log('❌ WebSocket unsupported data received');
break;
default:
console.log(`❓ WebSocket closed with code ${event.code}`);
}
// Try to reconnect if not manually disconnected and haven't exceeded max attempts
if (!isManuallyDisconnected && event.code !== 1000 && reconnectAttempts < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000); // Exponential backoff, max 10s
console.log(`🔄 Attempting to reconnect WebSocket in ${delay/1000}s... (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})`);
reconnectTimer = setTimeout(() => {
reconnectAttempts++;
connect();
}, delay);
} else if (reconnectAttempts >= maxReconnectAttempts) {
console.log(`❌ Max reconnection attempts (${maxReconnectAttempts}) reached. Giving up.`);
setConnectionError(`Connection failed after ${maxReconnectAttempts} attempts. The training job may not exist or the server may be unavailable.`);
}
};
} catch (error) {
console.error('Error creating WebSocket connection:', error);
setConnectionError('Failed to create WebSocket connection');
}
};
// Delay initial connection to ensure training job is created
const initialConnectionTimer = setTimeout(() => {
console.log('🚀 Starting initial WebSocket connection...');
connect();
}, 2000); // 2-second delay to let the job initialize
// Cleanup function
return () => {
isManuallyDisconnected = true;
if (initialConnectionTimer) {
clearTimeout(initialConnectionTimer);
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
}
if (ws) {
ws.close(1000, 'Component unmounted');
}
setIsConnected(false);
};
}, [tenantId, jobId, token, authToken, queryClient, memoizedOptions]);
return {
isConnected,
connectionError
};
};
// Utility Hooks

View File

@@ -13,18 +13,9 @@ export interface ImportValidationResponse {
total_records: number;
valid_records: number;
invalid_records: number;
errors: string[];
warnings: string[];
summary: {
status: string;
file_format: string;
file_size_bytes: number;
file_size_mb: number;
estimated_processing_time_seconds: number;
validation_timestamp: string;
detected_columns: string[];
suggestions: string[];
};
errors: Array<Record<string, any>>;
warnings: Array<Record<string, any>>;
summary: Record<string, any>;
unique_products: number;
product_list: string[];
message: string;

View File

@@ -6,17 +6,17 @@
export interface IngredientCreate {
name: string;
description?: string;
category: string;
category?: string;
unit_of_measure: string;
minimum_stock_level: number;
maximum_stock_level: number;
low_stock_threshold: number;
max_stock_level?: number;
reorder_point: number;
shelf_life_days?: number;
requires_refrigeration?: boolean;
requires_freezing?: boolean;
is_seasonal?: boolean;
supplier_id?: string;
cost_per_unit?: number;
average_cost?: number;
notes?: string;
}
@@ -25,15 +25,15 @@ export interface IngredientUpdate {
description?: string;
category?: string;
unit_of_measure?: string;
minimum_stock_level?: number;
maximum_stock_level?: number;
low_stock_threshold?: number;
max_stock_level?: number;
reorder_point?: number;
shelf_life_days?: number;
requires_refrigeration?: boolean;
requires_freezing?: boolean;
is_seasonal?: boolean;
supplier_id?: string;
cost_per_unit?: number;
average_cost?: number;
notes?: string;
}
@@ -44,15 +44,15 @@ export interface IngredientResponse {
description?: string;
category: string;
unit_of_measure: string;
minimum_stock_level: number;
maximum_stock_level: number;
low_stock_threshold: number;
max_stock_level: number;
reorder_point: number;
shelf_life_days?: number;
requires_refrigeration: boolean;
requires_freezing: boolean;
is_seasonal: boolean;
supplier_id?: string;
cost_per_unit?: number;
average_cost?: number;
notes?: string;
current_stock_level: number;
available_stock: number;