something
This commit is contained in:
@@ -21,6 +21,12 @@ function getCacheKey(endpoint, options = {}) {
|
||||
return `${endpoint}${query ? '?' + query : ''}`;
|
||||
}
|
||||
|
||||
// Utility function to normalize array responses (handles both old and new formats)
|
||||
export function normalizeArrayResponse(response) {
|
||||
const data = response?.data || response;
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
// Sentinel value to indicate a request was superseded (instead of rejecting)
|
||||
// Export it so components can check for it
|
||||
export const REQUEST_SUPERSEDED = Symbol('REQUEST_SUPERSEDED');
|
||||
@@ -36,6 +42,9 @@ function debounceRequest(key, requestFn, delay = DEBOUNCE_DELAY) {
|
||||
if (pending.timestamp && (now - pending.timestamp) < DEDUPE_WINDOW) {
|
||||
pending.promise.then(resolve).catch(reject);
|
||||
return;
|
||||
} else {
|
||||
// Request is older than dedupe window - remove it and create new one
|
||||
pendingRequests.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,8 +83,16 @@ export const setAuthErrorHandler = (handler) => {
|
||||
onAuthError = handler;
|
||||
};
|
||||
|
||||
const handleAuthError = (response) => {
|
||||
// Whitelist of endpoints that should NOT trigger auth error handling
|
||||
// These are endpoints that can legitimately return 401/403 without meaning the user is logged out
|
||||
const AUTH_CHECK_ENDPOINTS = ['/auth/me', '/auth/logout'];
|
||||
|
||||
const handleAuthError = (response, endpoint) => {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Don't trigger auth error handler for endpoints that check auth status
|
||||
if (AUTH_CHECK_ENDPOINTS.includes(endpoint)) {
|
||||
return;
|
||||
}
|
||||
// Trigger auth error handler if set (this will clear user state)
|
||||
if (onAuthError) {
|
||||
onAuthError();
|
||||
@@ -89,60 +106,79 @@ const handleAuthError = (response) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Extract error message from response - centralized to avoid duplication
|
||||
async function extractErrorMessage(response) {
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
return errorData?.error || response.statusText;
|
||||
} catch {
|
||||
return response.statusText;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async get(endpoint) {
|
||||
async get(endpoint, options = {}) {
|
||||
const abortController = options.signal || new AbortController();
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
credentials: 'include', // Include cookies for session
|
||||
signal: abortController.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Handle auth errors before parsing response
|
||||
// Don't redirect on /auth/me - that's the auth check itself
|
||||
if ((response.status === 401 || response.status === 403) && !endpoint.startsWith('/auth/')) {
|
||||
handleAuthError(response);
|
||||
// Don't redirect - let React handle UI change through state
|
||||
}
|
||||
const errorData = await response.json().catch(() => null);
|
||||
const errorMessage = errorData?.error || response.statusText;
|
||||
handleAuthError(response, endpoint);
|
||||
const errorMessage = await extractErrorMessage(response);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async post(endpoint, data) {
|
||||
async post(endpoint, data, options = {}) {
|
||||
const abortController = options.signal || new AbortController();
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'include', // Include cookies for session
|
||||
signal: abortController.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Handle auth errors before parsing response
|
||||
// Don't redirect on /auth/* endpoints - those are login/logout
|
||||
if ((response.status === 401 || response.status === 403) && !endpoint.startsWith('/auth/')) {
|
||||
handleAuthError(response);
|
||||
// Don't redirect - let React handle UI change through state
|
||||
}
|
||||
const errorData = await response.json().catch(() => null);
|
||||
const errorMessage = errorData?.error || response.statusText;
|
||||
handleAuthError(response, endpoint);
|
||||
const errorMessage = await extractErrorMessage(response);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async delete(endpoint) {
|
||||
async patch(endpoint, data, options = {}) {
|
||||
const abortController = options.signal || new AbortController();
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
credentials: 'include', // Include cookies for session
|
||||
signal: abortController.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Handle auth errors before parsing response
|
||||
// Don't redirect on /auth/* endpoints
|
||||
if ((response.status === 401 || response.status === 403) && !endpoint.startsWith('/auth/')) {
|
||||
handleAuthError(response);
|
||||
// Don't redirect - let React handle UI change through state
|
||||
}
|
||||
const errorData = await response.json().catch(() => null);
|
||||
const errorMessage = errorData?.error || response.statusText;
|
||||
handleAuthError(response, endpoint);
|
||||
const errorMessage = await extractErrorMessage(response);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async delete(endpoint, options = {}) {
|
||||
const abortController = options.signal || new AbortController();
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include', // Include cookies for session
|
||||
signal: abortController.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Handle auth errors before parsing response
|
||||
handleAuthError(response, endpoint);
|
||||
const errorMessage = await extractErrorMessage(response);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return response.json();
|
||||
@@ -179,8 +215,7 @@ export const api = {
|
||||
} else {
|
||||
// Handle auth errors
|
||||
if (xhr.status === 401 || xhr.status === 403) {
|
||||
handleAuthError({ status: xhr.status });
|
||||
// Don't redirect - let React handle UI change through state
|
||||
handleAuthError({ status: xhr.status }, endpoint);
|
||||
}
|
||||
try {
|
||||
const errorData = JSON.parse(xhr.responseText);
|
||||
@@ -263,7 +298,7 @@ export const jobs = {
|
||||
if (options.status) params.append('status', options.status);
|
||||
if (options.sort) params.append('sort', options.sort);
|
||||
const query = params.toString();
|
||||
return api.get(`/jobs/summary${query ? '?' + query : ''}`);
|
||||
return api.get(`/jobs/summary${query ? '?' + query : ''}`, options);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -286,7 +321,7 @@ export const jobs = {
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
return api.get(`/jobs/${id}`);
|
||||
return api.get(`/jobs/${id}`, options);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -319,7 +354,7 @@ export const jobs = {
|
||||
if (options.file_type) params.append('file_type', options.file_type);
|
||||
if (options.extension) params.append('extension', options.extension);
|
||||
const query = params.toString();
|
||||
return api.get(`/jobs/${jobId}/files${query ? '?' + query : ''}`);
|
||||
return api.get(`/jobs/${jobId}/files${query ? '?' + query : ''}`, options);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -333,8 +368,8 @@ export const jobs = {
|
||||
});
|
||||
},
|
||||
|
||||
async getContextArchive(jobId) {
|
||||
return api.get(`/jobs/${jobId}/context`);
|
||||
async getContextArchive(jobId, options = {}) {
|
||||
return api.get(`/jobs/${jobId}/context`, options);
|
||||
},
|
||||
|
||||
downloadFile(jobId, fileId) {
|
||||
@@ -354,7 +389,7 @@ export const jobs = {
|
||||
if (options.limit) params.append('limit', options.limit.toString());
|
||||
if (options.sinceId) params.append('since_id', options.sinceId.toString());
|
||||
const query = params.toString();
|
||||
const result = await api.get(`/jobs/${jobId}/tasks/${taskId}/logs${query ? '?' + query : ''}`);
|
||||
const result = await api.get(`/jobs/${jobId}/tasks/${taskId}/logs${query ? '?' + query : ''}`, options);
|
||||
// Handle both old format (array) and new format (object with logs, last_id, limit)
|
||||
if (Array.isArray(result)) {
|
||||
return { logs: result, last_id: result.length > 0 ? result[result.length - 1].id : 0, limit: options.limit || 100 };
|
||||
@@ -363,10 +398,21 @@ export const jobs = {
|
||||
});
|
||||
},
|
||||
|
||||
async getTaskSteps(jobId, taskId) {
|
||||
return api.get(`/jobs/${jobId}/tasks/${taskId}/steps`);
|
||||
async getTaskSteps(jobId, taskId, options = {}) {
|
||||
return api.get(`/jobs/${jobId}/tasks/${taskId}/steps`, options);
|
||||
},
|
||||
|
||||
// New unified client WebSocket - DEPRECATED: Use wsManager from websocket.js instead
|
||||
// This is kept for backwards compatibility but should not be used
|
||||
streamClientWebSocket() {
|
||||
console.warn('streamClientWebSocket() is deprecated - use wsManager from websocket.js instead');
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsHost = window.location.host;
|
||||
const url = `${wsProtocol}//${wsHost}${API_BASE}/ws`;
|
||||
return new WebSocket(url);
|
||||
},
|
||||
|
||||
// Old WebSocket methods (to be removed after migration)
|
||||
streamTaskLogsWebSocket(jobId, taskId, lastId = 0) {
|
||||
// Convert HTTP to WebSocket URL
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
@@ -378,7 +424,7 @@ export const jobs = {
|
||||
streamJobsWebSocket() {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsHost = window.location.host;
|
||||
const url = `${wsProtocol}//${wsHost}${API_BASE}/jobs/ws`;
|
||||
const url = `${wsProtocol}//${wsHost}${API_BASE}/jobs/ws-old`;
|
||||
return new WebSocket(url);
|
||||
},
|
||||
|
||||
@@ -408,7 +454,7 @@ export const jobs = {
|
||||
if (options.frameEnd) params.append('frame_end', options.frameEnd.toString());
|
||||
if (options.sort) params.append('sort', options.sort);
|
||||
const query = params.toString();
|
||||
return api.get(`/jobs/${jobId}/tasks${query ? '?' + query : ''}`);
|
||||
return api.get(`/jobs/${jobId}/tasks${query ? '?' + query : ''}`, options);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -421,7 +467,7 @@ export const jobs = {
|
||||
if (options.status) params.append('status', options.status);
|
||||
if (options.sort) params.append('sort', options.sort);
|
||||
const query = params.toString();
|
||||
return api.get(`/jobs/${jobId}/tasks/summary${query ? '?' + query : ''}`);
|
||||
return api.get(`/jobs/${jobId}/tasks/summary${query ? '?' + query : ''}`, options);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
177
web/src/utils/websocket.js
Normal file
177
web/src/utils/websocket.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// Shared WebSocket connection manager
|
||||
// All components should use this instead of creating their own connections
|
||||
|
||||
class WebSocketManager {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
this.listeners = new Map(); // Map of listener IDs to callback functions
|
||||
this.reconnectTimeout = null;
|
||||
this.reconnectDelay = 2000;
|
||||
this.isConnecting = false;
|
||||
this.listenerIdCounter = 0;
|
||||
this.verboseLogging = false; // Set to true to enable verbose WebSocket logging
|
||||
}
|
||||
|
||||
connect() {
|
||||
// If already connected or connecting, don't create a new connection
|
||||
if (this.ws && (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isConnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
|
||||
try {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsHost = window.location.host;
|
||||
const API_BASE = '/api';
|
||||
const url = `${wsProtocol}//${wsHost}${API_BASE}/jobs/ws`;
|
||||
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
if (this.verboseLogging) {
|
||||
console.log('Shared WebSocket connected');
|
||||
}
|
||||
this.isConnecting = false;
|
||||
this.notifyListeners('open', {});
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (this.verboseLogging) {
|
||||
console.log('WebSocketManager: Message received:', data.type, data.channel || 'no channel', data);
|
||||
}
|
||||
this.notifyListeners('message', data);
|
||||
} catch (error) {
|
||||
console.error('WebSocketManager: Failed to parse message:', error, 'Raw data:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('Shared WebSocket error:', error);
|
||||
this.isConnecting = false;
|
||||
this.notifyListeners('error', error);
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
if (this.verboseLogging) {
|
||||
console.log('Shared WebSocket closed:', {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
wasClean: event.wasClean
|
||||
});
|
||||
}
|
||||
this.ws = null;
|
||||
this.isConnecting = false;
|
||||
this.notifyListeners('close', event);
|
||||
|
||||
// Always retry connection
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
}
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
|
||||
this.connect();
|
||||
}
|
||||
}, this.reconnectDelay);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket:', error);
|
||||
this.isConnecting = false;
|
||||
// Retry after delay
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.connect();
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listenerId, callbacks) {
|
||||
// Generate ID if not provided
|
||||
if (!listenerId) {
|
||||
listenerId = `listener_${this.listenerIdCounter++}`;
|
||||
}
|
||||
|
||||
if (this.verboseLogging) {
|
||||
console.log('WebSocketManager: Subscribing listener:', listenerId, 'WebSocket state:', this.ws ? this.ws.readyState : 'no connection');
|
||||
}
|
||||
this.listeners.set(listenerId, callbacks);
|
||||
|
||||
// Connect if not already connected
|
||||
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
|
||||
if (this.verboseLogging) {
|
||||
console.log('WebSocketManager: WebSocket not connected, connecting...');
|
||||
}
|
||||
this.connect();
|
||||
}
|
||||
|
||||
// If already open, notify immediately
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN && callbacks.open) {
|
||||
if (this.verboseLogging) {
|
||||
console.log('WebSocketManager: WebSocket already open, calling open callback for listener:', listenerId);
|
||||
}
|
||||
// Use setTimeout to ensure this happens after the listener is registered
|
||||
setTimeout(() => {
|
||||
if (callbacks.open) {
|
||||
callbacks.open();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return listenerId;
|
||||
}
|
||||
|
||||
unsubscribe(listenerId) {
|
||||
this.listeners.delete(listenerId);
|
||||
|
||||
// If no more listeners, we could close the connection, but let's keep it open
|
||||
// in case other components need it
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
if (this.verboseLogging) {
|
||||
console.log('WebSocketManager: Sending message:', data);
|
||||
}
|
||||
this.ws.send(JSON.stringify(data));
|
||||
} else {
|
||||
console.warn('WebSocketManager: Cannot send message - connection not open. State:', this.ws ? this.ws.readyState : 'no connection', 'Message:', data);
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners(eventType, data) {
|
||||
this.listeners.forEach((callbacks) => {
|
||||
if (callbacks[eventType]) {
|
||||
try {
|
||||
callbacks[eventType](data);
|
||||
} catch (error) {
|
||||
console.error('Error in WebSocket listener:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getReadyState() {
|
||||
return this.ws ? this.ws.readyState : WebSocket.CLOSED;
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const wsManager = new WebSocketManager();
|
||||
|
||||
Reference in New Issue
Block a user