Simplify the onboardinf flow components 2
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user