288 lines
9.0 KiB
JavaScript
288 lines
9.0 KiB
JavaScript
// 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: []
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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`;
|
|
} else {
|
|
this.wsUrl = `${wsProtocol}//${currentHost}:3001`;
|
|
}
|
|
|
|
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;
|
|
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();
|