503 lines
17 KiB
JavaScript
503 lines
17 KiB
JavaScript
const API_BASE = '/api';
|
|
|
|
// Global auth error handler - will be set by useAuth hook
|
|
let onAuthError = null;
|
|
|
|
// Request debouncing and deduplication
|
|
const pendingRequests = new Map(); // key: endpoint+params, value: Promise
|
|
const requestQueue = new Map(); // key: endpoint+params, value: { resolve, reject, timestamp }
|
|
const DEBOUNCE_DELAY = 100; // 100ms debounce delay
|
|
const DEDUPE_WINDOW = 5000; // 5 seconds - same request within this window uses cached promise
|
|
|
|
// Generate cache key from endpoint and params
|
|
function getCacheKey(endpoint, options = {}) {
|
|
const params = new URLSearchParams();
|
|
Object.keys(options).sort().forEach(key => {
|
|
if (options[key] !== undefined && options[key] !== null) {
|
|
params.append(key, String(options[key]));
|
|
}
|
|
});
|
|
const query = params.toString();
|
|
return `${endpoint}${query ? '?' + query : ''}`;
|
|
}
|
|
|
|
// 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');
|
|
|
|
// Debounced request wrapper
|
|
function debounceRequest(key, requestFn, delay = DEBOUNCE_DELAY) {
|
|
return new Promise((resolve, reject) => {
|
|
// Check if there's a pending request for this key
|
|
if (pendingRequests.has(key)) {
|
|
const pending = pendingRequests.get(key);
|
|
// If request is very recent (within dedupe window), reuse it
|
|
const now = Date.now();
|
|
if (pending.timestamp && (now - pending.timestamp) < DEDUPE_WINDOW) {
|
|
pending.promise.then(resolve).catch(reject);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Clear any existing timeout for this key
|
|
if (requestQueue.has(key)) {
|
|
const queued = requestQueue.get(key);
|
|
clearTimeout(queued.timeout);
|
|
// Resolve with sentinel value instead of rejecting - this prevents errors from propagating
|
|
// The new request will handle the actual response
|
|
queued.resolve(REQUEST_SUPERSEDED);
|
|
}
|
|
|
|
// Queue new request
|
|
const timeout = setTimeout(() => {
|
|
requestQueue.delete(key);
|
|
const promise = requestFn();
|
|
const timestamp = Date.now();
|
|
pendingRequests.set(key, { promise, timestamp });
|
|
|
|
promise
|
|
.then(result => {
|
|
pendingRequests.delete(key);
|
|
resolve(result);
|
|
})
|
|
.catch(error => {
|
|
pendingRequests.delete(key);
|
|
reject(error);
|
|
});
|
|
}, delay);
|
|
|
|
requestQueue.set(key, { resolve, reject, timeout });
|
|
});
|
|
}
|
|
|
|
export const setAuthErrorHandler = (handler) => {
|
|
onAuthError = handler;
|
|
};
|
|
|
|
const handleAuthError = (response) => {
|
|
if (response.status === 401 || response.status === 403) {
|
|
// Trigger auth error handler if set (this will clear user state)
|
|
if (onAuthError) {
|
|
onAuthError();
|
|
}
|
|
// Force a re-check of auth status to ensure login is shown
|
|
// This ensures the App component re-renders with user=null
|
|
if (typeof window !== 'undefined') {
|
|
// Dispatch a custom event that useAuth can listen to
|
|
window.dispatchEvent(new CustomEvent('auth-error'));
|
|
}
|
|
}
|
|
};
|
|
|
|
export const api = {
|
|
async get(endpoint) {
|
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
|
credentials: 'include', // Include cookies for session
|
|
});
|
|
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;
|
|
throw new Error(errorMessage);
|
|
}
|
|
return response.json();
|
|
},
|
|
|
|
async post(endpoint, data) {
|
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
credentials: 'include', // Include cookies for session
|
|
});
|
|
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;
|
|
throw new Error(errorMessage);
|
|
}
|
|
return response.json();
|
|
},
|
|
|
|
async delete(endpoint) {
|
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
|
method: 'DELETE',
|
|
credentials: 'include', // Include cookies for session
|
|
});
|
|
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;
|
|
throw new Error(errorMessage);
|
|
}
|
|
return response.json();
|
|
},
|
|
|
|
async uploadFile(endpoint, file, onProgress, mainBlendFile) {
|
|
return new Promise((resolve, reject) => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
if (mainBlendFile) {
|
|
formData.append('main_blend_file', mainBlendFile);
|
|
}
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
|
|
// Track upload progress
|
|
if (onProgress) {
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|
if (e.lengthComputable) {
|
|
const percentComplete = (e.loaded / e.total) * 100;
|
|
onProgress(percentComplete);
|
|
}
|
|
});
|
|
}
|
|
|
|
xhr.addEventListener('load', () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
try {
|
|
const response = JSON.parse(xhr.responseText);
|
|
resolve(response);
|
|
} catch (err) {
|
|
resolve(xhr.responseText);
|
|
}
|
|
} 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
|
|
}
|
|
try {
|
|
const errorData = JSON.parse(xhr.responseText);
|
|
reject(new Error(errorData.error || xhr.statusText));
|
|
} catch {
|
|
reject(new Error(xhr.statusText));
|
|
}
|
|
}
|
|
});
|
|
|
|
xhr.addEventListener('error', () => {
|
|
reject(new Error('Upload failed'));
|
|
});
|
|
|
|
xhr.addEventListener('abort', () => {
|
|
reject(new Error('Upload aborted'));
|
|
});
|
|
|
|
xhr.open('POST', `${API_BASE}${endpoint}`);
|
|
xhr.withCredentials = true; // Include cookies for session
|
|
xhr.send(formData);
|
|
});
|
|
},
|
|
};
|
|
|
|
export const auth = {
|
|
async getMe() {
|
|
return api.get('/auth/me');
|
|
},
|
|
|
|
async logout() {
|
|
return api.post('/auth/logout');
|
|
},
|
|
|
|
async getProviders() {
|
|
return api.get('/auth/providers');
|
|
},
|
|
|
|
async isLocalLoginAvailable() {
|
|
return api.get('/auth/local/available');
|
|
},
|
|
|
|
async localRegister(email, name, password) {
|
|
return api.post('/auth/local/register', { email, name, password });
|
|
},
|
|
|
|
async localLogin(username, password) {
|
|
return api.post('/auth/local/login', { username, password });
|
|
},
|
|
|
|
async changePassword(oldPassword, newPassword, targetUserId = null) {
|
|
const body = { old_password: oldPassword, new_password: newPassword };
|
|
if (targetUserId !== null) {
|
|
body.target_user_id = targetUserId;
|
|
}
|
|
return api.post('/auth/change-password', body);
|
|
},
|
|
};
|
|
|
|
export const jobs = {
|
|
async list(options = {}) {
|
|
const key = getCacheKey('/jobs', options);
|
|
return debounceRequest(key, () => {
|
|
const params = new URLSearchParams();
|
|
if (options.limit) params.append('limit', options.limit.toString());
|
|
if (options.offset) params.append('offset', options.offset.toString());
|
|
if (options.status) params.append('status', options.status);
|
|
if (options.sort) params.append('sort', options.sort);
|
|
const query = params.toString();
|
|
return api.get(`/jobs${query ? '?' + query : ''}`);
|
|
});
|
|
},
|
|
|
|
async listSummary(options = {}) {
|
|
const key = getCacheKey('/jobs/summary', options);
|
|
return debounceRequest(key, () => {
|
|
const params = new URLSearchParams();
|
|
if (options.limit) params.append('limit', options.limit.toString());
|
|
if (options.offset) params.append('offset', options.offset.toString());
|
|
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 : ''}`);
|
|
});
|
|
},
|
|
|
|
async get(id, options = {}) {
|
|
const key = getCacheKey(`/jobs/${id}`, options);
|
|
return debounceRequest(key, async () => {
|
|
if (options.etag) {
|
|
// Include ETag in request headers for conditional requests
|
|
const headers = { 'If-None-Match': options.etag };
|
|
const response = await fetch(`${API_BASE}/jobs/${id}`, {
|
|
credentials: 'include',
|
|
headers,
|
|
});
|
|
if (response.status === 304) {
|
|
return null; // Not modified
|
|
}
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => null);
|
|
throw new Error(errorData?.error || response.statusText);
|
|
}
|
|
return response.json();
|
|
}
|
|
return api.get(`/jobs/${id}`);
|
|
});
|
|
},
|
|
|
|
async create(jobData) {
|
|
return api.post('/jobs', jobData);
|
|
},
|
|
|
|
async cancel(id) {
|
|
return api.delete(`/jobs/${id}`);
|
|
},
|
|
|
|
async delete(id) {
|
|
return api.post(`/jobs/${id}/delete`);
|
|
},
|
|
|
|
async uploadFile(jobId, file, onProgress, mainBlendFile) {
|
|
return api.uploadFile(`/jobs/${jobId}/upload`, file, onProgress, mainBlendFile);
|
|
},
|
|
|
|
async uploadFileForJobCreation(file, onProgress, mainBlendFile) {
|
|
return api.uploadFile(`/jobs/upload`, file, onProgress, mainBlendFile);
|
|
},
|
|
|
|
async getFiles(jobId, options = {}) {
|
|
const key = getCacheKey(`/jobs/${jobId}/files`, options);
|
|
return debounceRequest(key, () => {
|
|
const params = new URLSearchParams();
|
|
if (options.limit) params.append('limit', options.limit.toString());
|
|
if (options.offset) params.append('offset', options.offset.toString());
|
|
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 : ''}`);
|
|
});
|
|
},
|
|
|
|
async getFilesCount(jobId, options = {}) {
|
|
const key = getCacheKey(`/jobs/${jobId}/files/count`, options);
|
|
return debounceRequest(key, () => {
|
|
const params = new URLSearchParams();
|
|
if (options.file_type) params.append('file_type', options.file_type);
|
|
const query = params.toString();
|
|
return api.get(`/jobs/${jobId}/files/count${query ? '?' + query : ''}`);
|
|
});
|
|
},
|
|
|
|
async getContextArchive(jobId) {
|
|
return api.get(`/jobs/${jobId}/context`);
|
|
},
|
|
|
|
downloadFile(jobId, fileId) {
|
|
return `${API_BASE}/jobs/${jobId}/files/${fileId}/download`;
|
|
},
|
|
|
|
getVideoUrl(jobId) {
|
|
return `${API_BASE}/jobs/${jobId}/video`;
|
|
},
|
|
|
|
async getTaskLogs(jobId, taskId, options = {}) {
|
|
const key = getCacheKey(`/jobs/${jobId}/tasks/${taskId}/logs`, options);
|
|
return debounceRequest(key, async () => {
|
|
const params = new URLSearchParams();
|
|
if (options.stepName) params.append('step_name', options.stepName);
|
|
if (options.logLevel) params.append('log_level', options.logLevel);
|
|
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 : ''}`);
|
|
// 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 };
|
|
}
|
|
return result;
|
|
});
|
|
},
|
|
|
|
async getTaskSteps(jobId, taskId) {
|
|
return api.get(`/jobs/${jobId}/tasks/${taskId}/steps`);
|
|
},
|
|
|
|
streamTaskLogsWebSocket(jobId, taskId, lastId = 0) {
|
|
// Convert HTTP to WebSocket URL
|
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsHost = window.location.host;
|
|
const url = `${wsProtocol}//${wsHost}${API_BASE}/jobs/${jobId}/tasks/${taskId}/logs/ws?last_id=${lastId}`;
|
|
return new WebSocket(url);
|
|
},
|
|
|
|
streamJobsWebSocket() {
|
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsHost = window.location.host;
|
|
const url = `${wsProtocol}//${wsHost}${API_BASE}/jobs/ws`;
|
|
return new WebSocket(url);
|
|
},
|
|
|
|
streamJobWebSocket(jobId) {
|
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsHost = window.location.host;
|
|
const url = `${wsProtocol}//${wsHost}${API_BASE}/jobs/${jobId}/ws`;
|
|
return new WebSocket(url);
|
|
},
|
|
|
|
async retryTask(jobId, taskId) {
|
|
return api.post(`/jobs/${jobId}/tasks/${taskId}/retry`);
|
|
},
|
|
|
|
async getMetadata(jobId) {
|
|
return api.get(`/jobs/${jobId}/metadata`);
|
|
},
|
|
|
|
async getTasks(jobId, options = {}) {
|
|
const key = getCacheKey(`/jobs/${jobId}/tasks`, options);
|
|
return debounceRequest(key, () => {
|
|
const params = new URLSearchParams();
|
|
if (options.limit) params.append('limit', options.limit.toString());
|
|
if (options.offset) params.append('offset', options.offset.toString());
|
|
if (options.status) params.append('status', options.status);
|
|
if (options.frameStart) params.append('frame_start', options.frameStart.toString());
|
|
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 : ''}`);
|
|
});
|
|
},
|
|
|
|
async getTasksSummary(jobId, options = {}) {
|
|
const key = getCacheKey(`/jobs/${jobId}/tasks/summary`, options);
|
|
return debounceRequest(key, () => {
|
|
const params = new URLSearchParams();
|
|
if (options.limit) params.append('limit', options.limit.toString());
|
|
if (options.offset) params.append('offset', options.offset.toString());
|
|
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 : ''}`);
|
|
});
|
|
},
|
|
|
|
async batchGetJobs(jobIds) {
|
|
// Sort jobIds for consistent cache key
|
|
const sortedIds = [...jobIds].sort((a, b) => a - b);
|
|
const key = getCacheKey('/jobs/batch', { job_ids: sortedIds.join(',') });
|
|
return debounceRequest(key, () => {
|
|
return api.post('/jobs/batch', { job_ids: jobIds });
|
|
});
|
|
},
|
|
|
|
async batchGetTasks(jobId, taskIds) {
|
|
// Sort taskIds for consistent cache key
|
|
const sortedIds = [...taskIds].sort((a, b) => a - b);
|
|
const key = getCacheKey(`/jobs/${jobId}/tasks/batch`, { task_ids: sortedIds.join(',') });
|
|
return debounceRequest(key, () => {
|
|
return api.post(`/jobs/${jobId}/tasks/batch`, { task_ids: taskIds });
|
|
});
|
|
},
|
|
};
|
|
|
|
export const runners = {
|
|
// Non-admin runner list removed - use admin.listRunners() instead
|
|
};
|
|
|
|
export const admin = {
|
|
async generateAPIKey(name, description, scope) {
|
|
const data = { name, scope };
|
|
if (description) data.description = description;
|
|
return api.post('/admin/runners/api-keys', data);
|
|
},
|
|
|
|
async listAPIKeys() {
|
|
return api.get('/admin/runners/api-keys');
|
|
},
|
|
|
|
async revokeAPIKey(keyId) {
|
|
return api.patch(`/admin/runners/api-keys/${keyId}/revoke`);
|
|
},
|
|
|
|
async deleteAPIKey(keyId) {
|
|
return api.delete(`/admin/runners/api-keys/${keyId}`);
|
|
},
|
|
|
|
async listRunners() {
|
|
return api.get('/admin/runners');
|
|
},
|
|
|
|
async verifyRunner(runnerId) {
|
|
return api.post(`/admin/runners/${runnerId}/verify`);
|
|
},
|
|
|
|
async deleteRunner(runnerId) {
|
|
return api.delete(`/admin/runners/${runnerId}`);
|
|
},
|
|
|
|
async listUsers() {
|
|
return api.get('/admin/users');
|
|
},
|
|
|
|
async getUserJobs(userId) {
|
|
return api.get(`/admin/users/${userId}/jobs`);
|
|
},
|
|
|
|
async setUserAdminStatus(userId, isAdmin) {
|
|
return api.post(`/admin/users/${userId}/admin`, { is_admin: isAdmin });
|
|
},
|
|
|
|
async getRegistrationEnabled() {
|
|
return api.get('/admin/settings/registration');
|
|
},
|
|
|
|
async setRegistrationEnabled(enabled) {
|
|
return api.post('/admin/settings/registration', { enabled });
|
|
},
|
|
};
|
|
|