diff --git a/index.js b/index.js index 5cfbe23..48b7f8e 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,7 @@ const fs = require('fs'); const dgram = require('dgram'); const SporeApiClient = require('./src/client'); const cors = require('cors'); +const WebSocket = require('ws'); const app = express(); const PORT = process.env.PORT || 3001; @@ -31,6 +32,7 @@ app.use(cors({ // UDP discovery configuration const UDP_PORT = 4210; const DISCOVERY_MESSAGE = 'CLUSTER_DISCOVERY'; +const STALE_THRESHOLD_SECONDS = 3; // 3 seconds for faster detection // Initialize UDP server for auto discovery const udpServer = dgram.createSocket('udp4'); @@ -55,34 +57,106 @@ udpServer.on('message', (msg, rinfo) => { const sourceIp = rinfo.address; const sourcePort = rinfo.port; - //console.log(`UDP message received from ${sourceIp}:${sourcePort}: "${message}"`); + // Only log non-discovery messages to reduce noise + if (message !== DISCOVERY_MESSAGE) { + console.log(`๐จ UDP message received from ${sourceIp}:${sourcePort}: "${message}"`); + } if (message === DISCOVERY_MESSAGE) { //console.log(`Received CLUSTER_DISCOVERY from ${sourceIp}:${sourcePort}`); - + // Store the discovered node + const now = Math.floor(Date.now() / 1000); const nodeInfo = { ip: sourceIp, port: sourcePort, - discoveredAt: new Date(), - lastSeen: new Date() + status: 'active', // New nodes from discovery are active + discoveredAt: now, + lastSeen: now }; - + + const isNewNode = !discoveredNodes.has(sourceIp); discoveredNodes.set(sourceIp, nodeInfo); - + // Set as primary node if this is the first one or if we don't have one if (!primaryNodeIp) { primaryNodeIp = sourceIp; console.log(`Set primary node to ${sourceIp}`); - + // Immediately try to initialize the client updateSporeClient(); } - + // Update last seen timestamp - discoveredNodes.get(sourceIp).lastSeen = new Date(); - - //console.log(`Node ${sourceIp} added/updated. Total discovered nodes: ${discoveredNodes.size}`); + discoveredNodes.get(sourceIp).lastSeen = Math.floor(Date.now() / 1000); + + // Broadcast discovery event if this is a new node + if (isNewNode) { + console.log(`๐ NEW NODE DISCOVERED: ${sourceIp}:${sourcePort} via CLUSTER_DISCOVERY. Total nodes: ${discoveredNodes.size}`); + broadcastNodeDiscovery(sourceIp, 'discovered'); + // Broadcast cluster update after a short delay to allow member data to be fetched + setTimeout(() => broadcastMemberListChange('new discovery'), 1000); + } + } else if (message.startsWith('CLUSTER_HEARTBEAT:')) { + // Handle heartbeat messages that also update member list + const hostname = message.substring('CLUSTER_HEARTBEAT:'.length); + // Update or create node entry from heartbeat + const existingNode = discoveredNodes.get(sourceIp); + const now = Math.floor(Date.now() / 1000); // Use Unix timestamp for consistency + + if (existingNode) { + // Update existing node + const wasStale = existingNode.status === 'inactive'; + const oldHostname = existingNode.hostname; + + existingNode.lastSeen = now; + existingNode.hostname = hostname; + existingNode.status = 'active'; // Mark as active when heartbeat received + + console.log(`๐ Heartbeat from ${sourceIp}:${sourcePort} (${hostname}). Total nodes: ${discoveredNodes.size}`); + + // Check if hostname changed + const hostnameChanged = oldHostname !== hostname; + if (hostnameChanged) { + console.log(`๐ Hostname updated for ${sourceIp}: "${oldHostname}" -> "${hostname}"`); + } + + // ALWAYS broadcast every heartbeat for immediate UI updates + // This ensures the UI gets real-time updates without delay + const reason = wasStale ? 'node became active' : + hostnameChanged ? 'hostname update' : + 'active heartbeat'; + + console.log(`๐ก Broadcasting heartbeat update: ${reason}`); + broadcastMemberListChange(reason); + } else { + // Create new node entry from heartbeat - NEW NODE DISCOVERED + const nodeInfo = { + ip: sourceIp, + port: sourcePort, + hostname: hostname, + status: 'active', // New nodes from heartbeat are active + discoveredAt: now, + lastSeen: now + }; + discoveredNodes.set(sourceIp, nodeInfo); + + console.log(`๐ NEW NODE DISCOVERED: ${sourceIp}:${sourcePort} (${hostname}) via heartbeat. Total nodes: ${discoveredNodes.size}`); + + // Set as primary node if this is the first one or if we don't have one + if (!primaryNodeIp) { + primaryNodeIp = sourceIp; + console.log(`Set primary node to ${sourceIp} (from heartbeat)`); + + // Immediately try to initialize the client + updateSporeClient(); + } + + // Broadcast discovery event for new node from heartbeat + broadcastNodeDiscovery(sourceIp, 'discovered'); + // Broadcast cluster update after a short delay to allow member data to be fetched + setTimeout(() => broadcastMemberListChange('new heartbeat discovery'), 1000); + } } else if (!message.startsWith('RAW:')) { console.log(`Received unknown message from ${sourceIp}:${sourcePort}: "${message}"`); } @@ -121,23 +195,46 @@ function initializeSporeClient(nodeIp) { } } -// Function to clean up stale discovered nodes (nodes not seen in the last 5 minutes) -function cleanupStaleNodes() { - const now = new Date(); - const staleThreshold = 5 * 60 * 1000; // 5 minutes in milliseconds - +// Function to mark stale nodes as inactive (instead of removing them) +function markStaleNodes() { + const now = Math.floor(Date.now() / 1000); + + let nodesMarkedStale = false; + for (const [ip, node] of discoveredNodes.entries()) { - if (now - node.lastSeen > staleThreshold) { - console.log(`Removing stale node: ${ip} (last seen: ${node.lastSeen.toISOString()})`); - discoveredNodes.delete(ip); - - // If this was our primary node, clear it + const timeSinceLastSeen = now - node.lastSeen; + + if (timeSinceLastSeen > STALE_THRESHOLD_SECONDS && node.status !== 'inactive') { + console.log(`๐ NODE MARKED INACTIVE: ${ip} (${node.hostname || 'Unknown'}) - last seen ${timeSinceLastSeen}s ago (threshold: ${STALE_THRESHOLD_SECONDS}s)`); + node.status = 'inactive'; + nodesMarkedStale = true; + + // Broadcast stale node event immediately + console.log(`๐ก Broadcasting stale node event for ${ip}`); + broadcastNodeDiscovery(ip, 'stale'); + + // If this was our primary node, clear it and select a new one if (primaryNodeIp === ip) { primaryNodeIp = null; - console.log('Primary node became stale, clearing primary node selection'); + console.log('๐ซ PRIMARY NODE BECAME STALE: Clearing primary node selection'); + + // Automatically select a new primary node from remaining healthy nodes + const newPrimary = selectBestPrimaryNode(); + if (newPrimary) { + console.log(`โ NEW PRIMARY NODE SELECTED: ${newPrimary} (auto-selected after stale cleanup)`); + // Update the SPORE client to use the new primary node + updateSporeClient(); + } else { + console.log('โ ๏ธ No healthy nodes available for primary selection'); + } } } } + + // Broadcast cluster update if any nodes were marked stale + if (nodesMarkedStale) { + broadcastMemberListChange('nodes marked stale'); + } } // Function to select the best primary node @@ -165,6 +262,7 @@ function selectBestPrimaryNode() { if (bestNode && bestNode !== primaryNodeIp) { primaryNodeIp = bestNode; console.log(`Selected new primary node: ${bestNode}`); + broadcastMemberListChange('primary node change'); } return bestNode; @@ -191,7 +289,8 @@ function selectRandomPrimaryNode() { // Update primary node primaryNodeIp = randomNode; console.log(`Randomly selected new primary node: ${randomNode}`); - + broadcastMemberListChange('random primary node selection'); + return randomNode; } @@ -234,6 +333,7 @@ async function performWithFailover(operation) { primaryNodeIp = ip; sporeClient = client; console.log(`Failover: switched primary node to ${ip}`); + broadcastMemberListChange('failover primary node switch'); } return result; } catch (err) { @@ -248,11 +348,11 @@ async function performWithFailover(operation) { // Set up periodic tasks setInterval(() => { - cleanupStaleNodes(); + markStaleNodes(); if (!sporeClient || !primaryNodeIp || !discoveredNodes.has(primaryNodeIp)) { updateSporeClient(); } -}, 5000); // Check every 5 seconds +}, 2000); // Check every 2 seconds for faster stale detection // Serve static files from public directory app.use(express.static(path.join(__dirname, 'public'))); @@ -266,8 +366,8 @@ app.get('/', (req, res) => { app.get('/api/discovery/nodes', (req, res) => { const nodes = Array.from(discoveredNodes.values()).map(node => ({ ...node, - discoveredAt: node.discoveredAt.toISOString(), - lastSeen: node.lastSeen.toISOString(), + discoveredAt: new Date(node.discoveredAt * 1000).toISOString(), + lastSeen: new Date(node.lastSeen * 1000).toISOString(), isPrimary: node.ip === primaryNodeIp })); @@ -288,12 +388,15 @@ app.get('/api/discovery/nodes', (req, res) => { // API endpoint to manually trigger discovery refresh app.post('/api/discovery/refresh', (req, res) => { try { - // Clean up stale nodes - cleanupStaleNodes(); - + // Mark stale nodes as inactive + markStaleNodes(); + // Try to update the client updateSporeClient(); - + + // Broadcast cluster update via WebSocket + broadcastMemberListChange('manual refresh'); + res.json({ success: true, message: 'Discovery refresh completed', @@ -310,6 +413,27 @@ app.post('/api/discovery/refresh', (req, res) => { } }); +// API endpoint to test WebSocket broadcasting +app.post('/api/test/websocket', (req, res) => { + try { + console.log('๐งช Manual WebSocket test triggered'); + broadcastMemberListChange('manual test'); + + res.json({ + success: true, + message: 'WebSocket test broadcast sent', + websocketClients: wsClients.size, + totalNodes: discoveredNodes.size + }); + } catch (error) { + console.error('Error during WebSocket test:', error); + res.status(500).json({ + error: 'WebSocket test failed', + message: error.message + }); + } +}); + // API endpoint to randomly select a new primary node app.post('/api/discovery/random-primary', (req, res) => { try { @@ -367,6 +491,7 @@ app.post('/api/discovery/primary/:ip', (req, res) => { primaryNodeIp = requestedIp; updateSporeClient(); + broadcastMemberListChange('manual primary node setting'); res.json({ success: true, @@ -721,11 +846,216 @@ app.get('/api/health', (req, res) => { +// WebSocket server setup - will be initialized after HTTP server +let wss = null; +const wsClients = new Set(); + +// Function to broadcast cluster updates to all connected WebSocket clients +function broadcastClusterUpdate() { + if (wsClients.size === 0 || !wss) return; + + const startTime = Date.now(); + console.log(`๐ก [${new Date().toISOString()}] Starting cluster update broadcast to ${wsClients.size} clients`); + + // Get cluster members asynchronously + getCurrentClusterMembers().then(members => { + const clusterData = { + type: 'cluster_update', + members: members, + primaryNode: primaryNodeIp, + totalNodes: discoveredNodes.size, + timestamp: new Date().toISOString() + }; + + const message = JSON.stringify(clusterData); + const broadcastTime = Date.now() - startTime; + console.log(`๐ก [${new Date().toISOString()}] Broadcasting cluster update to ${wsClients.size} WebSocket clients (took ${broadcastTime}ms)`); + console.log(`๐ Cluster data: ${members.length} members, primary: ${primaryNodeIp || 'none'}`); + + wsClients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + }); + }).catch(error => { + console.error('Error broadcasting cluster update:', error); + }); +} + +// Function to broadcast node discovery events +function broadcastNodeDiscovery(nodeIp, action) { + if (wsClients.size === 0 || !wss) return; + + const eventData = { + type: 'node_discovery', + action: action, // 'discovered' or 'stale' + nodeIp: nodeIp, + timestamp: new Date().toISOString() + }; + + const message = JSON.stringify(eventData); + wsClients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + }); +} + +// Helper function to broadcast member list changes +function broadcastMemberListChange(reason = 'update') { + const timestamp = new Date().toISOString(); + console.log(`๐ [${timestamp}] Member list changed (${reason}), broadcasting update`); + broadcastClusterUpdate(); +} + +// Helper function to get current cluster members (async version) +async function getCurrentClusterMembers() { + try { + if (discoveredNodes.size === 0) { + return []; + } + + // Fetch real cluster data from SPORE nodes for accurate information + console.log(`๐ก Fetching real cluster data from ${discoveredNodes.size} nodes for WebSocket broadcast`); + const clusterResponse = await performWithFailover((client) => client.getClusterStatus()); + const apiMembers = clusterResponse.members || []; + + // Update our local discoveredNodes with fresh information from the API + let updatedNodes = false; + apiMembers.forEach(apiMember => { + const localNode = discoveredNodes.get(apiMember.ip); + if (localNode) { + // Update local node with fresh API data + const needsUpdate = + localNode.hostname !== apiMember.hostname || + localNode.status !== apiMember.status || + localNode.latency !== apiMember.latency || + JSON.stringify(localNode.labels) !== JSON.stringify(apiMember.labels); + + if (needsUpdate) { + console.log(`๐ Updating local node ${apiMember.ip} with fresh API data`); + localNode.hostname = apiMember.hostname; + localNode.status = apiMember.status; + localNode.latency = apiMember.latency; + localNode.labels = apiMember.labels || {}; + localNode.lastSeen = Math.floor(Date.now() / 1000); + updatedNodes = true; + } + } else { + // New node discovered via API - shouldn't happen but handle it + console.log(`๐ New node discovered via API: ${apiMember.ip}`); + discoveredNodes.set(apiMember.ip, { + ip: apiMember.ip, + hostname: apiMember.hostname, + status: apiMember.status, + latency: apiMember.latency, + labels: apiMember.labels || {}, + discoveredAt: new Date(), + lastSeen: Math.floor(Date.now() / 1000) + }); + updatedNodes = true; + } + }); + + // If we updated any nodes, broadcast the changes + if (updatedNodes) { + console.log(`๐ก Local node data updated, triggering immediate broadcast`); + // Note: We don't call broadcastMemberListChange here because we're already in the middle of a broadcast + // The calling function will handle the broadcast + } + + // Enhance API data with our local status information + const enhancedMembers = apiMembers.map(apiMember => { + const localNode = discoveredNodes.get(apiMember.ip); + if (localNode) { + // Use our local status (which may be 'inactive' if the node became stale) + return { + ...apiMember, + status: localNode.status || apiMember.status, + hostname: localNode.hostname || apiMember.hostname, + lastSeen: localNode.lastSeen || apiMember.lastSeen, + labels: localNode.labels || apiMember.labels || {}, + resources: localNode.resources || apiMember.resources || {} + }; + } + return apiMember; + }); + + console.log(`๐ Returning ${enhancedMembers.length} enhanced cluster members via WebSocket`); + return enhancedMembers; + } catch (error) { + console.error('Error getting cluster members for WebSocket:', error); + + // Fallback to local data if API fails + console.log('โ ๏ธ API failed, falling back to local discoveredNodes data'); + const fallbackMembers = Array.from(discoveredNodes.values()).map(node => ({ + ip: node.ip, + hostname: node.hostname || 'Unknown Device', + status: node.status || 'active', // Use stored status (may be 'inactive') + latency: node.latency || 0, + lastSeen: node.lastSeen || Math.floor(Date.now() / 1000), + labels: node.labels || {}, + resources: node.resources || {} + })); + + console.log(`๐ Fallback: Returning ${fallbackMembers.length} local cluster members`); + return fallbackMembers; + } +} + +// Initialize WebSocket server after HTTP server is created +function initializeWebSocketServer(httpServer) { + wss = new WebSocket.Server({ server: httpServer }); + + // WebSocket connection handler + wss.on('connection', (ws) => { + console.log('WebSocket client connected'); + wsClients.add(ws); + + // Send current cluster state to newly connected client + if (discoveredNodes.size > 0) { + // Get cluster members asynchronously without blocking + getCurrentClusterMembers().then(members => { + const clusterData = { + type: 'cluster_update', + members: members, + primaryNode: primaryNodeIp, + totalNodes: discoveredNodes.size, + timestamp: new Date().toISOString() + }; + console.log(`๐ Sending initial cluster state to new WebSocket client: ${members.length} members`); + ws.send(JSON.stringify(clusterData)); + }).catch(error => { + console.error('Error sending initial cluster state:', error); + }); + } + + // Handle client disconnection + ws.on('close', () => { + console.log('WebSocket client disconnected'); + wsClients.delete(ws); + }); + + // Handle WebSocket errors + ws.on('error', (error) => { + console.error('WebSocket error:', error); + wsClients.delete(ws); + }); + }); + + console.log('WebSocket server initialized'); +} + // Start the server const server = app.listen(PORT, '0.0.0.0', () => { console.log(`Server is running on http://0.0.0.0:${PORT}`); console.log(`Accessible from: http://YOUR_COMPUTER_IP:${PORT}`); console.log(`UDP discovery server listening on port ${UDP_PORT}`); + + // Initialize WebSocket server after HTTP server is running + initializeWebSocketServer(server); + console.log('WebSocket server ready for real-time updates'); + console.log('Waiting for CLUSTER_DISCOVERY messages from SPORE nodes...'); }); diff --git a/package-lock.json b/package-lock.json index d1bbfb7..98f58cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "cors": "^2.8.5", "express": "^5.1.0", - "express-fileupload": "^1.4.3" + "express-fileupload": "^1.4.3", + "ws": "^8.18.3" } }, "node_modules/accepts": { @@ -875,6 +876,27 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 11cc955..4a93514 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dependencies": { "cors": "^2.8.5", "express": "^5.1.0", - "express-fileupload": "^1.4.3" + "express-fileupload": "^1.4.3", + "ws": "^8.18.3" } } diff --git a/public/scripts/api-client.js b/public/scripts/api-client.js index d75b252..ed50af1 100644 --- a/public/scripts/api-client.js +++ b/public/scripts/api-client.js @@ -123,4 +123,166 @@ class ApiClient { } // Global API client instance -window.apiClient = new ApiClient(); \ No newline at end of file +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(); \ No newline at end of file diff --git a/public/scripts/components/ClusterMembersComponent.js b/public/scripts/components/ClusterMembersComponent.js index 8a0dd78..deee5e6 100644 --- a/public/scripts/components/ClusterMembersComponent.js +++ b/public/scripts/components/ClusterMembersComponent.js @@ -301,11 +301,20 @@ class ClusterMembersComponent extends Component { // Update status const statusElement = card.querySelector('.member-status'); if (statusElement) { - const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' : 'status-offline'; - const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? window.icon('dotGreen', { width: 12, height: 12 }) : window.icon('dotRed', { width: 12, height: 12 }); - - statusElement.className = `member-status ${statusClass}`; - statusElement.innerHTML = `${statusIcon}`; + let statusClass, statusIcon; + if (member.status && member.status.toUpperCase() === 'ACTIVE') { + statusClass = 'status-online'; + statusIcon = window.icon('dotGreen', { width: 12, height: 12 }); + } else if (member.status && member.status.toUpperCase() === 'INACTIVE') { + statusClass = 'status-dead'; + statusIcon = window.icon('dotRed', { width: 12, height: 12 }); + } else { + statusClass = 'status-offline'; + statusIcon = window.icon('dotRed', { width: 12, height: 12 }); + } + + statusElement.className = `member-status ${statusClass}`; + statusElement.innerHTML = `${statusIcon}`; } // Update latency @@ -405,8 +414,17 @@ class ClusterMembersComponent extends Component { logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members'); const membersHTML = members.map(member => { - const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' : 'status-offline'; - const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? window.icon('dotGreen', { width: 12, height: 12 }) : window.icon('dotRed', { width: 12, height: 12 }); + let statusClass, statusIcon; + if (member.status && member.status.toUpperCase() === 'ACTIVE') { + statusClass = 'status-online'; + statusIcon = window.icon('dotGreen', { width: 12, height: 12 }); + } else if (member.status && member.status.toUpperCase() === 'INACTIVE') { + statusClass = 'status-dead'; + statusIcon = window.icon('dotRed', { width: 12, height: 12 }); + } else { + statusClass = 'status-offline'; + statusIcon = window.icon('dotRed', { width: 12, height: 12 }); + } logger.debug('ClusterMembersComponent: Rendering member:', member); diff --git a/public/scripts/components/ClusterStatusComponent.js b/public/scripts/components/ClusterStatusComponent.js index 8ad683e..6f45197 100644 --- a/public/scripts/components/ClusterStatusComponent.js +++ b/public/scripts/components/ClusterStatusComponent.js @@ -2,6 +2,8 @@ class ClusterStatusComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); + this.wsConnected = false; + this.wsReconnectAttempts = 0; } setupViewModelListeners() { @@ -9,6 +11,37 @@ class ClusterStatusComponent extends Component { this.subscribeToProperty('totalNodes', this.render.bind(this)); this.subscribeToProperty('clientInitialized', this.render.bind(this)); this.subscribeToProperty('error', this.render.bind(this)); + + // Set up WebSocket status listeners + this.setupWebSocketListeners(); + } + + setupWebSocketListeners() { + if (!window.wsClient) return; + + window.wsClient.on('connected', () => { + this.wsConnected = true; + this.wsReconnectAttempts = 0; + this.render(); + }); + + window.wsClient.on('disconnected', () => { + this.wsConnected = false; + this.render(); + }); + + window.wsClient.on('maxReconnectAttemptsReached', () => { + this.wsConnected = false; + this.wsReconnectAttempts = window.wsClient ? window.wsClient.reconnectAttempts : 0; + this.render(); + }); + + // Initialize current WebSocket status + if (window.wsClient) { + const status = window.wsClient.getConnectionStatus(); + this.wsConnected = status.connected; + this.wsReconnectAttempts = status.reconnectAttempts; + } } render() { @@ -17,6 +50,20 @@ class ClusterStatusComponent extends Component { const error = this.viewModel.get('error'); let statusText, statusIcon, statusClass; + let wsStatusText = ''; + let wsStatusIcon = ''; + + // Determine WebSocket status + if (this.wsConnected) { + wsStatusIcon = window.icon('dotGreen', { width: 10, height: 10 }); + wsStatusText = 'Live'; + } else if (this.wsReconnectAttempts > 0) { + wsStatusIcon = window.icon('dotYellow', { width: 10, height: 10 }); + wsStatusText = 'Reconnecting'; + } else { + wsStatusIcon = window.icon('dotRed', { width: 10, height: 10 }); + wsStatusText = 'Offline'; + } if (error) { statusText = 'Cluster Error'; @@ -38,13 +85,29 @@ class ClusterStatusComponent extends Component { // Update the cluster status badge using the container passed to this component if (this.container) { - this.container.innerHTML = `${statusIcon} ${statusText}`; - + // Create HTML with both cluster and WebSocket status on a single compact line + this.container.innerHTML = ` +