// src/contexts/ApiServerContext.js import { createContext, useEffect, useState, useContext, useRef, useCallback } from 'react' import io from 'socket.io-client' import { message, Modal, Space, Button } from 'antd' import PropTypes from 'prop-types' import { AuthContext } from './AuthContext' import { useLocation, useNavigate } from 'react-router-dom' import axios from 'axios' import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon' import ReloadIcon from '../../Icons/ReloadIcon' import config from '../../../config' import loglevel from 'loglevel' import { getModelByName } from '../../../database/ObjectModels' const logger = loglevel.getLogger('ApiServerContext') logger.setLevel(config.logLevel) const SPOTLIGHT_CACHE_TTL_MS = 10_000 const spotlightCache = new Map() const runningSpotlightFetches = new Map() const createEmptyUserSettings = () => ({ viewMode: {}, filterSidebarVisibility: {}, columnVisibility: {}, collapseState: {} }) const normalizeUserSettingsCategory = (category) => category && typeof category === 'object' && !Array.isArray(category) ? category : {} const normalizeUserSettings = (settings = {}) => ({ viewMode: normalizeUserSettingsCategory(settings?.viewMode), filterSidebarVisibility: normalizeUserSettingsCategory( settings?.filterSidebarVisibility ), columnVisibility: normalizeUserSettingsCategory(settings?.columnVisibility), collapseState: normalizeUserSettingsCategory(settings?.collapseState) }) const emitWithAcknowledgement = (socket, eventName, data) => new Promise((resolve) => socket.emit(eventName, data, resolve)) const stableStringify = (value) => { if (Array.isArray(value)) { return `[${value.map(stableStringify).join(',')}]` } if (value && typeof value === 'object') { return `{${Object.keys(value) .sort() .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) .join(',')}}` } return JSON.stringify(value) } const getObjectTypeSubscriptionKey = (objectType, filter = {}) => `${objectType}:${stableStringify(filter || {})}` const getObjectTypeSubscriptionArgs = (filterOrCallback, callback) => { if (typeof filterOrCallback === 'function') { return { filter: {}, callback: filterOrCallback } } return { filter: filterOrCallback || {}, callback } } const getObjectEndpoint = (type) => getModelByName(type)?.endpoint || `${type.toLowerCase()}s` const ApiServerContext = createContext() const ApiServerProvider = ({ children }) => { const location = useLocation() const navigate = useNavigate() const { token, userProfile, setUserProfile, authenticated, setUnauthenticated } = useContext(AuthContext) const socketRef = useRef(null) const [connected, setConnected] = useState(false) const [connecting, setConnecting] = useState(false) const [error, setError] = useState(null) const [messageApi, contextHolder] = message.useMessage() const [fetchLoading, setFetchLoading] = useState(false) const [showErrorModal, setShowErrorModal] = useState(false) const [errorModalContent, setErrorModalContent] = useState('') const [retryCallback, setRetryCallback] = useState(null) const [userSettings, setUserSettings] = useState(createEmptyUserSettings) const [userSettingsLoaded, setUserSettingsLoaded] = useState(false) const subscribedCallbacksRef = useRef(new Map()) const subscribedActivityCallbacksRef = useRef(new Map()) const notificationListenersRef = useRef(new Set()) const completedLaunchSessionsRef = useRef(new Set()) const handleActivityUpdate = useCallback(async (data) => { logger.debug('Notifying activity update:', data) const objectId = data._id || data.id const objectType = data.objectType const activity = data.activity const activities = data.activities const callbacksRefKey = `${objectType}:${objectId}` if ( objectId && objectType && activity && subscribedActivityCallbacksRef.current.has(callbacksRefKey) ) { const callbacks = subscribedActivityCallbacksRef.current.get( callbacksRefKey ) logger.debug( `Calling ${callbacks.length} activity callbacks for object:`, objectId ) callbacks.forEach((callback) => { try { callback(activity, activities) } catch (error) { logger.error('Error in activity update callback:', error) } }) } }, []) const clearSubscriptions = useCallback(() => { subscribedCallbacksRef.current.clear() subscribedActivityCallbacksRef.current.clear() notificationListenersRef.current.clear() }, []) const registerNotificationListener = useCallback((callback) => { notificationListenersRef.current.add(callback) return () => notificationListenersRef.current.delete(callback) }, []) const unregisterNotificationListener = useCallback((callback) => { notificationListenersRef.current.delete(callback) }, []) const getUserSettings = useCallback(async (socket = socketRef.current) => { if (!socket?.connected) { return null } try { const result = await emitWithAcknowledgement( socket, 'getUserSettings', {} ) if (!result?.success) { throw new Error(result?.error || 'Unable to get user settings') } const settings = normalizeUserSettings(result.settings) setUserSettings(settings) return settings } catch (err) { logger.error('Failed to get user settings:', err) setUserSettings(createEmptyUserSettings()) return null } finally { setUserSettingsLoaded(true) } }, []) const updateUserSettings = useCallback( async (category, key, value) => { setUserSettings((currentSettings) => ({ ...currentSettings, [category]: { ...currentSettings[category], [key]: value } })) const socket = socketRef.current if (!socket?.connected) { return null } try { const result = await emitWithAcknowledgement( socket, 'updateUserSettings', { category, key, value } ) if (!result?.success) { throw new Error(result?.error || 'Unable to update user settings') } return result.settings } catch (err) { logger.error('Failed to update user settings:', err) messageApi.error('Failed to save user settings') await getUserSettings(socket) return null } }, [getUserSettings, messageApi] ) const connectToServer = useCallback(() => { if (token && authenticated == true) { logger.debug('Token is available, connecting to api server...') const newSocket = io(config.apiServerUrl, { reconnectionAttempts: 3, timeout: 3000, auth: { type: 'user' } }) setConnecting(true) newSocket.on('connect', () => { logger.debug('Api Server connected') newSocket.emit('authenticate', { token: token }, async (result) => { if (result?.valid !== true) { setConnecting(false) setError('Api Server authentication failed') newSocket.disconnect() return } setUserSettings(createEmptyUserSettings()) setUserSettingsLoaded(false) await getUserSettings(newSocket) if (!newSocket.connected) { return } setConnecting(false) setConnected(true) setError(null) }) }) newSocket.on('objectUpdate', handleObjectUpdate) newSocket.on('objectEvent', handleObjectEvent) newSocket.on('objectNew', handleObjectNew) newSocket.on('objectDelete', handleObjectDelete) newSocket.on('activityUpdate', handleActivityUpdate) newSocket.on('modelStats', handleModelStats) newSocket.on('notification', (data) => { let notification try { notification = typeof data === 'string' ? JSON.parse(data) : data } catch (err) { logger.error('Failed to parse notification:', err) return } if (!notification) return logger.debug('Notification received:', notification) notificationListenersRef.current.forEach((cb) => { try { cb(notification) } catch (err) { logger.error('Error in notification callback:', err) } }) }) newSocket.on('disconnect', () => { logger.debug('Api Server disconnected') setError('Api Server disconnected') clearSubscriptions() setConnected(false) setUserSettings(createEmptyUserSettings()) setUserSettingsLoaded(false) }) newSocket.on('connect_error', (err) => { logger.error('Api Server connection error:', err) messageApi.error('Api Server connection error: ' + err.message) setError('Api Server connection error') clearSubscriptions() setConnected(false) setUserSettings(createEmptyUserSettings()) setUserSettingsLoaded(false) }) newSocket.on('error', (err) => { logger.error('Api Server error:', err) setError('Api Server error') }) socketRef.current = newSocket } }, [ token, authenticated, messageApi, handleActivityUpdate, clearSubscriptions, getUserSettings ]) useEffect(() => { if (token && authenticated == true) { connectToServer() } else if (!token && socketRef.current) { logger.debug('Token not available, disconnecting api server...') socketRef.current.disconnect() socketRef.current = null setUserSettings(createEmptyUserSettings()) setUserSettingsLoaded(false) } // Clean up function return () => { if (socketRef.current) { logger.debug('Cleaning up api server connection...') socketRef.current.disconnect() socketRef.current = null } } }, [token, authenticated, connectToServer]) const setObjectActivity = (id, type, mode) => { logger.debug('Setting activity for', id, mode) if (socketRef.current && socketRef.current.connected) { return new Promise((resolve) => { socketRef.current.emit( 'setActivity', { _id: id, type, mode }, (result) => { logger.debug('Received setActivity result for object:', id, result) resolve(result || { success: true }) } ) }) } return Promise.resolve({ success: false, error: 'not_connected' }) } const clearObjectActivity = (id, type) => { logger.debug('Clearing activity for', id) if (socketRef.current && socketRef.current.connected == true) { socketRef.current.emit('clearActivity', { _id: id, type }) logger.debug('Sent clearActivity command for object:', id) } } const fetchObjectActivities = async (id, type) => { if (socketRef.current && socketRef.current.connected == true) { logger.debug('Fetching activities for ' + id) return new Promise((resolve) => { socketRef.current.emit( 'getActivities', { _id: id, type: type }, (result) => { logger.debug('Received activities for object:', id, result) resolve(result?.activities || []) } ) }) } return [] } const handleObjectUpdate = async (data) => { logger.debug('Notifying object update:', data) const id = data._id const objectType = data.objectType const callbacksRefKey = `${objectType}:${id}` if ( id && objectType && subscribedCallbacksRef.current.has(callbacksRefKey) ) { const callbacks = subscribedCallbacksRef.current.get(callbacksRefKey) logger.debug( `Calling ${callbacks.length} callbacks for object:`, callbacksRefKey ) callbacks.forEach((callback) => { try { callback(data.object) } catch (error) { logger.error('Error in object update callback:', error) } }) } else { logger.debug( `No callbacks found for object: ${callbacksRefKey}, subscribed callbacks:`, Array.from(subscribedCallbacksRef.current.keys()) ) } } const handleObjectEvent = async (data) => { const id = data._id const objectType = data.objectType const callbacksRefKey = `${objectType}:${id}:events:${data.event.type}` logger.debug('Notifying object event:', data) if ( id && objectType && subscribedCallbacksRef.current.has(callbacksRefKey) ) { const callbacks = subscribedCallbacksRef.current.get(callbacksRefKey) logger.debug( `Calling ${callbacks.length} callbacks for object:`, callbacksRefKey ) callbacks.forEach((callback) => { try { callback(data.event) } catch (error) { logger.error('Error in object event callback:', error) } }) } else { logger.debug( `No callbacks found for object: ${callbacksRefKey}, subscribed callbacks:`, Array.from(subscribedCallbacksRef.current.keys()) ) } } const handleModelStats = async (data) => { const objectType = data.objectType || 'unknown' const callbacksRefKey = `modelStats:${objectType}` logger.debug('Notifying model stats update:', data) if (objectType && subscribedCallbacksRef.current.has(callbacksRefKey)) { const callbacks = subscribedCallbacksRef.current.get(callbacksRefKey) logger.debug( `Calling ${callbacks.length} callbacks for model stats:`, callbacksRefKey ) callbacks.forEach((callback) => { try { callback(data.stats) } catch (error) { logger.error('Error in model stats callback:', error) } }) } else { logger.debug( `No callbacks found for model stats: ${callbacksRefKey}, subscribed callbacks:`, Array.from(subscribedCallbacksRef.current.keys()) ) } } const handleObjectNew = async (data) => { logger.debug('Notifying object new:', data) const objectType = data.objectType || 'unknown' const callbacksRefKey = getObjectTypeSubscriptionKey( objectType, data.filter || {} ) if (objectType && subscribedCallbacksRef.current.has(callbacksRefKey)) { const callbacks = subscribedCallbacksRef.current.get(callbacksRefKey) logger.debug( `Calling ${callbacks.length} callbacks for type:`, callbacksRefKey ) callbacks.forEach((callback) => { try { callback(data.object) } catch (error) { logger.error('Error in object new callback:', error) } }) } else { logger.debug( `No callbacks found for object: ${callbacksRefKey}, subscribed callbacks:`, Array.from(subscribedCallbacksRef.current.keys()) ) } } const handleObjectDelete = async (data) => { logger.debug('Notifying object delete:', data) const objectType = data.objectType || 'unknown' const callbacksRefKey = getObjectTypeSubscriptionKey( objectType, data.filter || {} ) if (objectType && subscribedCallbacksRef.current.has(callbacksRefKey)) { const callbacks = subscribedCallbacksRef.current.get(callbacksRefKey) logger.debug( `Calling ${callbacks.length} callbacks for type:`, callbacksRefKey ) callbacks.forEach((callback) => { try { callback(data.object) } catch (error) { logger.error('Error in object new callback:', error) } }) } else { logger.debug( `No callbacks found for object: ${callbacksRefKey}, subscribed callbacks:`, Array.from(subscribedCallbacksRef.current.keys()) ) } } const offObjectUpdatesEvent = useCallback((id, objectType, callback) => { if (socketRef.current && socketRef.current.connected == true) { const callbacksRefKey = `${objectType}:${id}` // Remove callback from the subscribed callbacks map if (subscribedCallbacksRef.current.has(callbacksRefKey)) { const callbacks = subscribedCallbacksRef.current .get(callbacksRefKey) .filter((cb) => cb !== callback) if (callbacks.length === 0) { logger.debug( 'No callbacks found for object:', callbacksRefKey, 'unsubscribing from object update...' ) subscribedCallbacksRef.current.delete(callbacksRefKey) socketRef.current.emit('unsubscribeObjectUpdate', { _id: id, objectType: objectType }) } else { subscribedCallbacksRef.current.set(callbacksRefKey, callbacks) } } } }, []) const offObjectTypeUpdatesEvent = useCallback( (objectType, filter, callback) => { if (socketRef.current && socketRef.current.connected == true) { const callbacksRefKey = getObjectTypeSubscriptionKey(objectType, filter) // Remove callback from the subscribed callbacks map if (subscribedCallbacksRef.current.has(callbacksRefKey)) { const callbacks = subscribedCallbacksRef.current .get(callbacksRefKey) .filter((cb) => cb !== callback) if (callbacks.length === 0) { subscribedCallbacksRef.current.delete(callbacksRefKey) socketRef.current.emit('unsubscribeObjectTypeUpdate', { objectType: objectType, filter: filter }) } else { subscribedCallbacksRef.current.set(callbacksRefKey, callbacks) } } } }, [] ) const subscribeToObjectUpdates = useCallback( (id, objectType, callback) => { if (socketRef.current && socketRef.current.connected == true) { const callbacksRefKey = `${objectType}:${id}` // Add callback to the subscribed callbacks map immediately if (!subscribedCallbacksRef.current.has(callbacksRefKey)) { subscribedCallbacksRef.current.set(callbacksRefKey, []) } const callbacksLength = subscribedCallbacksRef.current.get(callbacksRefKey).length if (callbacksLength <= 0) { socketRef.current.emit( 'subscribeToObjectUpdate', { _id: id, objectType: objectType }, (result) => { if (result.success) { logger.info('Subscribed to id:', id, 'objectType:', objectType) } } ) } logger.info( 'Adding callback id:', id, 'objectType:', objectType, 'callbacks length:', callbacksLength + 1 ) subscribedCallbacksRef.current.get(callbacksRefKey).push(callback) // Return cleanup function return () => offObjectUpdatesEvent(id, objectType, callback) } }, [offObjectUpdatesEvent] ) // Subscribe to user profile updates when WebSocket is connected and userProfile._id exists useEffect(() => { if (connected && userProfile?._id) { const unsubscribe = subscribeToObjectUpdates( userProfile._id, 'user', (updatedUser) => { logger.debug('Notifying user profile update:', updatedUser) setUserProfile((prev) => prev && updatedUser ? { ...prev, ...updatedUser } : prev ) } ) return () => { if (unsubscribe) unsubscribe() } } }, [connected, userProfile?._id, subscribeToObjectUpdates, setUserProfile]) const subscribeToObjectTypeUpdates = useCallback( (objectType, filterOrCallback = {}, maybeCallback) => { const { filter, callback } = getObjectTypeSubscriptionArgs( filterOrCallback, maybeCallback ) const callbacksRefKey = getObjectTypeSubscriptionKey(objectType, filter) logger.debug('Subscribing to type updates:', objectType, filter) if (socketRef.current && socketRef.current.connected == true) { // Add callback to the subscribed callbacks map immediately if (!subscribedCallbacksRef.current.has(callbacksRefKey)) { subscribedCallbacksRef.current.set(callbacksRefKey, []) } const callbacksLength = subscribedCallbacksRef.current.get(callbacksRefKey).length if (callbacksLength <= 0) { socketRef.current.emit( 'subscribeToObjectTypeUpdate', { objectType: objectType, filter: filter }, (result) => { if (result.success) { logger.info('Subscribed to objectType:', objectType, filter) } } ) } subscribedCallbacksRef.current.get(callbacksRefKey).push(callback) logger.debug( `Added callback for type ${callbacksRefKey}, total callbacks: ${callbacksLength + 1}` ) logger.debug( 'Registered type event listener for object:', callbacksRefKey ) // Return cleanup function return () => offObjectTypeUpdatesEvent(objectType, filter, callback) } }, [offObjectTypeUpdatesEvent] ) const offActivityEvent = useCallback((id, objectType, callback) => { if (socketRef.current && socketRef.current.connected == true) { const callbacksRefKey = `${objectType}:${id}` if (subscribedActivityCallbacksRef.current.has(callbacksRefKey)) { const callbacks = subscribedActivityCallbacksRef.current .get(callbacksRefKey) .filter((cb) => cb !== callback) if (callbacks.length === 0) { subscribedActivityCallbacksRef.current.delete(callbacksRefKey) socketRef.current.emit('unsubscribeObjectActivity', { _id: id, objectType }) } else { subscribedActivityCallbacksRef.current.set(callbacksRefKey, callbacks) } } logger.debug('Removed activity event listener for object:', id) } }, []) const offObjectEventEvent = useCallback( (id, objectType, eventType, callback) => { if (socketRef.current && socketRef.current.connected == true) { const callbacksRefKey = `${objectType}:${id}:events:${eventType}` // Remove callback from the subscribed callbacks map if (subscribedCallbacksRef.current.has(callbacksRefKey)) { const callbacks = subscribedCallbacksRef.current .get(callbacksRefKey) .filter((cb) => cb !== callback) if (callbacks.length === 0) { subscribedCallbacksRef.current.delete(callbacksRefKey) socketRef.current.emit('unsubscribeObjectEvent', { _id: id, objectType, eventType }) } else { subscribedCallbacksRef.current.set(callbacksRefKey, callbacks) } } } }, [] ) const subscribeToObjectEvent = useCallback( (id, objectType, eventType, callback) => { if (socketRef.current && socketRef.current.connected == true) { const callbacksRefKey = `${objectType}:${id}:events:${eventType}` // Add callback to the subscribed callbacks map immediately if (!subscribedCallbacksRef.current.has(callbacksRefKey)) { subscribedCallbacksRef.current.set(callbacksRefKey, []) } const callbacksLength = subscribedCallbacksRef.current.get(callbacksRefKey).length if (callbacksLength <= 0) { socketRef.current.emit( 'subscribeToObjectEvent', { _id: id, objectType: objectType, eventType: eventType }, (result) => { if (result.success) { logger.info( 'Subscribed to event id:', id, 'objectType:', objectType, 'eventType:', eventType ) } } ) } logger.info( 'Adding event callback id:', id, 'objectType:', objectType, 'eventType:', eventType, 'callbacks length:', callbacksLength + 1 ) subscribedCallbacksRef.current.get(callbacksRefKey).push(callback) // Return cleanup function return () => offObjectEventEvent(id, objectType, eventType, callback) } }, [offObjectEventEvent] ) const offModelStats = useCallback((objectType, callback) => { if (socketRef.current && socketRef.current.connected == true) { const callbacksRefKey = `modelStats:${objectType}` // Remove callback from the subscribed callbacks map if (subscribedCallbacksRef.current.has(callbacksRefKey)) { const callbacks = subscribedCallbacksRef.current .get(callbacksRefKey) .filter((cb) => cb !== callback) if (callbacks.length === 0) { subscribedCallbacksRef.current.delete(callbacksRefKey) socketRef.current.emit('unsubscribeModelStats', { objectType }) } else { subscribedCallbacksRef.current.set(callbacksRefKey, callbacks) } } } }, []) const subscribeToModelStats = useCallback( (objectType, callback) => { if (socketRef.current && socketRef.current.connected == true) { const callbacksRefKey = `modelStats:${objectType}` // Add callback to the subscribed callbacks map immediately if (!subscribedCallbacksRef.current.has(callbacksRefKey)) { subscribedCallbacksRef.current.set(callbacksRefKey, []) } const callbacksLength = subscribedCallbacksRef.current.get(callbacksRefKey).length if (callbacksLength <= 0) { socketRef.current.emit( 'subscribeToModelStats', { objectType: objectType }, (result) => { if (result.success) { logger.info('Subscribed to model stats:', objectType) } } ) } logger.info( 'Adding model stats callback:', objectType, 'callbacks length:', callbacksLength + 1 ) subscribedCallbacksRef.current.get(callbacksRefKey).push(callback) // Return cleanup function return () => offModelStats(objectType, callback) } }, [offModelStats] ) const subscribeToObjectActivity = useCallback( (id, type, callback) => { logger.debug('Subscribing to activity for object:', id, 'type:', type) if (socketRef.current && socketRef.current.connected == true) { const callbacksRefKey = `${type}:${id}` if (!subscribedActivityCallbacksRef.current.has(callbacksRefKey)) { subscribedActivityCallbacksRef.current.set(callbacksRefKey, []) } const callbacksLength = subscribedActivityCallbacksRef.current.get(callbacksRefKey).length subscribedActivityCallbacksRef.current .get(callbacksRefKey) .push(callback) if (callbacksLength <= 0) { socketRef.current.emit( 'subscribeToObjectActivity', { _id: id, objectType: type }, (result) => { if (result?.success && Array.isArray(result.activities)) { result.activities.forEach((activity) => { callback(activity) }) } } ) } logger.debug('Registered activity event listener for object:', id) return () => offActivityEvent(id, type, callback) } }, [offActivityEvent] ) const showError = useCallback( (error, callback = null) => { const code = error.response.data.code || 'UNKNOWN' if (code == 'UNAUTHORIZED') { setUnauthenticated() return } var content = `Error ${error.code} (${error.status}): ${error.message}` if (error.response?.data?.error) { content = `${error.response?.data?.error} (${error.status})` } setErrorModalContent(content) setRetryCallback(() => callback) setShowErrorModal(true) }, [setUnauthenticated] ) const handleRetry = () => { setShowErrorModal(false) setErrorModalContent('') if (retryCallback) { retryCallback() } setRetryCallback(null) } // Generalized fetchObject function const fetchObject = async (id, type) => { const fetchUrl = `${config.backendUrl}/${getObjectEndpoint(type)}/${id}` setFetchLoading(true) logger.debug('Fetching from ' + fetchUrl) try { const response = await axios.get(fetchUrl, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }) setFetchLoading(false) return response.data } catch (err) { console.error(err) showError(err, () => { fetchObject(id, type) }) return {} } } // Fetch table data with pagination, filtering, and sorting const fetchObjects = async (type, params = {}) => { const { page = 1, limit = 25, filter = {}, sorter = {}, onDataChange } = params let newFilter = { ...filter } if (filter != null && Object.keys(filter).length > 0) { const model = getModelByName(type) if (model.name !== 'unknown') { for (const key of Object.keys(filter)) { const property = model?.properties?.find((p) => p.name === key) if (property && property.type === 'object') { const value = filter[key] newFilter[`${key}._id`] = value?._id ?? value delete newFilter[key] } } } } logger.debug('Fetching table data from:', type, { page, limit, newFilter, sorter }) try { const response = await axios.get( `${config.backendUrl}/${getObjectEndpoint(type)}`, { params: { page, limit, ...newFilter, sort: sorter.field, order: sorter.order }, headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } } ) const newData = response.data const totalCount = parseInt(response.headers['x-total-count'] || '0', 10) const totalPages = Math.ceil(totalCount / limit) const hasMore = newData.length >= limit if (onDataChange) { onDataChange(newData) } return { data: newData, totalCount, totalPages, hasMore, page } } catch (err) { console.error(err) showError(err, () => { fetchObjects(type, params) }) return [] } } // Fetch table data with pagination, filtering, and sorting const fetchObjectsByProperty = async (type, params = {}) => { const { filter = {}, properties = [], masterFilter = {} } = params logger.debug('Fetching property object data from:', type, { properties, filter }) try { const response = await axios.get( `${config.backendUrl}/${getObjectEndpoint(type)}/properties`, { params: { ...Object.keys(filter).reduce((acc, key) => { acc[key] = Array.isArray(filter[key]) ? filter[key].join(',') : filter[key] return acc }, {}), properties: properties.join(','), // Convert array to comma-separated string masterFilter: JSON.stringify(masterFilter) }, headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } } ) const newData = response.data return newData } catch (err) { console.error(err) showError(err, () => { fetchObjects(type, params) }) return [] } } const searchObjects = async (type, search) => { const searchUrl = `${config.backendUrl}/${getObjectEndpoint(type)}/search` logger.debug('Searching objects for ' + type, { search }) try { const response = await axios.get(searchUrl, { params: { search }, headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }) return response.data } catch (err) { console.error(err) showError(err, () => { searchObjects(type, search) }) return [] } } // Update filament information const updateObject = async (id, type, value) => { const updateUrl = `${config.backendUrl}/${getObjectEndpoint(type)}/${id}` logger.debug('Updating info for ' + id) try { const response = await axios.put(updateUrl, value, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }) logger.debug('Object updated successfully') return response.data } catch (err) { console.error(err) setError(err, () => { updateObject(id, type, value) }) return {} } } // Update multiple objects const updateMultipleObjects = async (type, objects) => { const updateUrl = `${config.backendUrl}/${getObjectEndpoint(type)}` logger.debug('Updating multiple objects for ' + type) try { const response = await axios.put(updateUrl, objects, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }) logger.debug('Objects updated successfully') return response.data } catch (err) { console.error(err) showError(err, () => { updateMultipleObjects(type, objects) }) return [] } } // Update filament information const deleteObject = async (id, type) => { const deleteUrl = `${config.backendUrl}/${getObjectEndpoint(type)}/${id}` logger.debug('Deleting object ID: ' + id) try { const response = await axios.delete(deleteUrl, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }) logger.debug('Object deleted successfully') return response.data } catch (err) { console.error(err) showError(err, () => { deleteObject(id, type) }) return {} } } // Update filament information const createObject = async (type, value) => { const createUrl = `${config.backendUrl}/${getObjectEndpoint(type)}` logger.debug('Creating object...') try { const response = await axios.post(createUrl, value, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }) return response.data } catch (err) { console.error(err) showError(err, () => { createObject(type, value) }) return {} } } // Call a function on an object const sendObjectFunction = async (id, type, functionName, value = {}) => { const url = `${config.backendUrl}/${getObjectEndpoint(type)}/${id}/${functionName}` logger.debug(`Calling object function ${functionName} for ${id} at ${url}`) try { const response = await axios.post(url, value, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }) return response.data } catch (err) { console.error(err) showError(err, () => { sendObjectFunction(id, type, functionName, value) }) return {} } } const getObjectFunction = async (id, type, functionName, params = {}) => { const url = `${config.backendUrl}/${getObjectEndpoint(type)}/${id}/${functionName}` logger.debug(`Fetching object function ${functionName} for ${id} at ${url}`) try { const response = await axios.get(url, { params, headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }) return response.data } catch (err) { console.error(err) showError(err, () => { getObjectFunction(id, type, functionName, params) }) return {} } } const getMarketplaceAuthUrl = async (marketplaceId, state) => { return getObjectFunction(marketplaceId, 'Marketplace', 'auth/url', { state }) } const refreshMarketplaceAuth = async (marketplaceId) => { return sendObjectFunction(marketplaceId, 'Marketplace', 'auth/refresh') } // Export list data to CSV and download const exportToCsv = async (objectType) => { try { const response = await axios.get( `${config.backendUrl}/csv/${objectType}`, { responseType: 'blob', headers: { Accept: 'text/csv', Authorization: `Bearer ${token}` } } ) const blob = new Blob([response.data], { type: 'text/csv' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `${objectType}-export-${new Date().toISOString().slice(0, 10)}.csv` document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url) message.success('CSV file downloaded') } catch (err) { console.error(err) showError(err, () => exportToCsv(objectType)) } } // Export list data to Excel and download or open in Excel const exportToExcel = async (objectType, mode = 'download') => { try { if (mode === 'open') { // Open in Excel: get a temporary https URL (Excel fetches it when opening) const response = await axios.post( `${config.backendUrl}/excel/${objectType}/open`, {}, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } } ) const url = response.data?.url if (!url || typeof url !== 'string') { throw new Error('No URL received from server') } const excelUri = `ms-excel:ofe|u|${url}` window.location.href = excelUri message.success('Opening in Excel') } else if (mode === 'download') { const response = await axios.get( `${config.backendUrl}/excel/${objectType}`, { responseType: 'blob', headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', Authorization: `Bearer ${token}` } } ) const blob = new Blob([response.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `${objectType}-export-${new Date().toISOString().slice(0, 10)}.xlsx` document.body.appendChild(link) link.click() document.body.removeChild(link) message.success('Excel file downloaded') } else { throw new Error('Invalid mode') } } catch (err) { console.error(err) showError(err, () => exportToExcel(objectType)) } } // Download GCode file content const fetchFileContent = async (file, download = false) => { try { const response = await axios.get( `${config.backendUrl}/files/${file._id}/content`, { headers: { Accept: '*/*', Authorization: `Bearer ${token}` }, responseType: 'blob' } ) const blob = new Blob([response.data], { type: response.headers['content-type'] }) const fileURL = window.URL.createObjectURL(blob) if (download == true) { const fileLink = document.createElement('a') fileLink.href = fileURL fileLink.setAttribute('download', `${file.name}${file.extension}`) document.body.appendChild(fileLink) fileLink.click() fileLink.parentNode.removeChild(fileLink) return } return fileURL } catch (err) { console.error(err) showError(err, () => { fetchFileContent(file, download) }) } } // Fetch notes for a specific parent const fetchNotes = async (parentId) => { logger.debug('Fetching notes for parent:', parentId) try { const response = await axios.get(`${config.backendUrl}/notes`, { params: { 'parent._id': parentId, sort: 'createdAt', order: 'ascend' }, headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }) const notesData = response.data || [] logger.debug('Fetched notes:', notesData.length) return notesData } catch (err) { console.error(err) showError(err, () => { fetchNotes(parentId) }) } } const fetchSpotlightData = async (query) => { const cached = spotlightCache.get(query) if (cached && Date.now() - cached.timestamp < SPOTLIGHT_CACHE_TTL_MS) { logger.debug('Returning cached spotlight data for query:', query) return cached.data } const existing = runningSpotlightFetches.get(query) if (existing) { return existing } const fetchPromise = (async () => { try { logger.debug('Fetching spotlight data with query:', query) const response = await axios.get( `${config.backendUrl}/spotlight/${query}`, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } } ) spotlightCache.set(query, { data: response.data, timestamp: Date.now() }) return response.data } catch (err) { console.error(err) showError(err, () => { fetchSpotlightData(query) }) } finally { runningSpotlightFetches.delete(query) } })() runningSpotlightFetches.set(query, fetchPromise) return fetchPromise } const getModelStats = async (objectType) => { logger.debug('Fetching stats for model type:', objectType) try { let statsUrl if (objectType === 'history') { statsUrl = `${config.backendUrl}/stats/history` } else { statsUrl = `${config.backendUrl}/${getObjectEndpoint(objectType)}/stats` } const response = await axios.get(statsUrl, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }) logger.debug('Fetched stats for model type:', objectType) return response.data } catch (err) { console.error(err) showError(err, () => { getModelStats(objectType) }) return null } } const getModelPropertyValues = async (objectType, property) => { logger.debug('Fetching property values for model type:', objectType, property) try { const response = await axios.get( `${config.backendUrl}/${getObjectEndpoint(objectType)}/values`, { params: { property }, headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } } ) logger.debug('Fetched property values for model type:', objectType, property) return Array.isArray(response.data) ? response.data : [] } catch (err) { console.error(err) showError(err, () => { getModelPropertyValues(objectType, property) }) return [] } } const getModelHistory = async (objectType, startDate, endDate) => { logger.debug('Fetching history for model type:', objectType) const encodedStartDate = encodeURIComponent(startDate.toISOString()) const encodedEndDate = encodeURIComponent(endDate.toISOString()) try { const historyUrl = `${config.backendUrl}/${getObjectEndpoint(objectType)}/history?from=${encodedStartDate}&to=${encodedEndDate}` const response = await axios.get(historyUrl, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }) logger.debug('Fetched history for model type:', objectType) // Calculate time range in milliseconds const timeRangeMs = endDate.getTime() - startDate.getTime() const oneHourMs = 60 * 60 * 1000 const twelveHoursMs = 12 * 60 * 60 * 1000 const oneDayMs = 24 * 60 * 60 * 1000 const threeDaysMs = 3 * 24 * 60 * 60 * 1000 // Determine interval based on time range let intervalMinutes = 1 // Default: 1 minute if (timeRangeMs > threeDaysMs) { intervalMinutes = 60 // Over 1 day: 60 minutes } else if (timeRangeMs > oneDayMs) { intervalMinutes = 30 // Over 2 days: 30 minutes } else if (timeRangeMs > twelveHoursMs) { intervalMinutes = 10 // Over 12 hours: 10 minutes } else if (timeRangeMs > oneHourMs) { intervalMinutes = 5 // Over 1 hour: 5 minutes } const intervalMs = intervalMinutes * 60 * 1000 // Filter data to only include points at the specified intervals if (response.data && Array.isArray(response.data)) { const filteredData = [] const seenIntervals = new Set() response.data.forEach((point) => { const pointDate = new Date(point.date) // Round down to the nearest interval const roundedTime = Math.floor(pointDate.getTime() / intervalMs) * intervalMs const roundedDate = new Date(roundedTime) const intervalKey = roundedDate.toISOString() // Only include if we haven't seen this interval yet if (!seenIntervals.has(intervalKey)) { seenIntervals.add(intervalKey) // Update the point's date to the rounded interval filteredData.push({ ...point, date: roundedDate.toISOString() }) } }) // Sort by date to ensure chronological order filteredData.sort((a, b) => new Date(a.date) - new Date(b.date)) logger.debug( `Filtered history data: ${response.data.length} -> ${filteredData.length} points (${intervalMinutes} min intervals)` ) return filteredData } return response.data } catch (err) { console.error(err) showError(err, () => { getModelHistory(objectType) }) return null } } const fetchTemplatePreview = async ( id, content, testObject, scale, callback ) => { logger.debug('Fetching preview...') if (socketRef.current && socketRef.current.connected) { return socketRef.current.emit( 'previewTemplate', { _id: id, content: content, testObject: testObject, scale: scale }, callback ) } } const fetchTemplatePDF = async (id, content, testObject, callback) => { logger.debug('Fetching pdf template...') if (socketRef.current && socketRef.current.connected) { return socketRef.current.emit( 'renderTemplatePDF', { _id: id, content: content, object: testObject }, callback ) } } const downloadTemplatePDF = async ( id, content, object, filename, callback ) => { logger.debug('Downloading template PDF...') fetchTemplatePDF(id, content, object, (result) => { logger.debug('Downloading template PDF result:', result) if (result?.error) { console.error(result.error) if (callback) { callback(result.error) } } else { const pdfBlob = new Blob([result.pdf], { type: 'application/pdf' }) const pdfUrl = URL.createObjectURL(pdfBlob) const fileLink = document.createElement('a') fileLink.href = pdfUrl fileLink.setAttribute('download', `${filename}.pdf`) document.body.appendChild(fileLink) fileLink.click() fileLink.parentNode.removeChild(fileLink) if (callback) { callback() } } }) } const fetchHostOTP = async (id, callback) => { logger.debug('Fetching host OTP...') if (socketRef.current && socketRef.current.connected) { return socketRef.current.emit( 'generateHostOtp', { _id: id }, callback ) } } const sendObjectAction = async (id, objectType, action, callback) => { logger.debug('Sending object action...') if (socketRef.current && socketRef.current.connected) { return socketRef.current.emit( 'objectAction', { _id: id, objectType: objectType, action: action }, callback ) } } // Upload file to the API const uploadFile = async ( file, additionalData = {}, progressCallback = null ) => { const uploadUrl = `${config.backendUrl}/files` logger.debug('Uploading file:', file.name, 'to:', uploadUrl) try { const formData = new FormData() formData.append('file', file) // Add any additional data to the form Object.keys(additionalData).forEach((key) => { formData.append(key, additionalData[key]) }) const response = await axios.post(uploadUrl, formData, { headers: { 'Content-Type': 'multipart/form-data', Authorization: `Bearer ${token}` }, onUploadProgress: (progressEvent) => { const percentCompleted = Math.round( (progressEvent.loaded * 100) / progressEvent.total ) logger.debug(`Upload progress: ${percentCompleted}%`) if (progressCallback) { progressCallback(percentCompleted) } } }) logger.debug('File uploaded successfully:', response.data) return response.data } catch (err) { console.error('File upload error:', err) showError(err, () => { uploadFile(file, additionalData, progressCallback) }) return null } } const createUserNotifier = async (objectId, objectType) => { if (!userProfile?._id) return null return createObject('userNotifier', { user: userProfile._id, object: objectId, objectType }) } const deleteUserNotifier = async (userNotifierId) => { return deleteObject(userNotifierId, 'userNotifier') } const fetchUserNotifiersForObject = async (objectId, objectType) => { if (!userProfile?._id) return { data: [] } const result = await fetchObjects('userNotifier', { filter: { user: userProfile._id, object: objectId, objectType }, limit: 1 }) return result || { data: [] } } const fetchAllUserNotifiersForObject = async (objectId, objectType) => { const result = await fetchObjects('userNotifier', { filter: { object: objectId, objectType }, limit: 100 }) return result || { data: [] } } const toggleUserNotifier = async (objectId, objectType) => { const { data } = await fetchUserNotifiersForObject(objectId, objectType) const existing = data?.[0] if (existing) { await deleteUserNotifier(existing._id) return false } else { await createUserNotifier(objectId, objectType) return true } } const editUserNotifier = async (userNotifierId, updates) => { try { const result = await updateObject(userNotifierId, 'userNotifier', updates) return result } catch (err) { console.error(err) showError(err, () => editUserNotifier(userNotifierId, updates)) return null } } const fetchNotificationsApi = useCallback(async () => { const response = await axios.get(`${config.backendUrl}/notifications`, { params: { limit: 100, sort: 'createdAt', order: 'descend' }, headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }) return Array.isArray(response.data) ? response.data : [] }, [token]) const markNotificationAsReadApi = useCallback( async (notificationId) => { await axios.put( `${config.backendUrl}/notifications/${notificationId}/read`, {}, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } } ) }, [token] ) const markAllNotificationsAsReadApi = useCallback(async () => { await axios.put( `${config.backendUrl}/notifications/read-all`, {}, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } } ) }, [token]) const deleteNotificationApi = useCallback( async (notificationId) => { await axios.delete( `${config.backendUrl}/notifications/${notificationId}`, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } } ) }, [token] ) const deleteAllNotificationsApi = useCallback(async () => { await axios.delete(`${config.backendUrl}/notifications`, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }) }, [token]) const completeAppLaunchSession = useCallback( async (launchSession) => { const response = await axios.post( `${config.backendUrl}/applaunch/${launchSession}`, {}, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } } ) return response.data }, [token] ) useEffect(() => { const launchSession = new URLSearchParams(location.search).get( 'launchSession' ) if ( authenticated !== true || !token || !launchSession || completedLaunchSessionsRef.current.has(launchSession) ) { return } completedLaunchSessionsRef.current.add(launchSession) completeAppLaunchSession(launchSession) .then(() => { const searchParams = new URLSearchParams(location.search) searchParams.delete('launchSession') const newSearch = searchParams.toString() const newPath = location.pathname + (newSearch ? `?${newSearch}` : '') navigate(newPath, { replace: true }) }) .catch((err) => { logger.error('Failed to complete app launch session:', err) completedLaunchSessionsRef.current.delete(launchSession) }) }, [ token, authenticated, completeAppLaunchSession, location.search, location.pathname, navigate ]) const getAppLaunchSession = useCallback(async (launchSession) => { const response = await axios.get( `${config.backendUrl}/applaunch/${launchSession}`, { headers: { Accept: 'application/json' } } ) return response.data }, []) const fetchApiServerVersion = useCallback(async () => { try { const response = await axios.get(`${config.backendUrl}/server/version`, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }) return response.data } catch (err) { console.error(err) showError(err, () => { fetchApiServerVersion() }) return null } }, [token, showError]) const fetchWsServerVersion = useCallback(async () => { try { if (socketRef.current && socketRef.current.connected) { return await new Promise((resolve) => { socketRef.current.emit('getServerVersion', {}, resolve) }) } return null } catch (err) { console.error(err) showError(err, () => { fetchWsServerVersion() }) return null } }, [showError]) const fetchAppUpdateBranches = useCallback(async () => { try { const response = await axios.get( `${config.backendUrl}/appupdate/branches`, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } } ) return Array.isArray(response.data?.branches) ? response.data.branches : [] } catch (err) { console.error(err) showError(err, () => { fetchAppUpdateBranches() }) return [] } }, [token, showError]) const fetchAppUpdateCurrent = useCallback( async (branch) => { try { const response = await axios.get( `${config.backendUrl}/appupdate/current`, { params: { branch }, headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } } ) return response.data } catch (err) { console.error(err) showError(err, () => { fetchAppUpdateCurrent(branch) }) return null } }, [token, showError] ) const flushFile = async (id) => { logger.debug('Flushing file...') try { const response = await axios.delete( `${config.backendUrl}/files/${id}/flush`, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } } ) logger.debug('Flushed file:', response.data) return true } catch (err) { console.error(err) showError(err, () => { flushFile(id) }) } } // Sanitize a string so it is safe to use as a filename on most file systems const formatFileName = (name) => { if (!name || typeof name !== 'string') { return '' } // Remove characters that are problematic on most common file systems const cleaned = name.replace(/[^a-zA-Z0-9.\-_\s]/g, '') // Normalize whitespace to single underscores const normalized = cleaned.trim().replace(/\s+/g, '_') // Most file systems limit filenames to 255 characters return normalized.slice(0, 255) } return ( {contextHolder} {children} Error } open={showErrorModal} okText='OK' style={{ maxWidth: 430 }} closable={false} centered maskClosable={true} footer={[ , ]} > {errorModalContent} ) } ApiServerProvider.propTypes = { children: PropTypes.node.isRequired } export { ApiServerContext, ApiServerProvider }