REFACTOR external service and improve websocket training

This commit is contained in:
Urtzi Alfaro
2025-10-09 14:11:02 +02:00
parent 7c72f83c51
commit 3c689b4f98
111 changed files with 13289 additions and 2374 deletions

View File

@@ -13,13 +13,8 @@ import type {
TrainingJobResponse,
TrainingJobStatus,
SingleProductTrainingRequest,
ActiveModelResponse,
ModelMetricsResponse,
TrainedModelResponse,
TenantStatistics,
ModelPerformanceResponse,
ModelsQueryParams,
PaginatedResponse,
} from '../types/training';
// Query Keys Factory
@@ -30,10 +25,10 @@ export const trainingKeys = {
status: (tenantId: string, jobId: string) =>
[...trainingKeys.jobs.all(), 'status', tenantId, jobId] as const,
},
models: {
models: {
all: () => [...trainingKeys.all, 'models'] as const,
lists: () => [...trainingKeys.models.all(), 'list'] as const,
list: (tenantId: string, params?: ModelsQueryParams) =>
list: (tenantId: string, params?: any) =>
[...trainingKeys.models.lists(), tenantId, params] as const,
details: () => [...trainingKeys.models.all(), 'detail'] as const,
detail: (tenantId: string, modelId: string) =>
@@ -67,7 +62,7 @@ export const useTrainingJobStatus = (
jobId: !!jobId,
isWebSocketConnected,
queryEnabled: isEnabled
});
});
return useQuery<TrainingJobStatus, ApiError>({
queryKey: trainingKeys.jobs.status(tenantId, jobId),
@@ -76,14 +71,8 @@ export const useTrainingJobStatus = (
return trainingService.getTrainingJobStatus(tenantId, jobId);
},
enabled: isEnabled, // Completely disable when WebSocket connected
refetchInterval: (query) => {
// CRITICAL FIX: React Query executes refetchInterval even when enabled=false
// We must check WebSocket connection state here to prevent misleading polling
if (isWebSocketConnected) {
console.log('✅ WebSocket connected - HTTP polling DISABLED');
return false; // Disable polling when WebSocket is active
}
refetchInterval: isEnabled ? (query) => {
// Only set up refetch interval if the query is enabled
const data = query.state.data;
// Stop polling if we get auth errors or training is completed
@@ -96,9 +85,9 @@ export const useTrainingJobStatus = (
return false; // Stop polling when training is done
}
console.log('📊 HTTP fallback polling active (WebSocket actually disconnected) - 5s interval');
console.log('📊 HTTP fallback polling active (WebSocket disconnected) - 5s interval');
return 5000; // Poll every 5 seconds while training (fallback when WebSocket unavailable)
},
} : false, // Completely disable interval when WebSocket connected
staleTime: 1000, // Consider data stale after 1 second
retry: (failureCount, error) => {
// Don't retry on auth errors
@@ -116,9 +105,9 @@ export const useTrainingJobStatus = (
export const useActiveModel = (
tenantId: string,
inventoryProductId: string,
options?: Omit<UseQueryOptions<ActiveModelResponse, ApiError>, 'queryKey' | 'queryFn'>
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ActiveModelResponse, ApiError>({
return useQuery<any, ApiError>({
queryKey: trainingKeys.models.active(tenantId, inventoryProductId),
queryFn: () => trainingService.getActiveModel(tenantId, inventoryProductId),
enabled: !!tenantId && !!inventoryProductId,
@@ -129,10 +118,10 @@ export const useActiveModel = (
export const useModels = (
tenantId: string,
queryParams?: ModelsQueryParams,
options?: Omit<UseQueryOptions<PaginatedResponse<TrainedModelResponse>, ApiError>, 'queryKey' | 'queryFn'>
queryParams?: any,
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<PaginatedResponse<TrainedModelResponse>, ApiError>({
return useQuery<any, ApiError>({
queryKey: trainingKeys.models.list(tenantId, queryParams),
queryFn: () => trainingService.getModels(tenantId, queryParams),
enabled: !!tenantId,
@@ -158,9 +147,9 @@ export const useModelMetrics = (
export const useModelPerformance = (
tenantId: string,
modelId: string,
options?: Omit<UseQueryOptions<ModelPerformanceResponse, ApiError>, 'queryKey' | 'queryFn'>
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ModelPerformanceResponse, ApiError>({
return useQuery<any, ApiError>({
queryKey: trainingKeys.models.performance(tenantId, modelId),
queryFn: () => trainingService.getModelPerformance(tenantId, modelId),
enabled: !!tenantId && !!modelId,
@@ -172,9 +161,9 @@ export const useModelPerformance = (
// Statistics Queries
export const useTenantTrainingStatistics = (
tenantId: string,
options?: Omit<UseQueryOptions<TenantStatistics, ApiError>, 'queryKey' | 'queryFn'>
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<TenantStatistics, ApiError>({
return useQuery<any, ApiError>({
queryKey: trainingKeys.statistics(tenantId),
queryFn: () => trainingService.getTenantStatistics(tenantId),
enabled: !!tenantId,
@@ -207,7 +196,6 @@ export const useCreateTrainingJob = (
job_id: data.job_id,
status: data.status,
progress: 0,
message: data.message,
}
);
@@ -242,7 +230,6 @@ export const useTrainSingleProduct = (
job_id: data.job_id,
status: data.status,
progress: 0,
message: data.message,
}
);
@@ -448,76 +435,130 @@ export const useTrainingWebSocket = (
}
const message = JSON.parse(event.data);
console.log('🔔 Training WebSocket message received:', message);
// Handle heartbeat messages
if (message.type === 'heartbeat') {
console.log('💓 Heartbeat received from server');
return; // Don't process heartbeats further
// Handle initial state message to restore the latest known state
if (message.type === 'initial_state') {
console.log('📥 Received initial state:', message.data);
const initialData = message.data;
const initialEventData = initialData.data || {};
let initialProgress = initialEventData.progress || 0;
// Calculate progress for product_completed events
if (initialData.type === 'product_completed') {
const productsCompleted = initialEventData.products_completed || 0;
const totalProducts = initialEventData.total_products || 1;
initialProgress = 20 + Math.floor((productsCompleted / totalProducts) * 60);
console.log('📦 Product training completed in initial state',
`${productsCompleted}/${totalProducts}`,
`progress: ${initialProgress}%`);
}
// Update job status in cache with initial state
queryClient.setQueryData(
trainingKeys.jobs.status(tenantId, jobId),
(oldData: TrainingJobStatus | undefined) => ({
...oldData,
job_id: jobId,
status: initialData.type === 'completed' ? 'completed' :
initialData.type === 'failed' ? 'failed' :
initialData.type === 'started' ? 'running' :
initialData.type === 'progress' ? 'running' :
initialData.type === 'product_completed' ? 'running' :
initialData.type === 'step_completed' ? 'running' :
oldData?.status || 'running',
progress: typeof initialProgress === 'number' ? initialProgress : oldData?.progress || 0,
current_step: initialEventData.current_step || initialEventData.step_name || oldData?.current_step,
})
);
return; // Initial state messages are only for state restoration, don't process as regular events
}
// Extract data from backend message structure
const eventData = message.data || {};
const progress = eventData.progress || 0;
let progress = eventData.progress || 0;
const currentStep = eventData.current_step || eventData.step_name || '';
const statusMessage = eventData.message || eventData.status || '';
const stepDetails = eventData.step_details || '';
// Update job status in cache with backend structure
// Handle product_completed events - calculate progress dynamically
if (message.type === 'product_completed') {
const productsCompleted = eventData.products_completed || 0;
const totalProducts = eventData.total_products || 1;
// Calculate progress: 20% base + (completed/total * 60%)
progress = 20 + Math.floor((productsCompleted / totalProducts) * 60);
console.log('📦 Product training completed',
`${productsCompleted}/${totalProducts}`,
`progress: ${progress}%`);
}
// Update job status in cache
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' :
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)
// Call appropriate callback based on message type
switch (message.type) {
case 'connected':
console.log('🔗 WebSocket connected');
break;
case 'started':
console.log('🚀 Training started');
memoizedOptions?.onStarted?.(message);
break;
case 'progress':
console.log('📊 Training progress update', `${progress}%`);
memoizedOptions?.onProgress?.(message);
break;
case 'step_completed':
memoizedOptions?.onProgress?.(message); // Treat step completion as progress
case 'product_completed':
console.log('✅ Product training completed');
// Treat as progress update
memoizedOptions?.onProgress?.({
...message,
data: {
...eventData,
progress, // Use calculated progress
}
});
break;
case 'step_completed':
console.log('📋 Step completed');
memoizedOptions?.onProgress?.(message);
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
isManuallyDisconnected = true;
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);
}
isManuallyDisconnected = true;
break;
default:
console.log(`🔍 Received unknown message type: ${message.type}`);
console.log(`🔍 Unknown message type: ${message.type}`);
break;
}
} catch (error) {
@@ -593,28 +634,22 @@ export const useTrainingWebSocket = (
}
};
// 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
// Connect immediately to avoid missing early progress updates
console.log('🚀 Starting immediate WebSocket connection...');
connect();
// Cleanup function
return () => {
isManuallyDisconnected = true;
if (initialConnectionTimer) {
clearTimeout(initialConnectionTimer);
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
}
if (ws) {
ws.close(1000, 'Component unmounted');
}
setIsConnected(false);
};
}, [tenantId, jobId, queryClient, memoizedOptions]);
@@ -652,9 +687,8 @@ export const useTrainingProgress = (
return {
progress: jobStatus?.progress || 0,
currentStep: jobStatus?.current_step,
estimatedTimeRemaining: jobStatus?.estimated_time_remaining,
isComplete: jobStatus?.status === 'completed',
isFailed: jobStatus?.status === 'failed',
isRunning: jobStatus?.status === 'running',
};
};
};

View File

@@ -0,0 +1,130 @@
// frontend/src/api/services/external.ts
/**
* External Data API Service
* Handles weather and traffic data operations
*/
import { apiClient } from '../client';
import type {
CityInfoResponse,
DataAvailabilityResponse,
WeatherDataResponse,
TrafficDataResponse,
HistoricalWeatherRequest,
HistoricalTrafficRequest,
} from '../types/external';
class ExternalDataService {
/**
* List all supported cities
*/
async listCities(): Promise<CityInfoResponse[]> {
const response = await apiClient.get<CityInfoResponse[]>(
'/api/v1/external/cities'
);
return response.data;
}
/**
* Get data availability for a specific city
*/
async getCityAvailability(cityId: string): Promise<DataAvailabilityResponse> {
const response = await apiClient.get<DataAvailabilityResponse>(
`/api/v1/external/operations/cities/${cityId}/availability`
);
return response.data;
}
/**
* Get historical weather data (optimized city-based endpoint)
*/
async getHistoricalWeatherOptimized(
tenantId: string,
params: {
latitude: number;
longitude: number;
start_date: string;
end_date: string;
}
): Promise<WeatherDataResponse[]> {
const response = await apiClient.get<WeatherDataResponse[]>(
`/api/v1/tenants/${tenantId}/external/operations/historical-weather-optimized`,
{ params }
);
return response.data;
}
/**
* Get historical traffic data (optimized city-based endpoint)
*/
async getHistoricalTrafficOptimized(
tenantId: string,
params: {
latitude: number;
longitude: number;
start_date: string;
end_date: string;
}
): Promise<TrafficDataResponse[]> {
const response = await apiClient.get<TrafficDataResponse[]>(
`/api/v1/tenants/${tenantId}/external/operations/historical-traffic-optimized`,
{ params }
);
return response.data;
}
/**
* Get current weather for a location (real-time)
*/
async getCurrentWeather(
tenantId: string,
params: {
latitude: number;
longitude: number;
}
): Promise<WeatherDataResponse> {
const response = await apiClient.get<WeatherDataResponse>(
`/api/v1/tenants/${tenantId}/external/operations/weather/current`,
{ params }
);
return response.data;
}
/**
* Get weather forecast
*/
async getWeatherForecast(
tenantId: string,
params: {
latitude: number;
longitude: number;
days?: number;
}
): Promise<WeatherDataResponse[]> {
const response = await apiClient.get<WeatherDataResponse[]>(
`/api/v1/tenants/${tenantId}/external/operations/weather/forecast`,
{ params }
);
return response.data;
}
/**
* Get current traffic conditions (real-time)
*/
async getCurrentTraffic(
tenantId: string,
params: {
latitude: number;
longitude: number;
}
): Promise<TrafficDataResponse> {
const response = await apiClient.get<TrafficDataResponse>(
`/api/v1/tenants/${tenantId}/external/operations/traffic/current`,
{ params }
);
return response.data;
}
}
export const externalDataService = new ExternalDataService();
export default externalDataService;

View File

@@ -317,3 +317,44 @@ export interface TrafficForecastRequest {
longitude: number;
hours?: number; // Default: 24
}
// ================================================================
// CITY-BASED DATA TYPES (NEW)
// ================================================================
/**
* City information response
* Backend: services/external/app/schemas/city_data.py:CityInfoResponse
*/
export interface CityInfoResponse {
city_id: string;
name: string;
country: string;
latitude: number;
longitude: number;
radius_km: number;
weather_provider: string;
traffic_provider: string;
enabled: boolean;
}
/**
* Data availability response
* Backend: services/external/app/schemas/city_data.py:DataAvailabilityResponse
*/
export interface DataAvailabilityResponse {
city_id: string;
city_name: string;
// Weather availability
weather_available: boolean;
weather_start_date: string | null;
weather_end_date: string | null;
weather_record_count: number;
// Traffic availability
traffic_available: boolean;
traffic_start_date: string | null;
traffic_end_date: string | null;
traffic_record_count: number;
}