Add base kubernetes support final fix 4
This commit is contained in:
@@ -91,7 +91,27 @@ class ApiClient {
|
||||
|
||||
// Response interceptor for error handling and automatic token refresh
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(response) => {
|
||||
// Enhanced logging for token refresh header detection
|
||||
const refreshSuggested = response.headers['x-token-refresh-suggested'];
|
||||
if (refreshSuggested) {
|
||||
console.log('🔍 TOKEN REFRESH HEADER DETECTED:', {
|
||||
url: response.config?.url,
|
||||
method: response.config?.method,
|
||||
status: response.status,
|
||||
refreshSuggested,
|
||||
hasRefreshToken: !!this.refreshToken,
|
||||
currentTokenLength: this.authToken?.length || 0
|
||||
});
|
||||
}
|
||||
|
||||
// Check if server suggests token refresh
|
||||
if (refreshSuggested === 'true' && this.refreshToken) {
|
||||
console.log('🔄 Server suggests token refresh - refreshing proactively');
|
||||
this.proactiveTokenRefresh();
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
@@ -228,6 +248,40 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async proactiveTokenRefresh() {
|
||||
// Avoid multiple simultaneous proactive refreshes
|
||||
if (this.isRefreshing) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isRefreshing = true;
|
||||
console.log('🔄 Proactively refreshing token...');
|
||||
|
||||
const response = await this.client.post('/auth/refresh', {
|
||||
refresh_token: this.refreshToken
|
||||
});
|
||||
|
||||
const { access_token, refresh_token } = response.data;
|
||||
|
||||
// Update tokens
|
||||
this.setAuthToken(access_token);
|
||||
if (refresh_token) {
|
||||
this.setRefreshToken(refresh_token);
|
||||
}
|
||||
|
||||
// Update auth store
|
||||
await this.updateAuthStore(access_token, refresh_token);
|
||||
|
||||
console.log('✅ Proactive token refresh successful');
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Proactive token refresh failed:', error);
|
||||
// Don't handle as auth failure here - let the next 401 handle it
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAuthFailure() {
|
||||
try {
|
||||
// Clear tokens
|
||||
@@ -275,6 +329,117 @@ class ApiClient {
|
||||
return this.tenantId;
|
||||
}
|
||||
|
||||
// Token synchronization methods for WebSocket connections
|
||||
getCurrentValidToken(): string | null {
|
||||
return this.authToken;
|
||||
}
|
||||
|
||||
async ensureValidToken(): Promise<string | null> {
|
||||
const originalToken = this.authToken;
|
||||
const originalTokenShort = originalToken ? `${originalToken.slice(0, 20)}...${originalToken.slice(-10)}` : 'null';
|
||||
|
||||
console.log('🔍 ensureValidToken() called:', {
|
||||
hasToken: !!this.authToken,
|
||||
tokenPreview: originalTokenShort,
|
||||
isRefreshing: this.isRefreshing,
|
||||
hasRefreshToken: !!this.refreshToken
|
||||
});
|
||||
|
||||
// If we have a valid token, return it
|
||||
if (this.authToken && !this.isTokenNearExpiry(this.authToken)) {
|
||||
const expiryInfo = this.getTokenExpiryInfo(this.authToken);
|
||||
console.log('✅ Token is valid, returning current token:', {
|
||||
tokenPreview: originalTokenShort,
|
||||
expiryInfo
|
||||
});
|
||||
return this.authToken;
|
||||
}
|
||||
|
||||
// If token is near expiry or expired, try to refresh
|
||||
if (this.refreshToken && !this.isRefreshing) {
|
||||
console.log('🔄 Token needs refresh, attempting proactive refresh:', {
|
||||
reason: this.authToken ? 'near expiry' : 'no token',
|
||||
expiryInfo: this.authToken ? this.getTokenExpiryInfo(this.authToken) : 'N/A'
|
||||
});
|
||||
|
||||
try {
|
||||
await this.proactiveTokenRefresh();
|
||||
const newTokenShort = this.authToken ? `${this.authToken.slice(0, 20)}...${this.authToken.slice(-10)}` : 'null';
|
||||
const tokenChanged = originalToken !== this.authToken;
|
||||
|
||||
console.log('✅ Token refresh completed:', {
|
||||
tokenChanged,
|
||||
oldTokenPreview: originalTokenShort,
|
||||
newTokenPreview: newTokenShort,
|
||||
newExpiryInfo: this.authToken ? this.getTokenExpiryInfo(this.authToken) : 'N/A'
|
||||
});
|
||||
|
||||
return this.authToken;
|
||||
} catch (error) {
|
||||
console.warn('❌ Failed to refresh token in ensureValidToken:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('⚠️ Returning current token without refresh:', {
|
||||
reason: this.isRefreshing ? 'already refreshing' : 'no refresh token',
|
||||
tokenPreview: originalTokenShort
|
||||
});
|
||||
return this.authToken;
|
||||
}
|
||||
|
||||
private getTokenExpiryInfo(token: string): any {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const exp = payload.exp;
|
||||
const iat = payload.iat;
|
||||
if (!exp) return { error: 'No expiry in token' };
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const timeUntilExpiry = exp - now;
|
||||
const tokenLifetime = exp - iat;
|
||||
|
||||
return {
|
||||
issuedAt: new Date(iat * 1000).toISOString(),
|
||||
expiresAt: new Date(exp * 1000).toISOString(),
|
||||
lifetimeMinutes: Math.floor(tokenLifetime / 60),
|
||||
secondsUntilExpiry: timeUntilExpiry,
|
||||
minutesUntilExpiry: Math.floor(timeUntilExpiry / 60),
|
||||
isNearExpiry: timeUntilExpiry < 300,
|
||||
isExpired: timeUntilExpiry <= 0
|
||||
};
|
||||
} catch (error) {
|
||||
return { error: 'Failed to parse token', details: error };
|
||||
}
|
||||
}
|
||||
|
||||
private isTokenNearExpiry(token: string): boolean {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const exp = payload.exp;
|
||||
if (!exp) return false;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const timeUntilExpiry = exp - now;
|
||||
|
||||
// Consider token near expiry if less than 5 minutes remaining
|
||||
const isNear = timeUntilExpiry < 300;
|
||||
|
||||
if (isNear) {
|
||||
console.log('⏰ Token is near expiry:', {
|
||||
secondsUntilExpiry: timeUntilExpiry,
|
||||
minutesUntilExpiry: Math.floor(timeUntilExpiry / 60),
|
||||
expiresAt: new Date(exp * 1000).toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
return isNear;
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse token for expiry check:', error);
|
||||
return true; // Assume expired if we can't parse
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP Methods - Return direct data for React Query
|
||||
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response: AxiosResponse<T> = await this.client.get(url, config);
|
||||
|
||||
@@ -90,24 +90,35 @@ export const useUpdateStep = (
|
||||
|
||||
export const useMarkStepCompleted = (
|
||||
options?: UseMutationOptions<
|
||||
UserProgress,
|
||||
ApiError,
|
||||
UserProgress,
|
||||
ApiError,
|
||||
{ userId: string; stepName: string; data?: Record<string, any> }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
return useMutation<
|
||||
UserProgress,
|
||||
ApiError,
|
||||
UserProgress,
|
||||
ApiError,
|
||||
{ userId: string; stepName: string; data?: Record<string, any> }
|
||||
>({
|
||||
mutationFn: ({ userId, stepName, data }) =>
|
||||
mutationFn: ({ userId, stepName, data }) =>
|
||||
onboardingService.markStepCompleted(userId, stepName, data),
|
||||
onSuccess: (data, { userId }) => {
|
||||
// Update progress cache
|
||||
// Update progress cache with new data
|
||||
queryClient.setQueryData(onboardingKeys.progress(userId), data);
|
||||
|
||||
// Invalidate the query to ensure fresh data on next access
|
||||
queryClient.invalidateQueries({ queryKey: onboardingKeys.progress(userId) });
|
||||
},
|
||||
onError: (error, { userId, stepName }) => {
|
||||
console.error(`Failed to complete step ${stepName} for user ${userId}:`, error);
|
||||
|
||||
// Invalidate queries on error to ensure we get fresh data
|
||||
queryClient.invalidateQueries({ queryKey: onboardingKeys.progress(userId) });
|
||||
},
|
||||
// Prevent duplicate requests by using the step name as a mutation key
|
||||
mutationKey: (variables) => ['markStepCompleted', variables?.userId, variables?.stepName],
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* Provides data fetching, caching, and state management for training operations
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import * as React from 'react';
|
||||
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { trainingService } from '../services/training';
|
||||
import { ApiError } from '../client/apiClient';
|
||||
import { ApiError, apiClient } from '../client/apiClient';
|
||||
import { useAuthStore } from '../../stores/auth.store';
|
||||
import type {
|
||||
TrainingJobRequest,
|
||||
@@ -53,15 +53,62 @@ export const trainingKeys = {
|
||||
export const useTrainingJobStatus = (
|
||||
tenantId: string,
|
||||
jobId: string,
|
||||
options?: Omit<UseQueryOptions<TrainingJobStatus, ApiError>, 'queryKey' | 'queryFn'>
|
||||
options?: Omit<UseQueryOptions<TrainingJobStatus, ApiError>, 'queryKey' | 'queryFn'> & {
|
||||
isWebSocketConnected?: boolean;
|
||||
}
|
||||
) => {
|
||||
const { isWebSocketConnected, ...queryOptions } = options || {};
|
||||
|
||||
// Completely disable the query when WebSocket is connected
|
||||
const isEnabled = !!tenantId && !!jobId && !isWebSocketConnected;
|
||||
|
||||
console.log('🔄 Training status query:', {
|
||||
tenantId: !!tenantId,
|
||||
jobId: !!jobId,
|
||||
isWebSocketConnected,
|
||||
queryEnabled: isEnabled
|
||||
});
|
||||
|
||||
return useQuery<TrainingJobStatus, ApiError>({
|
||||
queryKey: trainingKeys.jobs.status(tenantId, jobId),
|
||||
queryFn: () => trainingService.getTrainingJobStatus(tenantId, jobId),
|
||||
enabled: !!tenantId && !!jobId,
|
||||
refetchInterval: 5000, // Poll every 5 seconds while training
|
||||
queryFn: () => {
|
||||
console.log('📡 Executing HTTP training status query (WebSocket disconnected)');
|
||||
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
|
||||
}
|
||||
|
||||
const data = query.state.data;
|
||||
|
||||
// Stop polling if we get auth errors or training is completed
|
||||
if (query.state.error && (query.state.error as any)?.status === 401) {
|
||||
console.log('🚫 Stopping status polling due to auth error');
|
||||
return false;
|
||||
}
|
||||
if (data?.status === 'completed' || data?.status === 'failed') {
|
||||
console.log('🏁 Training completed - stopping HTTP polling');
|
||||
return false; // Stop polling when training is done
|
||||
}
|
||||
|
||||
console.log('📊 HTTP fallback polling active (WebSocket actually disconnected) - 5s interval');
|
||||
return 5000; // Poll every 5 seconds while training (fallback when WebSocket unavailable)
|
||||
},
|
||||
staleTime: 1000, // Consider data stale after 1 second
|
||||
...options,
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry on auth errors
|
||||
if ((error as any)?.status === 401) {
|
||||
console.log('🚫 Not retrying due to auth error');
|
||||
return false;
|
||||
}
|
||||
return failureCount < 3;
|
||||
},
|
||||
...queryOptions,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -242,9 +289,9 @@ 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);
|
||||
const [connectionAttempts, setConnectionAttempts] = React.useState(0);
|
||||
|
||||
// Memoize options to prevent unnecessary effect re-runs
|
||||
const memoizedOptions = React.useMemo(() => options, [
|
||||
@@ -266,20 +313,44 @@ export const useTrainingWebSocket = (
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectAttempts = 3;
|
||||
|
||||
const connect = () => {
|
||||
const connect = async () => {
|
||||
try {
|
||||
setConnectionError(null);
|
||||
const effectiveToken = token || authToken;
|
||||
setConnectionAttempts(prev => prev + 1);
|
||||
|
||||
// Use centralized token management from apiClient
|
||||
let effectiveToken: string | null;
|
||||
|
||||
try {
|
||||
// Always use the apiClient's token management
|
||||
effectiveToken = await apiClient.ensureValidToken();
|
||||
|
||||
if (!effectiveToken) {
|
||||
throw new Error('No valid token available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get valid token for WebSocket:', error);
|
||||
setConnectionError('Authentication failed. Please log in again.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔄 Attempting WebSocket connection (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts + 1}):`, {
|
||||
tenantId,
|
||||
jobId,
|
||||
hasToken: !!effectiveToken
|
||||
hasToken: !!effectiveToken,
|
||||
tokenFromApiClient: true
|
||||
});
|
||||
|
||||
ws = trainingService.createWebSocketConnection(tenantId, jobId, token || authToken || undefined);
|
||||
|
||||
ws = trainingService.createWebSocketConnection(tenantId, jobId, effectiveToken);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('✅ Training WebSocket connected successfully');
|
||||
console.log('✅ Training WebSocket connected successfully', {
|
||||
readyState: ws?.readyState,
|
||||
url: ws?.url,
|
||||
jobId
|
||||
});
|
||||
// Track connection time for debugging
|
||||
(ws as any)._connectTime = Date.now();
|
||||
setIsConnected(true);
|
||||
reconnectAttempts = 0; // Reset on successful connection
|
||||
|
||||
@@ -291,23 +362,81 @@ export const useTrainingWebSocket = (
|
||||
console.warn('Failed to request status on connection:', e);
|
||||
}
|
||||
|
||||
// Set up periodic ping to keep connection alive
|
||||
const pingInterval = setInterval(() => {
|
||||
// Helper function to check if tokens represent different auth sessions
|
||||
const isTokenSessionDifferent = (oldToken: string, newToken: string): boolean => {
|
||||
if (!oldToken || !newToken) return !!oldToken !== !!newToken;
|
||||
|
||||
try {
|
||||
const oldPayload = JSON.parse(atob(oldToken.split('.')[1]));
|
||||
const newPayload = JSON.parse(atob(newToken.split('.')[1]));
|
||||
|
||||
// Compare by issued timestamp (iat) - different iat means new auth session
|
||||
return oldPayload.iat !== newPayload.iat;
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse token for session comparison, falling back to string comparison:', e);
|
||||
return oldToken !== newToken;
|
||||
}
|
||||
};
|
||||
|
||||
// Set up periodic ping and intelligent token refresh detection
|
||||
const heartbeatInterval = setInterval(async () => {
|
||||
if (ws?.readyState === WebSocket.OPEN && !isManuallyDisconnected) {
|
||||
try {
|
||||
// Check token validity (this may refresh if needed)
|
||||
const currentToken = await apiClient.ensureValidToken();
|
||||
|
||||
// Enhanced token change detection with detailed logging
|
||||
const tokenStringChanged = currentToken !== effectiveToken;
|
||||
const tokenSessionChanged = currentToken && effectiveToken ?
|
||||
isTokenSessionDifferent(effectiveToken, currentToken) : tokenStringChanged;
|
||||
|
||||
console.log('🔍 WebSocket token validation check:', {
|
||||
hasCurrentToken: !!currentToken,
|
||||
hasEffectiveToken: !!effectiveToken,
|
||||
tokenStringChanged,
|
||||
tokenSessionChanged,
|
||||
currentTokenPreview: currentToken ? `${currentToken.slice(0, 20)}...${currentToken.slice(-10)}` : 'null',
|
||||
effectiveTokenPreview: effectiveToken ? `${effectiveToken.slice(0, 20)}...${effectiveToken.slice(-10)}` : 'null'
|
||||
});
|
||||
|
||||
// Only reconnect if we have a genuine session change (different iat)
|
||||
if (tokenSessionChanged) {
|
||||
console.log('🔄 Token session changed - reconnecting WebSocket with new session token');
|
||||
console.log('📊 Session change details:', {
|
||||
reason: !currentToken ? 'token removed' :
|
||||
!effectiveToken ? 'token added' : 'new auth session',
|
||||
oldTokenIat: effectiveToken ? (() => {
|
||||
try { return JSON.parse(atob(effectiveToken.split('.')[1])).iat; } catch { return 'parse-error'; }
|
||||
})() : 'N/A',
|
||||
newTokenIat: currentToken ? (() => {
|
||||
try { return JSON.parse(atob(currentToken.split('.')[1])).iat; } catch { return 'parse-error'; }
|
||||
})() : 'N/A'
|
||||
});
|
||||
|
||||
// Close current connection and trigger reconnection with new token
|
||||
ws?.close(1000, 'Token session changed - reconnecting');
|
||||
clearInterval(heartbeatInterval);
|
||||
return;
|
||||
} else if (tokenStringChanged) {
|
||||
console.log('ℹ️ Token string changed but same session - continuing with current connection');
|
||||
// Update effective token reference for future comparisons
|
||||
effectiveToken = currentToken;
|
||||
}
|
||||
|
||||
console.log('✅ Token validated during heartbeat - same session');
|
||||
ws?.send('ping');
|
||||
console.log('💓 Sent ping to server');
|
||||
console.log('💓 Sent ping to server (token session validated)');
|
||||
} catch (e) {
|
||||
console.warn('Failed to send ping:', e);
|
||||
clearInterval(pingInterval);
|
||||
console.warn('Failed to send ping or validate token:', e);
|
||||
clearInterval(heartbeatInterval);
|
||||
}
|
||||
} else {
|
||||
clearInterval(pingInterval);
|
||||
clearInterval(heartbeatInterval);
|
||||
}
|
||||
}, 30000); // Ping every 30 seconds
|
||||
}, 30000); // Check every 30 seconds for token refresh and send ping
|
||||
|
||||
// Store interval for cleanup
|
||||
(ws as any).pingInterval = pingInterval;
|
||||
(ws as any).heartbeatInterval = heartbeatInterval;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -404,13 +533,22 @@ export const useTrainingWebSocket = (
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log(`❌ Training WebSocket disconnected. Code: ${event.code}, Reason: "${event.reason}"`);
|
||||
console.log(`❌ Training WebSocket disconnected. Code: ${event.code}, Reason: "${event.reason}"`, {
|
||||
wasClean: event.wasClean,
|
||||
jobId,
|
||||
timeConnected: ws ? `${Date.now() - (ws as any)._connectTime || 0}ms` : 'unknown',
|
||||
reconnectAttempts
|
||||
});
|
||||
setIsConnected(false);
|
||||
|
||||
// Detailed logging for different close codes
|
||||
switch (event.code) {
|
||||
case 1000:
|
||||
console.log('🔒 WebSocket closed normally');
|
||||
if (event.reason === 'Token refreshed - reconnecting') {
|
||||
console.log('🔄 WebSocket closed for token refresh - will reconnect immediately');
|
||||
} else {
|
||||
console.log('🔒 WebSocket closed normally');
|
||||
}
|
||||
break;
|
||||
case 1006:
|
||||
console.log('⚠️ WebSocket closed abnormally (1006) - likely server-side issue or network problem');
|
||||
@@ -425,11 +563,20 @@ export const useTrainingWebSocket = (
|
||||
console.log(`❓ WebSocket closed with code ${event.code}`);
|
||||
}
|
||||
|
||||
// Handle token refresh reconnection (immediate reconnect)
|
||||
if (event.code === 1000 && event.reason === 'Token refreshed - reconnecting') {
|
||||
console.log('🔄 Reconnecting immediately due to token refresh...');
|
||||
reconnectTimer = setTimeout(() => {
|
||||
connect(); // Reconnect immediately with fresh token
|
||||
}, 1000); // Short delay to allow cleanup
|
||||
return;
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -470,7 +617,7 @@ export const useTrainingWebSocket = (
|
||||
|
||||
setIsConnected(false);
|
||||
};
|
||||
}, [tenantId, jobId, token, authToken, queryClient, memoizedOptions]);
|
||||
}, [tenantId, jobId, queryClient, memoizedOptions]);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
@@ -479,17 +626,27 @@ export const useTrainingWebSocket = (
|
||||
};
|
||||
|
||||
// Utility Hooks
|
||||
export const useIsTrainingInProgress = (tenantId: string, jobId?: string) => {
|
||||
export const useIsTrainingInProgress = (
|
||||
tenantId: string,
|
||||
jobId?: string,
|
||||
isWebSocketConnected?: boolean
|
||||
) => {
|
||||
const { data: jobStatus } = useTrainingJobStatus(tenantId, jobId || '', {
|
||||
enabled: !!jobId,
|
||||
isWebSocketConnected,
|
||||
});
|
||||
|
||||
return jobStatus?.status === 'running' || jobStatus?.status === 'pending';
|
||||
};
|
||||
|
||||
export const useTrainingProgress = (tenantId: string, jobId?: string) => {
|
||||
export const useTrainingProgress = (
|
||||
tenantId: string,
|
||||
jobId?: string,
|
||||
isWebSocketConnected?: boolean
|
||||
) => {
|
||||
const { data: jobStatus } = useTrainingJobStatus(tenantId, jobId || '', {
|
||||
enabled: !!jobId,
|
||||
isWebSocketConnected,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -94,18 +94,21 @@ export const OnboardingWizard: React.FC = () => {
|
||||
const { setCurrentTenant } = useTenantActions();
|
||||
|
||||
// Auto-complete user_registered step if needed (runs first)
|
||||
const [autoCompletionAttempted, setAutoCompletionAttempted] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (userProgress && user?.id) {
|
||||
if (userProgress && user?.id && !autoCompletionAttempted && !markStepCompleted.isPending) {
|
||||
const userRegisteredStep = userProgress.steps.find(s => s.step_name === 'user_registered');
|
||||
|
||||
|
||||
if (!userRegisteredStep?.completed) {
|
||||
console.log('🔄 Auto-completing user_registered step for new user...');
|
||||
|
||||
setAutoCompletionAttempted(true);
|
||||
|
||||
markStepCompleted.mutate({
|
||||
userId: user.id,
|
||||
stepName: 'user_registered',
|
||||
data: {
|
||||
auto_completed: true,
|
||||
data: {
|
||||
auto_completed: true,
|
||||
completed_at: new Date().toISOString(),
|
||||
source: 'onboarding_wizard_auto_completion'
|
||||
}
|
||||
@@ -116,11 +119,13 @@ export const OnboardingWizard: React.FC = () => {
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('❌ Failed to auto-complete user_registered step:', error);
|
||||
// Reset flag on error to allow retry
|
||||
setAutoCompletionAttempted(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [userProgress, user?.id, markStepCompleted]);
|
||||
}, [userProgress, user?.id, autoCompletionAttempted, markStepCompleted.isPending]); // Removed markStepCompleted from deps
|
||||
|
||||
// Initialize step index based on backend progress with validation
|
||||
useEffect(() => {
|
||||
@@ -205,6 +210,12 @@ export const OnboardingWizard: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent concurrent mutations
|
||||
if (markStepCompleted.isPending) {
|
||||
console.warn(`⚠️ Step completion already in progress for "${currentStep.id}", skipping duplicate call`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🎯 Completing step: "${currentStep.id}" with data:`, data);
|
||||
|
||||
try {
|
||||
@@ -260,25 +271,50 @@ export const OnboardingWizard: React.FC = () => {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Error completing step "${currentStep.id}":`, error);
|
||||
|
||||
|
||||
// Extract detailed error information
|
||||
const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error';
|
||||
const statusCode = error?.response?.status;
|
||||
|
||||
|
||||
console.error(`📊 Error details: Status ${statusCode}, Message: ${errorMessage}`);
|
||||
|
||||
|
||||
// Handle different types of errors
|
||||
if (statusCode === 207) {
|
||||
// Multi-Status: Step updated but summary failed
|
||||
console.warn(`⚠️ Partial success for step "${currentStep.id}": ${errorMessage}`);
|
||||
|
||||
// Continue with step advancement since the actual step was completed
|
||||
if (currentStep.id === 'completion') {
|
||||
// Navigate to dashboard after completion
|
||||
if (isNewTenant) {
|
||||
navigate('/app/dashboard');
|
||||
} else {
|
||||
navigate('/app');
|
||||
}
|
||||
} else {
|
||||
// Auto-advance to next step after successful completion
|
||||
if (currentStepIndex < STEPS.length - 1) {
|
||||
setCurrentStepIndex(currentStepIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Show a warning but don't block progress
|
||||
console.warn(`Step "${currentStep.title}" completed with warnings: ${errorMessage}`);
|
||||
return; // Don't show error alert
|
||||
}
|
||||
|
||||
// Check if it's a dependency error
|
||||
if (errorMessage.includes('dependencies not met')) {
|
||||
console.error('🚫 Dependencies not met for step:', currentStep.id);
|
||||
|
||||
|
||||
// Check what dependencies are missing
|
||||
if (userProgress) {
|
||||
console.log('📋 Current progress:', userProgress);
|
||||
console.log('📋 Completed steps:', userProgress.steps.filter(s => s.completed).map(s => s.step_name));
|
||||
}
|
||||
}
|
||||
|
||||
// Don't advance automatically on error - user should see the issue
|
||||
|
||||
// Don't advance automatically on real errors - user should see the issue
|
||||
alert(`${t('onboarding:errors.step_failed', 'Error al completar paso')} "${currentStep.title}": ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Button } from '../../../ui/Button';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useCreateTrainingJob, useTrainingWebSocket } from '../../../../api/hooks/training';
|
||||
import { useCreateTrainingJob, useTrainingWebSocket, useTrainingJobStatus } from '../../../../api/hooks/training';
|
||||
|
||||
interface MLTrainingStepProps {
|
||||
onNext: () => void;
|
||||
@@ -85,6 +85,63 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
|
||||
} : undefined
|
||||
);
|
||||
|
||||
// Smart fallback polling - automatically disabled when WebSocket is connected
|
||||
const { data: jobStatus } = useTrainingJobStatus(
|
||||
currentTenant?.id || '',
|
||||
jobId || '',
|
||||
{
|
||||
enabled: !!jobId && !!currentTenant?.id,
|
||||
isWebSocketConnected: isConnected, // This will disable HTTP polling when WebSocket is connected
|
||||
}
|
||||
);
|
||||
|
||||
// Handle training status updates from HTTP polling (fallback only)
|
||||
useEffect(() => {
|
||||
if (!jobStatus || !jobId || trainingProgress?.stage === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📊 HTTP fallback status update:', jobStatus);
|
||||
|
||||
// Check if training completed via HTTP polling fallback
|
||||
if (jobStatus.status === 'completed' && trainingProgress?.stage !== 'completed') {
|
||||
console.log('✅ Training completion detected via HTTP fallback');
|
||||
setTrainingProgress({
|
||||
stage: 'completed',
|
||||
progress: 100,
|
||||
message: 'Entrenamiento completado exitosamente (detectado por verificación HTTP)'
|
||||
});
|
||||
setIsTraining(false);
|
||||
|
||||
setTimeout(() => {
|
||||
onComplete({
|
||||
jobId: jobId,
|
||||
success: true,
|
||||
message: 'Modelo entrenado correctamente',
|
||||
detectedViaPolling: true
|
||||
});
|
||||
}, 2000);
|
||||
} else if (jobStatus.status === 'failed') {
|
||||
console.log('❌ Training failure detected via HTTP fallback');
|
||||
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
|
||||
const currentProgress = trainingProgress?.progress || 0;
|
||||
if (jobStatus.progress > currentProgress) {
|
||||
console.log(`📈 Progress update via HTTP fallback: ${jobStatus.progress}%`);
|
||||
setTrainingProgress(prev => ({
|
||||
...prev,
|
||||
stage: 'training',
|
||||
progress: jobStatus.progress,
|
||||
message: jobStatus.message || 'Entrenando modelo...',
|
||||
currentStep: jobStatus.current_step
|
||||
}) as TrainingProgress);
|
||||
}
|
||||
}
|
||||
}, [jobStatus, jobId, trainingProgress?.stage, onComplete]);
|
||||
|
||||
// Auto-trigger training when component mounts
|
||||
useEffect(() => {
|
||||
if (currentTenant?.id && !isTraining && !trainingProgress && !error) {
|
||||
|
||||
Reference in New Issue
Block a user