something

This commit is contained in:
2025-11-27 00:46:48 -06:00
parent 11e7552b5b
commit edc8ea160c
43 changed files with 9990 additions and 3059 deletions

View File

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