// [DEF:api_module:Module] // @TIER: STANDARD // @SEMANTICS: api, client, fetch, rest // @PURPOSE: Handles all communication with the backend API. // @LAYER: Infra-API import { addToast } from './toasts.js'; import { PUBLIC_WS_URL } from '$env/static/public'; const API_BASE_URL = '/api'; // [DEF:buildApiError:Function] // @PURPOSE: Creates a normalized Error object for failed API responses. // @PRE: response is a failed fetch Response object. // @POST: Returned error contains message and status fields. async function buildApiError(response) { const errorData = await response.json().catch(() => ({})); const message = errorData.detail ? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) : `API request failed with status ${response.status}`; const error = new Error(message); error.status = response.status; return error; } // [/DEF:buildApiError:Function] // [DEF:notifyApiError:Function] // @PURPOSE: Shows toast for API errors with explicit handling of critical statuses. // @PRE: error is an Error instance. // @POST: User gets visible toast feedback for request failure. function notifyApiError(error) { if (error?.status === 401) { addToast(`401 Unauthorized: ${error.message}`, 'error'); return; } if (error?.status >= 500) { addToast(`Server error (${error.status}): ${error.message}`, 'error'); return; } addToast(error.message, 'error'); } // [/DEF:notifyApiError:Function] // [DEF:getWsUrl:Function] // @PURPOSE: Returns the WebSocket URL for a specific task, with fallback logic. // @PRE: taskId is provided. // @POST: Returns valid WebSocket URL string. // @PARAM: taskId (string) - The ID of the task. // @RETURN: string - The WebSocket URL. export const getWsUrl = (taskId) => { let baseUrl = PUBLIC_WS_URL; if (!baseUrl) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; // Use the current host and port to allow Vite proxy to handle the connection baseUrl = `${protocol}//${window.location.host}`; } return `${baseUrl}/ws/logs/${taskId}`; }; // [/DEF:getWsUrl:Function] // [DEF:getAuthHeaders:Function] // @PURPOSE: Returns headers with Authorization if token exists. function getAuthHeaders() { const headers = { 'Content-Type': 'application/json', }; if (typeof window !== 'undefined') { const token = localStorage.getItem('auth_token'); if (token) { headers['Authorization'] = `Bearer ${token}`; } } return headers; } // [/DEF:getAuthHeaders:Function] // [DEF:fetchApi:Function] // @PURPOSE: Generic GET request wrapper. // @PRE: endpoint string is provided. // @POST: Returns Promise resolving to JSON data or throws on error. // @PARAM: endpoint (string) - API endpoint. // @RETURN: Promise - JSON response. async function fetchApi(endpoint) { try { console.log(`[api.fetchApi][Action] Fetching from context={{'endpoint': '${endpoint}'}}`); const response = await fetch(`${API_BASE_URL}${endpoint}`, { headers: getAuthHeaders() }); console.log(`[api.fetchApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`); if (!response.ok) { throw await buildApiError(response); } if (response.status === 204) return null; return await response.json(); } catch (error) { console.error(`[api.fetchApi][Coherence:Failed] Error fetching from ${endpoint}:`, error); notifyApiError(error); throw error; } } // [/DEF:fetchApi:Function] // [DEF:fetchApiBlob:Function] // @PURPOSE: Generic GET wrapper for binary payloads. // @PRE: endpoint string is provided. // @POST: Returns Blob or throws on error. async function fetchApiBlob(endpoint, options = {}) { const notifyError = options.notifyError !== false; try { const response = await fetch(`${API_BASE_URL}${endpoint}`, { headers: getAuthHeaders() }); if (response.status === 202) { const payload = await response.json().catch(() => ({ message: "Resource is being prepared" })); const error = new Error(payload?.message || "Resource is being prepared"); error.status = 202; throw error; } if (!response.ok) { throw await buildApiError(response); } return await response.blob(); } catch (error) { console.error(`[api.fetchApiBlob][Coherence:Failed] Error fetching blob from ${endpoint}:`, error); if (notifyError) { notifyApiError(error); } throw error; } } // [/DEF:fetchApiBlob:Function] // [DEF:postApi:Function] // @PURPOSE: Generic POST request wrapper. // @PRE: endpoint and body are provided. // @POST: Returns Promise resolving to JSON data or throws on error. // @PARAM: endpoint (string) - API endpoint. // @PARAM: body (object) - Request payload. // @RETURN: Promise - JSON response. async function postApi(endpoint, body) { try { console.log(`[api.postApi][Action] Posting to context={{'endpoint': '${endpoint}'}}`); const response = await fetch(`${API_BASE_URL}${endpoint}`, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(body), }); console.log(`[api.postApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`); if (!response.ok) { throw await buildApiError(response); } if (response.status === 204) return null; return await response.json(); } catch (error) { console.error(`[api.postApi][Coherence:Failed] Error posting to ${endpoint}:`, error); notifyApiError(error); throw error; } } // [/DEF:postApi:Function] // [DEF:requestApi:Function] // @PURPOSE: Generic request wrapper. // @PRE: endpoint and method are provided. // @POST: Returns Promise resolving to JSON data or throws on error. async function requestApi(endpoint, method = 'GET', body = null) { try { console.log(`[api.requestApi][Action] ${method} to context={{'endpoint': '${endpoint}'}}`); const options = { method, headers: getAuthHeaders(), }; if (body) { options.body = JSON.stringify(body); } const response = await fetch(`${API_BASE_URL}${endpoint}`, options); console.log(`[api.requestApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`); if (!response.ok) { const error = await buildApiError(response); console.error(`[api.requestApi][Action] Request failed context={{'status': ${response.status}, 'message': '${error.message}'}}`); throw error; } if (response.status === 204) { console.log('[api.requestApi][Action] 204 No Content received'); return null; } return await response.json(); } catch (error) { console.error(`[api.requestApi][Coherence:Failed] Error ${method} to ${endpoint}:`, error); notifyApiError(error); throw error; } } // [/DEF:requestApi:Function] // [DEF:api:Data] // @PURPOSE: API client object with specific methods. export const api = { fetchApi, postApi, requestApi, getPlugins: () => fetchApi('/plugins'), getTasks: (options = {}) => { const params = new URLSearchParams(); if (options.limit != null) params.append('limit', String(options.limit)); if (options.offset != null) params.append('offset', String(options.offset)); if (options.status) params.append('status', options.status); if (options.task_type) params.append('task_type', options.task_type); if (options.completed_only != null) params.append('completed_only', String(Boolean(options.completed_only))); if (Array.isArray(options.plugin_id)) { options.plugin_id.forEach((pluginId) => params.append('plugin_id', pluginId)); } const query = params.toString(); return fetchApi(`/tasks${query ? `?${query}` : ''}`); }, getTask: (taskId) => fetchApi(`/tasks/${taskId}`), getTaskLogs: (taskId, options = {}) => { const params = new URLSearchParams(); if (options.level) params.append('level', options.level); if (options.source) params.append('source', options.source); if (options.search) params.append('search', options.search); if (options.offset != null) params.append('offset', String(options.offset)); if (options.limit != null) params.append('limit', String(options.limit)); const query = params.toString(); return fetchApi(`/tasks/${taskId}/logs${query ? `?${query}` : ''}`); }, createTask: (pluginId, params) => postApi('/tasks', { plugin_id: pluginId, params }), // Settings getSettings: () => fetchApi('/settings'), updateGlobalSettings: (settings) => requestApi('/settings/global', 'PATCH', settings), getEnvironments: () => fetchApi('/settings/environments'), addEnvironment: (env) => postApi('/settings/environments', env), updateEnvironment: (id, env) => requestApi(`/settings/environments/${id}`, 'PUT', env), deleteEnvironment: (id) => requestApi(`/settings/environments/${id}`, 'DELETE'), testEnvironmentConnection: (id) => postApi(`/settings/environments/${id}/test`, {}), updateEnvironmentSchedule: (id, schedule) => requestApi(`/environments/${id}/schedule`, 'PUT', schedule), getStorageSettings: () => fetchApi('/settings/storage'), updateStorageSettings: (storage) => requestApi('/settings/storage', 'PUT', storage), getEnvironmentsList: () => fetchApi('/environments'), getLlmStatus: () => fetchApi('/llm/status'), getEnvironmentDatabases: (id) => fetchApi(`/environments/${id}/databases`), getStorageFileBlob: (path) => fetchApiBlob(`/storage/file?path=${encodeURIComponent(path)}`), // Dashboards getDashboards: (envId, options = {}) => { const params = new URLSearchParams({ env_id: envId }); if (options.search) params.append('search', options.search); if (options.page) params.append('page', options.page); if (options.page_size) params.append('page_size', options.page_size); return fetchApi(`/dashboards?${params.toString()}`); }, getDashboardDetail: (envId, dashboardId) => fetchApi(`/dashboards/${dashboardId}?env_id=${envId}`), getDashboardTaskHistory: (envId, dashboardId, options = {}) => { const params = new URLSearchParams(); if (envId) params.append('env_id', envId); if (options.limit) params.append('limit', options.limit); return fetchApi(`/dashboards/${dashboardId}/tasks?${params.toString()}`); }, getDashboardThumbnail: (envId, dashboardId, options = {}) => { const params = new URLSearchParams(); params.append('env_id', envId); if (options.force != null) params.append('force', String(Boolean(options.force))); return fetchApiBlob(`/dashboards/${dashboardId}/thumbnail?${params.toString()}`, { notifyError: false }); }, getDatabaseMappings: (sourceEnvId, targetEnvId) => fetchApi(`/dashboards/db-mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`), // Datasets getDatasets: (envId, options = {}) => { const params = new URLSearchParams({ env_id: envId }); if (options.search) params.append('search', options.search); if (options.page) params.append('page', options.page); if (options.page_size) params.append('page_size', options.page_size); return fetchApi(`/datasets?${params.toString()}`); }, getDatasetIds: (envId, options = {}) => { const params = new URLSearchParams({ env_id: envId }); if (options.search) params.append('search', options.search); return fetchApi(`/datasets/ids?${params.toString()}`); }, getDatasetDetail: (envId, datasetId) => fetchApi(`/datasets/${datasetId}?env_id=${envId}`), // Settings getConsolidatedSettings: () => fetchApi('/settings/consolidated'), updateConsolidatedSettings: (settings) => requestApi('/settings/consolidated', 'PATCH', settings), }; // [/DEF:api:Data] // [/DEF:api_module:Module] // Export individual functions for easier use in components export { requestApi }; export const getPlugins = api.getPlugins; export const getTasks = api.getTasks; export const getTask = api.getTask; export const createTask = api.createTask; export const getSettings = api.getSettings; export const updateGlobalSettings = api.updateGlobalSettings; export const getEnvironments = api.getEnvironments; export const addEnvironment = api.addEnvironment; export const updateEnvironment = api.updateEnvironment; export const deleteEnvironment = api.deleteEnvironment; export const testEnvironmentConnection = api.testEnvironmentConnection; export const updateEnvironmentSchedule = api.updateEnvironmentSchedule; export const getEnvironmentsList = api.getEnvironmentsList; export const getStorageSettings = api.getStorageSettings; export const updateStorageSettings = api.updateStorageSettings; export const getDashboards = api.getDashboards; export const getDatasets = api.getDatasets; export const getConsolidatedSettings = api.getConsolidatedSettings; export const updateConsolidatedSettings = api.updateConsolidatedSettings;