REFACTOR external service and improve websocket training
This commit is contained in:
@@ -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',
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
130
frontend/src/api/services/external.ts
Normal file
130
frontend/src/api/services/external.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
||||
// Update zoomed data when filtered data changes
|
||||
useEffect(() => {
|
||||
console.log('🔍 Setting zoomed data from filtered data:', filteredData);
|
||||
// Always update zoomed data when filtered data changes, even if empty
|
||||
setZoomedData(filteredData);
|
||||
}, [filteredData]);
|
||||
|
||||
@@ -236,11 +237,19 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Use filteredData if zoomedData is empty but we have data
|
||||
const displayData = zoomedData.length > 0 ? zoomedData : filteredData;
|
||||
// Robust fallback logic for display data
|
||||
const displayData = zoomedData.length > 0 ? zoomedData : (filteredData.length > 0 ? filteredData : chartData);
|
||||
|
||||
console.log('📊 Final display data:', {
|
||||
chartDataLength: chartData.length,
|
||||
filteredDataLength: filteredData.length,
|
||||
zoomedDataLength: zoomedData.length,
|
||||
displayDataLength: displayData.length,
|
||||
displayData: displayData
|
||||
});
|
||||
|
||||
// Empty state - only show if we truly have no data
|
||||
if (displayData.length === 0 && chartData.length === 0) {
|
||||
if (displayData.length === 0) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
|
||||
@@ -95,21 +95,24 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
|
||||
}
|
||||
);
|
||||
|
||||
// Handle training status updates from HTTP polling (fallback only)
|
||||
// Handle training status updates from React Query cache (updated by WebSocket or HTTP fallback)
|
||||
useEffect(() => {
|
||||
if (!jobStatus || !jobId || trainingProgress?.stage === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📊 HTTP fallback status update:', jobStatus);
|
||||
console.log('📊 Training status update from cache:', jobStatus,
|
||||
`(source: ${isConnected ? 'WebSocket' : 'HTTP polling'})`);
|
||||
|
||||
// Check if training completed via HTTP polling fallback
|
||||
// Check if training completed
|
||||
if (jobStatus.status === 'completed' && trainingProgress?.stage !== 'completed') {
|
||||
console.log('✅ Training completion detected via HTTP fallback');
|
||||
console.log(`✅ Training completion detected (source: ${isConnected ? 'WebSocket' : 'HTTP polling'})`);
|
||||
setTrainingProgress({
|
||||
stage: 'completed',
|
||||
progress: 100,
|
||||
message: 'Entrenamiento completado exitosamente (detectado por verificación HTTP)'
|
||||
message: isConnected
|
||||
? 'Entrenamiento completado exitosamente'
|
||||
: 'Entrenamiento completado exitosamente (detectado por verificación HTTP)'
|
||||
});
|
||||
setIsTraining(false);
|
||||
|
||||
@@ -122,15 +125,15 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
|
||||
});
|
||||
}, 2000);
|
||||
} else if (jobStatus.status === 'failed') {
|
||||
console.log('❌ Training failure detected via HTTP fallback');
|
||||
console.log(`❌ Training failure detected (source: ${isConnected ? 'WebSocket' : 'HTTP polling'})`);
|
||||
setError('Error detectado durante el entrenamiento (verificación de estado)');
|
||||
setIsTraining(false);
|
||||
setTrainingProgress(null);
|
||||
} else if (jobStatus.status === 'running' && jobStatus.progress !== undefined) {
|
||||
// Update progress if we have newer information from HTTP polling fallback
|
||||
// Update progress if we have newer information
|
||||
const currentProgress = trainingProgress?.progress || 0;
|
||||
if (jobStatus.progress > currentProgress) {
|
||||
console.log(`📈 Progress update via HTTP fallback: ${jobStatus.progress}%`);
|
||||
console.log(`📈 Progress update (source: ${isConnected ? 'WebSocket' : 'HTTP polling'}): ${jobStatus.progress}%`);
|
||||
setTrainingProgress(prev => ({
|
||||
...prev,
|
||||
stage: 'training',
|
||||
@@ -140,7 +143,7 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
|
||||
}) as TrainingProgress);
|
||||
}
|
||||
}
|
||||
}, [jobStatus, jobId, trainingProgress?.stage, onComplete]);
|
||||
}, [jobStatus, jobId, trainingProgress?.stage, onComplete, isConnected]);
|
||||
|
||||
// Auto-trigger training when component mounts
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { forwardRef, ButtonHTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning';
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning' | 'gradient';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
isLoading?: boolean;
|
||||
isFullWidth?: boolean;
|
||||
@@ -29,8 +29,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'border rounded-md shadow-sm',
|
||||
'hover:shadow-md active:shadow-sm'
|
||||
'border rounded-md',
|
||||
];
|
||||
|
||||
const variantClasses = {
|
||||
@@ -38,19 +37,22 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
'bg-[var(--color-primary)] text-[var(--text-inverse)] border-[var(--color-primary)]',
|
||||
'hover:bg-[var(--color-primary-dark)] hover:border-[var(--color-primary-dark)]',
|
||||
'focus:ring-[var(--color-primary)]/20',
|
||||
'active:bg-[var(--color-primary-dark)]'
|
||||
'active:bg-[var(--color-primary-dark)]',
|
||||
'shadow-sm hover:shadow-md active:shadow-sm'
|
||||
],
|
||||
secondary: [
|
||||
'bg-[var(--color-secondary)] text-[var(--text-inverse)] border-[var(--color-secondary)]',
|
||||
'hover:bg-[var(--color-secondary-dark)] hover:border-[var(--color-secondary-dark)]',
|
||||
'focus:ring-[var(--color-secondary)]/20',
|
||||
'active:bg-[var(--color-secondary-dark)]'
|
||||
'active:bg-[var(--color-secondary-dark)]',
|
||||
'shadow-sm hover:shadow-md active:shadow-sm'
|
||||
],
|
||||
outline: [
|
||||
'bg-transparent text-[var(--color-primary)] border-[var(--color-primary)]',
|
||||
'hover:bg-[var(--color-primary)] hover:text-[var(--text-inverse)]',
|
||||
'focus:ring-[var(--color-primary)]/20',
|
||||
'active:bg-[var(--color-primary-dark)] active:border-[var(--color-primary-dark)]'
|
||||
'active:bg-[var(--color-primary-dark)] active:border-[var(--color-primary-dark)]',
|
||||
'shadow-sm hover:shadow-md active:shadow-sm'
|
||||
],
|
||||
ghost: [
|
||||
'bg-transparent text-[var(--text-primary)] border-transparent',
|
||||
@@ -62,19 +64,30 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
'bg-[var(--color-error)] text-[var(--text-inverse)] border-[var(--color-error)]',
|
||||
'hover:bg-[var(--color-error-dark)] hover:border-[var(--color-error-dark)]',
|
||||
'focus:ring-[var(--color-error)]/20',
|
||||
'active:bg-[var(--color-error-dark)]'
|
||||
'active:bg-[var(--color-error-dark)]',
|
||||
'shadow-sm hover:shadow-md active:shadow-sm'
|
||||
],
|
||||
success: [
|
||||
'bg-[var(--color-success)] text-[var(--text-inverse)] border-[var(--color-success)]',
|
||||
'hover:bg-[var(--color-success-dark)] hover:border-[var(--color-success-dark)]',
|
||||
'focus:ring-[var(--color-success)]/20',
|
||||
'active:bg-[var(--color-success-dark)]'
|
||||
'active:bg-[var(--color-success-dark)]',
|
||||
'shadow-sm hover:shadow-md active:shadow-sm'
|
||||
],
|
||||
warning: [
|
||||
'bg-[var(--color-warning)] text-[var(--text-inverse)] border-[var(--color-warning)]',
|
||||
'hover:bg-[var(--color-warning-dark)] hover:border-[var(--color-warning-dark)]',
|
||||
'focus:ring-[var(--color-warning)]/20',
|
||||
'active:bg-[var(--color-warning-dark)]'
|
||||
'active:bg-[var(--color-warning-dark)]',
|
||||
'shadow-sm hover:shadow-md active:shadow-sm'
|
||||
],
|
||||
gradient: [
|
||||
'bg-[var(--color-primary)] text-white border-[var(--color-primary)]',
|
||||
'hover:bg-[var(--color-primary-dark)] hover:border-[var(--color-primary-dark)]',
|
||||
'focus:ring-[var(--color-primary)]/20',
|
||||
'shadow-lg hover:shadow-xl',
|
||||
'transform hover:scale-105',
|
||||
'font-semibold'
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ const ForecastingPage: React.FC = () => {
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - parseInt(forecastPeriod));
|
||||
|
||||
// Fetch existing forecasts
|
||||
// NOTE: We don't need to fetch forecasts from API because we already have them
|
||||
// from the multi-day forecast response stored in currentForecastData
|
||||
// Keeping this disabled to avoid unnecessary API calls
|
||||
const {
|
||||
data: forecastsData,
|
||||
isLoading: forecastsLoading,
|
||||
@@ -38,7 +40,7 @@ const ForecastingPage: React.FC = () => {
|
||||
...(selectedProduct && { inventory_product_id: selectedProduct }),
|
||||
limit: 100
|
||||
}, {
|
||||
enabled: !!tenantId && hasGeneratedForecast && !!selectedProduct
|
||||
enabled: false // Disabled - we use currentForecastData from multi-day API response
|
||||
});
|
||||
|
||||
|
||||
@@ -72,12 +74,15 @@ const ForecastingPage: React.FC = () => {
|
||||
|
||||
// Build products list from ingredients that have trained models
|
||||
const products = useMemo(() => {
|
||||
if (!ingredientsData || !modelsData?.models) {
|
||||
if (!ingredientsData || !modelsData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Handle both array and paginated response formats
|
||||
const modelsList = Array.isArray(modelsData) ? modelsData : (modelsData.models || modelsData.items || []);
|
||||
|
||||
// Get inventory product IDs that have trained models
|
||||
const modelProductIds = new Set(modelsData.models.map(model => model.inventory_product_id));
|
||||
const modelProductIds = new Set(modelsList.map((model: any) => model.inventory_product_id));
|
||||
|
||||
// Filter ingredients to only those with models
|
||||
const ingredientsWithModels = ingredientsData.filter(ingredient =>
|
||||
@@ -130,10 +135,10 @@ const ForecastingPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Use either current forecast data or fetched data
|
||||
const forecasts = currentForecastData.length > 0 ? currentForecastData : (forecastsData?.forecasts || []);
|
||||
const isLoading = forecastsLoading || ingredientsLoading || modelsLoading || isGenerating;
|
||||
const hasError = forecastsError || ingredientsError || modelsError;
|
||||
// Use current forecast data from multi-day API response
|
||||
const forecasts = currentForecastData;
|
||||
const isLoading = ingredientsLoading || modelsLoading || isGenerating;
|
||||
const hasError = ingredientsError || modelsError;
|
||||
|
||||
// Calculate metrics from real data
|
||||
const totalDemand = forecasts.reduce((sum, f) => sum + f.predicted_demand, 0);
|
||||
|
||||
Reference in New Issue
Block a user