Add base kubernetes support final fix 4

This commit is contained in:
Urtzi Alfaro
2025-09-29 07:54:25 +02:00
parent 57f77638cc
commit 4777e59e7a
14 changed files with 1041 additions and 167 deletions

View File

@@ -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);

View File

@@ -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,
});
};

View File

@@ -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 {

View File

@@ -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}`);
}
};

View File

@@ -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) {