// API Client for communicating with the backend class ApiClient { constructor() { // Auto-detect server URL based on current location const currentHost = window.location.hostname; const currentPort = window.location.port; // If accessing from localhost, use localhost:3001 // If accessing from another device, use the same hostname but port 3001 if (currentHost === 'localhost' || currentHost === '127.0.0.1') { this.baseUrl = 'http://localhost:3001'; } else { // Use the same hostname but port 3001 this.baseUrl = `http://${currentHost}:3001`; } logger.debug('API Client initialized with base URL:', this.baseUrl); } async request(path, { method = 'GET', headers = {}, body = undefined, query = undefined, isForm = false } = {}) { const url = new URL(`${this.baseUrl}${path}`); if (query && typeof query === 'object') { Object.entries(query).forEach(([k, v]) => { if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); }); } const finalHeaders = { 'Accept': 'application/json', ...headers }; const options = { method, headers: finalHeaders }; if (body !== undefined) { if (isForm) { options.body = body; } else { options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json'; options.body = typeof body === 'string' ? body : JSON.stringify(body); } } const response = await fetch(url.toString(), options); let data; const text = await response.text(); try { data = text ? JSON.parse(text) : null; } catch (_) { data = text; // Non-JSON payload } if (!response.ok) { const message = (data && data.message) || `HTTP ${response.status}: ${response.statusText}`; throw new Error(message); } return data; } async getClusterMembers() { return this.request('/api/cluster/members', { method: 'GET' }); } async getClusterMembersFromNode(ip) { return this.request(`/api/cluster/members`, { method: 'GET', query: { ip: ip } }); } async getDiscoveryInfo() { return this.request('/api/discovery/nodes', { method: 'GET' }); } async selectRandomPrimaryNode() { return this.request('/api/discovery/random-primary', { method: 'POST', body: { timestamp: new Date().toISOString() } }); } async getNodeStatus(ip) { return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' }); } async getTasksStatus(ip) { return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined }); } async getEndpoints(ip) { return this.request('/api/node/endpoints', { method: 'GET', query: ip ? { ip } : undefined }); } async callEndpoint({ ip, method, uri, params }) { return this.request('/api/proxy-call', { method: 'POST', body: { ip, method, uri, params } }); } async uploadFirmware(file, nodeIp) { const formData = new FormData(); formData.append('file', file); const data = await this.request(`/api/node/update`, { method: 'POST', query: { ip: nodeIp }, body: formData, isForm: true, headers: {}, }); // Some endpoints may return HTTP 200 with success=false on logical failure if (data && data.success === false) { const message = data.message || 'Firmware upload failed'; throw new Error(message); } return data; } async getMonitoringResources(ip) { return this.request('/api/proxy-call', { method: 'POST', body: { ip: ip, method: 'GET', uri: '/api/monitoring/resources', params: [] } }); } async getNodeLabels(ip) { return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' }); } async setNodeLabels(ip, labels) { return this.request('/api/proxy-call', { method: 'POST', body: { ip: ip, method: 'POST', uri: '/api/node/config', params: [{ name: 'labels', value: JSON.stringify(labels) }] } }); } // Registry API methods async getRegistryBaseUrl() { // Auto-detect registry server URL based on current location const currentHost = window.location.hostname; // If accessing from localhost, use localhost:8080 // If accessing from another device, use the same hostname but port 8080 if (currentHost === 'localhost' || currentHost === '127.0.0.1') { return 'http://localhost:8080'; } else { return `http://${currentHost}:8080`; } } async uploadFirmwareToRegistry(metadata, firmwareFile) { const registryBaseUrl = await this.getRegistryBaseUrl(); const formData = new FormData(); formData.append('metadata', JSON.stringify(metadata)); formData.append('firmware', firmwareFile); const response = await fetch(`${registryBaseUrl}/firmware`, { method: 'POST', body: formData }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Registry upload failed: ${errorText}`); } return await response.json(); } async updateFirmwareMetadata(name, version, metadata) { const registryBaseUrl = await this.getRegistryBaseUrl(); const response = await fetch(`${registryBaseUrl}/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(metadata) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Registry metadata update failed: ${errorText}`); } return await response.json(); } async listFirmwareFromRegistry(name = null, version = null) { const registryBaseUrl = await this.getRegistryBaseUrl(); const query = {}; if (name) query.name = name; if (version) query.version = version; const response = await fetch(`${registryBaseUrl}/firmware${Object.keys(query).length ? '?' + new URLSearchParams(query).toString() : ''}`); if (!response.ok) { const errorText = await response.text(); throw new Error(`Registry list failed: ${errorText}`); } return await response.json(); } async downloadFirmwareFromRegistry(name, version) { const registryBaseUrl = await this.getRegistryBaseUrl(); const response = await fetch(`${registryBaseUrl}/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`); if (!response.ok) { const errorText = await response.text(); throw new Error(`Registry download failed: ${errorText}`); } return response.blob(); } async getRegistryHealth() { const registryBaseUrl = await this.getRegistryBaseUrl(); const response = await fetch(`${registryBaseUrl}/health`); if (!response.ok) { const errorText = await response.text(); throw new Error(`Registry health check failed: ${errorText}`); } return await response.json(); } } // Global API client instance window.apiClient = new ApiClient(); // WebSocket Client for real-time updates class WebSocketClient { constructor() { this.ws = null; this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; this.reconnectDelay = 1000; // Start with 1 second this.listeners = new Map(); this.isConnected = false; // Auto-detect WebSocket URL based on current location const currentHost = window.location.hostname; const currentPort = window.location.port; // Use ws:// for HTTP and wss:// for HTTPS const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; if (currentHost === 'localhost' || currentHost === '127.0.0.1') { this.wsUrl = `${wsProtocol}//localhost:3001/ws`; } else { this.wsUrl = `${wsProtocol}//${currentHost}:3001/ws`; } logger.debug('WebSocket Client initialized with URL:', this.wsUrl); this.connect(); } connect() { try { this.ws = new WebSocket(this.wsUrl); this.setupEventListeners(); } catch (error) { logger.error('Failed to create WebSocket connection:', error); this.scheduleReconnect(); } } setupEventListeners() { this.ws.onopen = () => { logger.debug('WebSocket connected'); this.isConnected = true; this.reconnectAttempts = 0; this.reconnectDelay = 1000; // Notify listeners of connection this.emit('connected'); }; this.ws.onmessage = (event) => { try { const data = JSON.parse(event.data); logger.debug('WebSocket message received:', data); logger.debug('WebSocket message type:', data.type); this.emit('message', data); this.handleMessage(data); } catch (error) { logger.error('Failed to parse WebSocket message:', error); } }; this.ws.onclose = (event) => { logger.debug('WebSocket disconnected:', event.code, event.reason); this.isConnected = false; this.emit('disconnected'); if (event.code !== 1000) { // Not a normal closure this.scheduleReconnect(); } }; this.ws.onerror = (error) => { logger.error('WebSocket error:', error); this.emit('error', error); }; } handleMessage(data) { switch (data.type) { case 'cluster_update': this.emit('clusterUpdate', data); break; case 'node_discovery': this.emit('nodeDiscovery', data); break; case 'firmware_upload_status': this.emit('firmwareUploadStatus', data); break; default: logger.debug('Unknown WebSocket message type:', data.type); } } scheduleReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { logger.error('Max reconnection attempts reached'); this.emit('maxReconnectAttemptsReached'); return; } this.reconnectAttempts++; const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); // Exponential backoff logger.debug(`Scheduling WebSocket reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`); setTimeout(() => { this.connect(); }, delay); } on(event, callback) { if (!this.listeners.has(event)) { this.listeners.set(event, []); } this.listeners.get(event).push(callback); } off(event, callback) { if (this.listeners.has(event)) { const callbacks = this.listeners.get(event); const index = callbacks.indexOf(callback); if (index > -1) { callbacks.splice(index, 1); } } } emit(event, ...args) { if (this.listeners.has(event)) { this.listeners.get(event).forEach(callback => { try { callback(...args); } catch (error) { logger.error('Error in WebSocket event listener:', error); } }); } } send(data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); } else { logger.warn('WebSocket not connected, cannot send data'); } } disconnect() { if (this.ws) { this.ws.close(1000, 'Client disconnect'); } } getConnectionStatus() { return { connected: this.isConnected, reconnectAttempts: this.reconnectAttempts, maxReconnectAttempts: this.maxReconnectAttempts, url: this.wsUrl }; } } // Global WebSocket client instance window.wsClient = new WebSocketClient();