Fix new services implementation 5

This commit is contained in:
Urtzi Alfaro
2025-08-15 17:53:59 +02:00
parent 03b4d4185d
commit f7de9115d1
43 changed files with 1714 additions and 891 deletions

View File

@@ -16,13 +16,28 @@ class AuthInterceptor {
static setup() {
apiClient.addRequestInterceptor({
onRequest: async (config: RequestConfig) => {
const token = localStorage.getItem('auth_token');
let token = localStorage.getItem('auth_token');
console.log('🔐 AuthInterceptor: Checking auth token...', token ? 'Found' : 'Missing');
if (token) {
console.log('🔐 AuthInterceptor: Token preview:', token.substring(0, 20) + '...');
}
// For development: If no token exists or token is invalid, set a valid demo token
if ((!token || token === 'demo-development-token') && window.location.hostname === 'localhost') {
console.log('🔧 AuthInterceptor: Development mode - setting valid demo token');
token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxN2Q1ZTJjZC1hMjk4LTQyNzEtODZjNi01NmEzZGNiNDE0ZWUiLCJ1c2VyX2lkIjoiMTdkNWUyY2QtYTI5OC00MjcxLTg2YzYtNTZhM2RjYjQxNGVlIiwiZW1haWwiOiJ0ZXN0QGRlbW8uY29tIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NTI3MzEyNSwiaWF0IjoxNzU1MjcxMzI1LCJpc3MiOiJiYWtlcnktYXV0aCIsImZ1bGxfbmFtZSI6IkRlbW8gVXNlciIsImlzX3ZlcmlmaWVkIjpmYWxzZSwiaXNfYWN0aXZlIjp0cnVlLCJyb2xlIjoidXNlciJ9.RBfzH9L_NKySYkyLzBLYAApnrCFNK4OsGLLO-eCaTSI';
localStorage.setItem('auth_token', token);
}
if (token) {
config.headers = {
...config.headers,
Authorization: `Bearer ${token}`,
};
console.log('🔐 AuthInterceptor: Added Authorization header');
} else {
console.warn('⚠️ AuthInterceptor: No auth token found in localStorage');
}
return config;

View File

@@ -11,7 +11,7 @@ export { useTraining } from './useTraining';
export { useForecast } from './useForecast';
export { useNotification } from './useNotification';
export { useOnboarding, useOnboardingStep } from './useOnboarding';
export { useInventory, useInventoryDashboard, useInventoryItem } from './useInventory';
export { useInventory, useInventoryDashboard, useInventoryItem, useInventoryProducts } from './useInventory';
export { useRecipes, useProduction } from './useRecipes';
// Import hooks for combined usage
@@ -23,6 +23,7 @@ import { useTraining } from './useTraining';
import { useForecast } from './useForecast';
import { useNotification } from './useNotification';
import { useOnboarding } from './useOnboarding';
import { useInventory } from './useInventory';
// Combined hook for common operations
export const useApiHooks = () => {
@@ -30,10 +31,11 @@ export const useApiHooks = () => {
const tenant = useTenant();
const sales = useSales();
const external = useExternal();
const training = useTraining();
const training = useTraining({ disablePolling: true }); // Disable polling by default
const forecast = useForecast();
const notification = useNotification();
const onboarding = useOnboarding();
const inventory = useInventory();
return {
auth,
@@ -44,5 +46,6 @@ export const useApiHooks = () => {
forecast,
notification,
onboarding,
inventory
};
};

View File

@@ -31,7 +31,7 @@ export const useForecast = () => {
setError(null);
const newForecasts = await forecastingService.createSingleForecast(tenantId, request);
setForecasts(prev => [...newForecasts, ...prev]);
setForecasts(prev => [...newForecasts, ...(prev || [])]);
return newForecasts;
} catch (error) {
@@ -52,7 +52,7 @@ export const useForecast = () => {
setError(null);
const batchForecast = await forecastingService.createBatchForecast(tenantId, request);
setBatchForecasts(prev => [batchForecast, ...prev]);
setBatchForecasts(prev => [batchForecast, ...(prev || [])]);
return batchForecast;
} catch (error) {
@@ -90,7 +90,7 @@ export const useForecast = () => {
const batchForecast = await forecastingService.getBatchForecastStatus(tenantId, batchId);
// Update batch forecast in state
setBatchForecasts(prev => prev.map(bf =>
setBatchForecasts(prev => (prev || []).map(bf =>
bf.id === batchId ? batchForecast : bf
));
@@ -147,7 +147,7 @@ export const useForecast = () => {
setError(null);
const acknowledgedAlert = await forecastingService.acknowledgeForecastAlert(tenantId, alertId);
setAlerts(prev => prev.map(alert =>
setAlerts(prev => (prev || []).map(alert =>
alert.id === alertId ? acknowledgedAlert : alert
));
} catch (error) {

View File

@@ -20,6 +20,7 @@ import {
PaginatedResponse,
InventoryDashboardData
} from '../services/inventory.service';
import type { ProductInfo } from '../types';
import { useTenantId } from '../../hooks/useTenantId';
@@ -117,17 +118,29 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
try {
const response = await inventoryService.getInventoryItems(tenantId, params);
setItems(response.items);
console.log('🔄 useInventory: Loaded items:', response.items);
setItems(response.items || []); // Ensure it's always an array
setPagination({
page: response.page,
limit: response.limit,
total: response.total,
totalPages: response.total_pages
page: response.page || 1,
limit: response.limit || 20,
total: response.total || 0,
totalPages: response.total_pages || 0
});
} catch (err: any) {
console.error('❌ useInventory: Error loading items:', err);
const errorMessage = err.response?.data?.detail || err.message || 'Error loading inventory items';
setError(errorMessage);
toast.error(errorMessage);
setItems([]); // Set empty array on error
// Show appropriate error message
if (err.response?.status === 401) {
console.error('❌ useInventory: Authentication failed');
} else if (err.response?.status === 403) {
toast.error('No tienes permisos para acceder a este inventario');
} else {
toast.error(errorMessage);
}
} finally {
setIsLoading(false);
}
@@ -222,6 +235,7 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
setStockLevels(levelMap);
} catch (err: any) {
console.error('Error loading stock levels:', err);
// Don't show toast error for this as it's not critical for forecast page
}
}, [tenantId]);
@@ -273,6 +287,7 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
setAlerts(alertsData);
} catch (err: any) {
console.error('Error loading alerts:', err);
// Don't show toast error for this as it's not critical for forecast page
}
}, [tenantId]);
@@ -301,6 +316,7 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
setDashboardData(data);
} catch (err: any) {
console.error('Error loading dashboard:', err);
// Don't show toast error for this as it's not critical for forecast page
}
}, [tenantId]);
@@ -507,4 +523,61 @@ export const useInventoryItem = (itemId: string): UseInventoryItemReturn => {
adjustStock,
refresh
};
};
// ========== SIMPLE PRODUCTS HOOK FOR FORECASTING ==========
export const useInventoryProducts = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* Get Products List for Forecasting
*/
const getProductsList = useCallback(async (tenantId: string): Promise<ProductInfo[]> => {
try {
setIsLoading(true);
setError(null);
const products = await inventoryService.getProductsList(tenantId);
return products;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to get products list';
setError(message);
throw error;
} finally {
setIsLoading(false);
}
}, []);
/**
* Get Product by ID
*/
const getProductById = useCallback(async (tenantId: string, productId: string): Promise<ProductInfo | null> => {
try {
setIsLoading(true);
setError(null);
const product = await inventoryService.getProductById(tenantId, productId);
return product;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to get product';
setError(message);
throw error;
} finally {
setIsLoading(false);
}
}, []);
return {
// State
isLoading,
error,
// Actions
getProductsList,
getProductById,
};
};

View File

@@ -156,25 +156,6 @@ export const useSales = () => {
}
}, []);
/**
* Get Products List
*/
const getProductsList = useCallback(async (tenantId: string): Promise<string[]> => {
try {
setIsLoading(true);
setError(null);
const products = await salesService.getProductsList(tenantId);
return products;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to get products list';
setError(message);
throw error;
} finally {
setIsLoading(false);
}
}, []);
/**
* Get Sales Analytics
@@ -213,7 +194,6 @@ export const useSales = () => {
getDashboardStats,
getRecentActivity,
exportSalesData,
getProductsList,
getSalesAnalytics,
clearError: () => setError(null),
};

View File

@@ -16,6 +16,7 @@ export {
trainingService,
forecastingService,
notificationService,
inventoryService,
api
} from './services';
@@ -31,6 +32,8 @@ export {
useNotification,
useApiHooks,
useOnboarding,
useInventory,
useInventoryProducts
} from './hooks';
// Export WebSocket functionality

View File

@@ -80,6 +80,7 @@ export class HealthService {
{ name: 'Sales', endpoint: '/sales/health' },
{ name: 'External', endpoint: '/external/health' },
{ name: 'Training', endpoint: '/training/health' },
{ name: 'Inventory', endpoint: '/inventory/health' },
{ name: 'Forecasting', endpoint: '/forecasting/health' },
{ name: 'Notification', endpoint: '/notifications/health' },
];

View File

@@ -5,6 +5,7 @@
*/
import { apiClient } from '../client';
import type { ProductInfo } from '../types';
// ========== TYPES AND INTERFACES ==========
@@ -208,7 +209,7 @@ export interface PaginatedResponse<T> {
// ========== INVENTORY SERVICE CLASS ==========
export class InventoryService {
private baseEndpoint = '/api/v1';
private baseEndpoint = '';
// ========== INVENTORY ITEMS ==========
@@ -230,16 +231,70 @@ export class InventoryService {
}
const query = searchParams.toString();
const url = `${this.baseEndpoint}/tenants/${tenantId}/inventory/items${query ? `?${query}` : ''}`;
const url = `/tenants/${tenantId}/ingredients${query ? `?${query}` : ''}`;
return apiClient.get(url);
console.log('🔍 InventoryService: Fetching inventory items from:', url);
try {
console.log('🔑 InventoryService: Making request with auth token:', localStorage.getItem('auth_token') ? 'Present' : 'Missing');
const response = await apiClient.get(url);
console.log('📋 InventoryService: Raw response:', response);
console.log('📋 InventoryService: Response type:', typeof response);
console.log('📋 InventoryService: Response keys:', response ? Object.keys(response) : 'null');
// Handle different response formats
if (Array.isArray(response)) {
// Direct array response
console.log('✅ InventoryService: Array response with', response.length, 'items');
return {
items: response,
total: response.length,
page: 1,
limit: response.length,
total_pages: 1
};
} else if (response && typeof response === 'object') {
// Check if it's already paginated
if ('items' in response && Array.isArray(response.items)) {
console.log('✅ InventoryService: Paginated response with', response.items.length, 'items');
return response;
}
// Handle object with numeric keys (convert to array)
const keys = Object.keys(response);
if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) {
const items = Object.values(response);
console.log('✅ InventoryService: Numeric keys response with', items.length, 'items');
return {
items,
total: items.length,
page: 1,
limit: items.length,
total_pages: 1
};
}
// Handle empty object - this seems to be what we're getting
if (keys.length === 0) {
console.log('📭 InventoryService: Empty object response - backend has no inventory items for this tenant');
throw new Error('NO_INVENTORY_ITEMS'); // This will trigger fallback in useInventory
}
}
// Fallback: unexpected response format
console.warn('⚠️ InventoryService: Unexpected response format, keys:', Object.keys(response || {}));
throw new Error('UNEXPECTED_RESPONSE_FORMAT');
} catch (error) {
console.error('❌ InventoryService: Failed to fetch inventory items:', error);
throw error;
}
}
/**
* Get single inventory item by ID
*/
async getInventoryItem(tenantId: string, itemId: string): Promise<InventoryItem> {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/items/${itemId}`);
return apiClient.get(`/tenants/${tenantId}/ingredients/${itemId}`);
}
/**
@@ -249,7 +304,7 @@ export class InventoryService {
tenantId: string,
data: CreateInventoryItemRequest
): Promise<InventoryItem> {
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/items`, data);
return apiClient.post(`/tenants/${tenantId}/ingredients`, data);
}
/**
@@ -260,14 +315,14 @@ export class InventoryService {
itemId: string,
data: UpdateInventoryItemRequest
): Promise<InventoryItem> {
return apiClient.put(`${this.baseEndpoint}/tenants/${tenantId}/inventory/items/${itemId}`, data);
return apiClient.put(`/tenants/${tenantId}/ingredients/${itemId}`, data);
}
/**
* Delete inventory item (soft delete)
*/
async deleteInventoryItem(tenantId: string, itemId: string): Promise<void> {
return apiClient.delete(`${this.baseEndpoint}/tenants/${tenantId}/inventory/items/${itemId}`);
return apiClient.delete(`/tenants/${tenantId}/ingredients/${itemId}`);
}
/**
@@ -277,7 +332,7 @@ export class InventoryService {
tenantId: string,
updates: { id: string; data: UpdateInventoryItemRequest }[]
): Promise<{ success: number; failed: number; errors: string[] }> {
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/items/bulk-update`, {
return apiClient.post(`/tenants/${tenantId}/ingredients/bulk-update`, {
updates
});
}
@@ -288,14 +343,16 @@ export class InventoryService {
* Get current stock level for an item
*/
async getStockLevel(tenantId: string, itemId: string): Promise<StockLevel> {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/stock/${itemId}`);
return apiClient.get(`/tenants/${tenantId}/ingredients/${itemId}/stock`);
}
/**
* Get stock levels for all items
*/
async getAllStockLevels(tenantId: string): Promise<StockLevel[]> {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/stock`);
// TODO: Map to correct endpoint when available
return [];
// return apiClient.get(`/stock/summary`);
}
/**
@@ -307,7 +364,7 @@ export class InventoryService {
adjustment: StockAdjustmentRequest
): Promise<StockMovement> {
return apiClient.post(
`${this.baseEndpoint}/tenants/${tenantId}/inventory/stock/${itemId}/adjust`,
`/stock/consume`,
adjustment
);
}
@@ -353,7 +410,9 @@ export class InventoryService {
* Get current stock alerts
*/
async getStockAlerts(tenantId: string): Promise<StockAlert[]> {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/alerts`);
// TODO: Map to correct endpoint when available
return [];
// return apiClient.get(`/tenants/${tenantId}/inventory/alerts`);
}
/**
@@ -378,7 +437,17 @@ export class InventoryService {
* Get inventory dashboard data
*/
async getDashboardData(tenantId: string): Promise<InventoryDashboardData> {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/dashboard`);
// TODO: Map to correct endpoint when available
return {
total_items: 0,
low_stock_items: 0,
out_of_stock_items: 0,
total_value: 0,
recent_movements: [],
top_products: [],
stock_alerts: []
};
// return apiClient.get(`/tenants/${tenantId}/inventory/dashboard`);
}
/**
@@ -470,6 +539,87 @@ export class InventoryService {
async getSupplierSuggestions(tenantId: string): Promise<string[]> {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/suppliers`);
}
// ========== PRODUCTS FOR FORECASTING ==========
/**
* Get Products List with IDs for Forecasting
*/
async getProductsList(tenantId: string): Promise<ProductInfo[]> {
try {
const response = await apiClient.get(`/tenants/${tenantId}/ingredients`, {
params: { limit: 100 }, // Get all products
});
console.log('🔍 Inventory Products API Response:', response);
let productsArray: any[] = [];
if (Array.isArray(response)) {
productsArray = response;
} else if (response && typeof response === 'object') {
// Handle different response formats
const keys = Object.keys(response);
if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) {
productsArray = Object.values(response);
} else {
console.warn('⚠️ Response is object but not with numeric keys:', response);
return [];
}
} else {
console.warn('⚠️ Response is not array or object:', response);
return [];
}
// Convert to ProductInfo objects
const products: ProductInfo[] = productsArray
.map((product: any) => ({
inventory_product_id: product.id || product.inventory_product_id,
name: product.name || product.product_name || `Product ${product.id || ''}`,
category: product.category,
// Add additional fields if available from inventory
current_stock: product.current_stock,
unit: product.unit,
cost_per_unit: product.cost_per_unit
}))
.filter(product => product.inventory_product_id && product.name);
console.log('📋 Processed inventory products:', products);
return products;
} catch (error) {
console.error('❌ Failed to fetch inventory products:', error);
// Return empty array on error - let dashboard handle fallback
return [];
}
}
/**
* Get Product by ID
*/
async getProductById(tenantId: string, productId: string): Promise<ProductInfo | null> {
try {
const response = await apiClient.get(`/tenants/${tenantId}/ingredients/${productId}`);
if (response) {
return {
inventory_product_id: response.id || response.inventory_product_id,
name: response.name || response.product_name,
category: response.category,
current_stock: response.current_stock,
unit: response.unit,
cost_per_unit: response.cost_per_unit
};
}
return null;
} catch (error) {
console.error('❌ Failed to fetch product by ID:', error);
return null;
}
}
}
export const inventoryService = new InventoryService();

View File

@@ -181,85 +181,6 @@ export class SalesService {
});
}
/**
* Get Products List from Sales Data
*/
async getProductsList(tenantId: string): Promise<string[]> {
try {
const response = await apiClient.get(`/tenants/${tenantId}/sales/products`);
console.log('🔍 Products API Response Analysis:');
console.log('- Type:', typeof response);
console.log('- Is Array:', Array.isArray(response));
console.log('- Keys:', Object.keys(response || {}));
console.log('- Response:', response);
let productsArray: any[] = [];
// ✅ FIX: Handle different response formats
if (Array.isArray(response)) {
// Standard array response
productsArray = response;
console.log('✅ Response is already an array');
} else if (response && typeof response === 'object') {
// Object with numeric keys - convert to array
const keys = Object.keys(response);
if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) {
// Object has numeric keys like {0: {...}, 1: {...}}
productsArray = Object.values(response);
console.log('✅ Converted object with numeric keys to array');
} else {
console.warn('⚠️ Response is object but not with numeric keys:', response);
return [];
}
} else {
console.warn('⚠️ Response is not array or object:', response);
return [];
}
console.log('📦 Products array:', productsArray);
// Extract product names from the array
const productNames = productsArray
.map((product: any) => {
if (typeof product === 'string') {
return product;
}
if (product && typeof product === 'object') {
return product.product_name ||
product.name ||
product.productName ||
null;
}
return null;
})
.filter(Boolean) // Remove null/undefined values
.filter((name: string) => name.trim().length > 0); // Remove empty strings
console.log('📋 Extracted product names:', productNames);
if (productNames.length === 0) {
console.warn('⚠️ No valid product names extracted from response');
}
return productNames;
} catch (error) {
console.error('❌ Failed to fetch products list:', error);
// Return fallback products for Madrid bakery
return [
'Croissants',
'Pan de molde',
'Baguettes',
'Café',
'Napolitanas',
'Pan integral',
'Magdalenas',
'Churros'
];
}
}
/**
* Get Sales Summary by Period

View File

@@ -91,10 +91,21 @@ export class TenantService {
}
/**
* Get User's Tenants
* Get User's Tenants - Get tenants where user is owner
*/
async getUserTenants(): Promise<TenantInfo[]> {
return apiClient.get(`/users/me/tenants`);
try {
// First get current user info to get user ID
const currentUser = await apiClient.get(`/users/me`);
const userId = currentUser.id;
// Then get tenants owned by this user
return apiClient.get(`${this.baseEndpoint}/user/${userId}`);
} catch (error) {
console.error('Failed to get user tenants:', error);
// Return empty array if API call fails
return [];
}
}
}

View File

@@ -5,6 +5,15 @@
import { BaseQueryParams } from './common';
export interface ProductInfo {
inventory_product_id: string;
name: string;
category?: string;
sales_count?: number;
total_quantity?: number;
last_sale_date?: string;
}
export interface SalesData {
id: string;
tenant_id: string;
@@ -26,7 +35,6 @@ export interface SalesData {
cost_of_goods?: number;
revenue?: number;
quantity_sold?: number;
inventory_product_id?: string;
discount_applied?: number;
weather_condition?: string;
}

View File

@@ -97,20 +97,48 @@ export const useWebSocket = (config: WebSocketConfig) => {
// Hook for training job updates
export const useTrainingWebSocket = (jobId: string, tenantId?: string) => {
const [jobUpdates, setJobUpdates] = useState<any[]>([]);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [isAuthenticationError, setIsAuthenticationError] = useState(false);
// Get tenant ID reliably
// Get tenant ID reliably with enhanced error handling
const actualTenantId = tenantId || (() => {
try {
const userData = localStorage.getItem('user_data');
if (userData) {
const parsed = JSON.parse(userData);
return parsed.current_tenant_id || parsed.tenant_id;
}
// Try multiple sources for tenant ID
const sources = [
() => localStorage.getItem('current_tenant_id'),
() => {
const userData = localStorage.getItem('user_data');
if (userData) {
const parsed = JSON.parse(userData);
return parsed.current_tenant_id || parsed.tenant_id;
}
return null;
},
() => {
const authData = localStorage.getItem('auth_data');
if (authData) {
const parsed = JSON.parse(authData);
return parsed.tenant_id;
}
return null;
},
() => {
const tenantContext = localStorage.getItem('tenant_context');
if (tenantContext) {
const parsed = JSON.parse(tenantContext);
return parsed.current_tenant_id;
}
return null;
}
];
const authData = localStorage.getItem('auth_data');
if (authData) {
const parsed = JSON.parse(authData);
return parsed.tenant_id;
for (const source of sources) {
try {
const tenantId = source();
if (tenantId) return tenantId;
} catch (e) {
console.warn('Failed to get tenant ID from source:', e);
}
}
} catch (e) {
console.error('Failed to parse tenant ID from storage:', e);
@@ -123,8 +151,10 @@ export const useTrainingWebSocket = (jobId: string, tenantId?: string) => {
? `ws://localhost:8000/api/v1/ws/tenants/${actualTenantId}/training/jobs/${jobId}/live`
: `ws://localhost:8000/api/v1/ws/tenants/unknown/training/jobs/${jobId}/live`,
reconnect: true,
reconnectInterval: 3000,
maxReconnectAttempts: 10
reconnectInterval: 3000, // Faster reconnection for training
maxReconnectAttempts: 20, // More attempts for long training jobs
heartbeatInterval: 15000, // Send heartbeat every 15 seconds for training jobs
enableLogging: true // Enable logging for debugging
};
const {
@@ -137,40 +167,93 @@ export const useTrainingWebSocket = (jobId: string, tenantId?: string) => {
sendMessage
} = useWebSocket(config);
// Enhanced message handler
// Enhanced message handler with error handling
const handleWebSocketMessage = useCallback((message: any) => {
// Handle different message structures
let processedMessage = message;
// If message has nested data, flatten it for easier processing
if (message.data && typeof message.data === 'object') {
processedMessage = {
...message,
// Merge data properties to root level for backward compatibility
...message.data
};
}
try {
// Clear connection error when receiving messages
setConnectionError(null);
setIsAuthenticationError(false);
// Handle different message structures
let processedMessage = message;
// If message has nested data, flatten it for easier processing
if (message.data && typeof message.data === 'object') {
processedMessage = {
...message,
// Merge data properties to root level for backward compatibility
...message.data,
// Preserve original structure
_originalData: message.data
};
}
// Comprehensive message type handling
const trainingMessageTypes = [
'progress', 'training_progress',
'completed', 'training_completed',
'failed', 'training_failed',
'error', 'training_error',
'started', 'training_started',
'heartbeat', 'initial_status',
'status_update'
];
if (trainingMessageTypes.includes(message.type)) {
// Add to updates array with processed message
setJobUpdates(prev => {
const newUpdates = [processedMessage, ...prev.slice(0, 49)]; // Keep last 50 messages
return newUpdates;
});
} else {
// Still add to updates for debugging purposes
setJobUpdates(prev => [processedMessage, ...prev.slice(0, 49)]);
// Handle special message types
if (message.type === 'connection_established') {
console.log('WebSocket training connection established:', message);
setJobUpdates(prev => [{
type: 'connection_established',
message: 'Connected to training service',
timestamp: Date.now()
}, ...prev.slice(0, 49)]);
return;
}
// Handle keepalive messages (don't show to user, just for connection health)
if (message.type === 'pong' || message.type === 'heartbeat') {
console.debug('Training WebSocket keepalive received:', message.type);
return; // Don't add to jobUpdates
}
if (message.type === 'authentication_error' || message.type === 'authorization_error') {
console.error('WebSocket auth/authorization error:', message);
setIsAuthenticationError(true);
setConnectionError(message.message || 'Authentication/authorization failed - please refresh and try again');
return;
}
if (message.type === 'connection_error') {
console.error('WebSocket connection error:', message);
setConnectionError(message.message || 'Connection error');
return;
}
if (message.type === 'connection_timeout') {
console.warn('WebSocket connection timeout:', message);
// Don't set as error, just log - connection will retry
return;
}
if (message.type === 'job_not_found') {
console.error('Training job not found:', message);
setConnectionError('Training job not found. Please restart the training process.');
return;
}
// Comprehensive message type handling
const trainingMessageTypes = [
'progress', 'training_progress',
'completed', 'training_completed',
'failed', 'training_failed',
'error', 'training_error',
'started', 'training_started',
'heartbeat', 'initial_status',
'status_update'
];
if (trainingMessageTypes.includes(message.type)) {
// Add to updates array with processed message
setJobUpdates(prev => {
const newUpdates = [processedMessage, ...prev.slice(0, 49)]; // Keep last 50 messages
return newUpdates;
});
} else {
// Still add to updates for debugging purposes
console.log('Received unknown message type:', message.type, message);
setJobUpdates(prev => [processedMessage, ...prev.slice(0, 49)]);
}
} catch (error) {
console.error('Error processing WebSocket message:', error, message);
}
}, []);
@@ -179,21 +262,108 @@ export const useTrainingWebSocket = (jobId: string, tenantId?: string) => {
addMessageHandler(handleWebSocketMessage);
}, [addMessageHandler, handleWebSocketMessage]);
// Send periodic ping to keep connection alive
// Enhanced dual ping system for training jobs - prevent disconnection during long training
useEffect(() => {
if (isConnected) {
const pingInterval = setInterval(() => {
sendMessage({
type: 'ping',
data: undefined
// Primary ping system using JSON messages with training info
const keepaliveInterval = setInterval(() => {
const success = sendMessage({
type: 'training_keepalive',
data: {
timestamp: Date.now(),
job_id: jobId,
tenant_id: actualTenantId,
status: 'active'
}
});
}, 30000); // Every 30 seconds
if (!success) {
console.warn('Training keepalive failed - connection may be lost');
}
}, 10000); // Every 10 seconds for training jobs
// Secondary simple text ping system (more lightweight)
const simplePingInterval = setInterval(() => {
// Send a simple text ping to keep connection alive
const success = sendMessage({
type: 'ping',
data: {
timestamp: Date.now(),
source: 'training_client'
}
});
if (!success) {
console.warn('Simple training ping failed');
}
}, 15000); // Every 15 seconds
return () => {
clearInterval(pingInterval);
clearInterval(keepaliveInterval);
clearInterval(simplePingInterval);
};
}
}, [isConnected, sendMessage]);
}, [isConnected, sendMessage, jobId, actualTenantId]);
// Define refresh connection function
const refreshConnection = useCallback(() => {
setConnectionError(null);
setIsAuthenticationError(false);
disconnect();
setTimeout(() => {
connect();
}, 1000);
}, [connect, disconnect]);
// Enhanced connection monitoring and auto-recovery for training jobs
useEffect(() => {
if (actualTenantId && jobId !== 'pending') {
const healthCheckInterval = setInterval(() => {
// If we should be connected but aren't, try to reconnect
if (status === 'disconnected' && !connectionError) {
console.log('WebSocket health check: reconnecting disconnected training socket');
connect();
}
// More aggressive stale connection detection for training jobs
const lastUpdate = jobUpdates.length > 0 ? jobUpdates[0] : null;
if (lastUpdate && status === 'connected') {
const timeSinceLastMessage = Date.now() - (lastUpdate.timestamp || 0);
if (timeSinceLastMessage > 45000) { // 45 seconds without messages during training
console.log('WebSocket health check: connection appears stale, refreshing');
refreshConnection();
}
}
// If connection is in a failed state for too long, force reconnect
if (status === 'failed' && !isAuthenticationError) {
console.log('WebSocket health check: recovering from failed state');
setTimeout(() => connect(), 2000);
}
}, 12000); // Check every 12 seconds for training jobs
return () => clearInterval(healthCheckInterval);
}
}, [actualTenantId, jobId, status, connectionError, connect, refreshConnection, jobUpdates, isAuthenticationError]);
// Enhanced connection setup - request current status when connecting
useEffect(() => {
if (isConnected && jobId !== 'pending') {
// Wait a moment for connection to stabilize, then request current status
const statusRequestTimer = setTimeout(() => {
console.log('Requesting current training status after connection');
sendMessage({
type: 'get_status',
data: {
job_id: jobId,
tenant_id: actualTenantId
}
});
}, 2000);
return () => clearTimeout(statusRequestTimer);
}
}, [isConnected, jobId, actualTenantId, sendMessage]);
return {
status,
@@ -204,13 +374,33 @@ export const useTrainingWebSocket = (jobId: string, tenantId?: string) => {
lastMessage,
tenantId: actualTenantId,
wsUrl: config.url,
// Manual refresh function
refreshConnection: useCallback(() => {
connectionError,
isAuthenticationError,
// Enhanced refresh function with status request
refreshConnection,
// Force retry with new authentication
retryWithAuth: useCallback(() => {
setConnectionError(null);
setIsAuthenticationError(false);
// Clear any cached auth data that might be stale
disconnect();
setTimeout(() => {
connect();
}, 1000);
}, [connect, disconnect])
}, 2000);
}, [connect, disconnect]),
// Manual status request function
requestStatus: useCallback(() => {
if (isConnected && jobId !== 'pending') {
return sendMessage({
type: 'get_status',
data: {
job_id: jobId,
tenant_id: actualTenantId
}
});
}
return false;
}, [isConnected, jobId, actualTenantId, sendMessage])
};
};

View File

@@ -99,8 +99,17 @@ export class WebSocketManager {
this.handlers.onClose?.(event);
// Auto-reconnect if enabled and not manually closed
if (this.config.reconnect && event.code !== 1000) {
// Don't reconnect on authorization failures or job not found (1008) with specific reasons
const isAuthorizationError = event.code === 1008 &&
(event.reason === 'Authentication failed' || event.reason === 'Authorization failed');
const isJobNotFound = event.code === 1008 && event.reason === 'Job not found';
if (this.config.reconnect && event.code !== 1000 && !isAuthorizationError && !isJobNotFound) {
this.scheduleReconnect();
} else if (isAuthorizationError || isJobNotFound) {
this.log('Connection failed - stopping reconnection attempts:', event.reason);
this.status = 'failed';
this.handlers.onReconnectFailed?.();
}
};