From a7018f53f32795ed751c83c4e4f7effa5381c707 Mon Sep 17 00:00:00 2001 From: 0x1d Date: Sun, 19 Oct 2025 21:52:55 +0200 Subject: [PATCH 1/4] feat: externalize cluster integration and API --- README.md | 204 ++---- index-standalone.js | 1164 ++++++++++++++++++++++++++++++++++ index.js | 1143 +-------------------------------- public/scripts/api-client.js | 4 +- 4 files changed, 1245 insertions(+), 1270 deletions(-) create mode 100644 index-standalone.js diff --git a/README.md b/README.md index 985479f..ca9bd6f 100644 --- a/README.md +++ b/README.md @@ -1,182 +1,114 @@ -# SPORE UI +# SPORE UI Frontend -Zero-configuration web interface for monitoring and managing SPORE embedded systems. +Frontend web interface for monitoring and managing SPORE embedded systems. Now works in conjunction with the SPORE Gateway backend service. + +## Architecture + +This frontend server works together with the **SPORE Gateway** (spore-gateway) backend service: + +- **spore-ui**: Serves the static frontend files and provides the user interface +- **spore-gateway**: Handles UDP node discovery, API endpoints, and WebSocket connections ## Features -- **๐ŸŒ Cluster Monitoring**: Real-time view of all cluster members with auto-discovery +- **๐ŸŒ Cluster Monitoring**: Real-time view of all cluster members via spore-gateway - **๐Ÿ“Š Node Details**: Detailed system information including running tasks and available endpoints - **๐Ÿš€ OTA**: Clusterwide over-the-air firmware updates - **๐Ÿ“ฑ Responsive**: Works on all devices and screen sizes - **๐Ÿ–ฅ๏ธ Terminal**: Terminal for interacting with a node's WebSocket +- **๐Ÿ”— Gateway Integration**: Seamlessly connects to spore-gateway for all backend functionality ## Screenshots ### Cluster -![UI](./assets/cluster.png) +![UI](./assets/cluster.png) ### Topology -![UI](./assets/topology.png) +![UI](./assets/topology.png) ### Monitoring -![UI](./assets/monitoring.png) +![UI](./assets/monitoring.png) ### Firmware ![UI](./assets/firmware.png) ## Getting Started +### Prerequisites 1. **Install dependencies**: `npm install` -2. **Start the server**: `npm start` -3. **Open in browser**: `http://localhost:3001` +2. **Start spore-gateway**: `./spore-gateway` (in the spore-gateway directory) +3. **Start frontend server**: `npm start` -## API Endpoints +### Access +- **Frontend UI**: `http://localhost:3000` +- **API Backend**: spore-gateway runs on port 3001 +- **WebSocket**: Connects to spore-gateway on port 3001 -- **`/`** - Main UI page -- **`/api/cluster/members`** - Get cluster member information -- **`/api/tasks/status`** - Get task status -- **`/api/node/status`** - Get system status -- **`/api/node/status/:ip`** - Get status from specific node +## API Integration + +The frontend automatically connects to the spore-gateway for: + +- **Cluster Discovery**: `/api/discovery/*` endpoints +- **Node Management**: `/api/node/*` endpoints +- **Task Monitoring**: `/api/tasks/*` endpoints +- **Real-time Updates**: WebSocket connections via `/ws` ## Technologies Used -- **Backend**: Express.js, Node.js +- **Backend Integration**: Express.js server connecting to spore-gateway - **Frontend**: Vanilla JavaScript, CSS3, HTML5 - **Framework**: Custom component-based architecture -- **API**: SPORE Embedded System API +- **API**: SPORE Embedded System API via spore-gateway - **Design**: Glassmorphism, CSS Grid, Flexbox -## UDP Heartbeat Discovery +## Development -The backend now includes automatic UDP heartbeat-based discovery for SPORE nodes on the network. This eliminates the need for hardcoded IP addresses and provides a self-healing, scalable solution for managing SPORE clusters. - -### ๐Ÿš€ How It Works - -1. **UDP Server**: The backend listens on port 4210 for UDP messages -2. **Heartbeat Message**: Nodes send `CLUSTER_HEARTBEAT` messages to broadcast address `255.255.255.255:4210` -3. **Auto Configuration**: When a heartbeat message is received, the source IP is automatically used to configure the SporeApiClient -4. **Dynamic Updates**: The system automatically switches to the most recently seen node as the primary connection -5. **Health Monitoring**: Continuous monitoring of node availability with automatic failover - -### ๐Ÿ“ก Heartbeat Protocol - -- **Port**: 4210 (configurable via `UDP_PORT` constant) -- **Message**: `CLUSTER_HEARTBEAT` (configurable via `HEARTBEAT_MESSAGE` constant) -- **Broadcast**: `255.255.255.255:4210` -- **Protocol**: UDP broadcast listening -- **Auto-binding**: Automatically binds to the specified port on startup - -### ๐Ÿ”ง Setup Instructions - -#### Backend Setup -```bash -# Start the backend server -npm start - -# The server will automatically: -# - Start HTTP server on port 3001 -# - Start UDP heartbeat server on port 4210 -# - Wait for CLUSTER_HEARTBEAT messages +### File Structure +``` +spore-ui/ +โ”œโ”€โ”€ public/ # Static frontend files +โ”‚ โ”œโ”€โ”€ index.html # Main HTML page +โ”‚ โ”œโ”€โ”€ scripts/ # JavaScript components +โ”‚ โ””โ”€โ”€ styles/ # CSS stylesheets +โ”œโ”€โ”€ index.js # Simple static file server +โ””โ”€โ”€ package.json # Node.js dependencies ``` -#### Node Configuration -SPORE nodes should send heartbeat messages periodically: +### Key Changes +- **Simplified Backend**: Now only serves static files +- **Gateway Integration**: All API calls go through spore-gateway +- **WebSocket Proxy**: Real-time updates via spore-gateway +- **UDP Discovery**: Handled by spore-gateway service + +## Troubleshooting + +### Common Issues + +**Frontend not connecting to gateway** ```bash -# Recommended: Send every 30-60 seconds -# Message format: "CLUSTER_HEARTBEAT:hostname" -# Target: 255.255.255.255:4210 -``` - -### ๐ŸŒ Cluster Endpoints - -#### Cluster Management -- `GET /api/discovery/nodes` - View all cluster nodes and current status -- `POST /api/discovery/refresh` - Manually trigger cluster refresh -- `POST /api/discovery/primary/:ip` - Manually set a specific node as primary -- `POST /api/discovery/random-primary` - Randomly select a new primary node - -#### Health Monitoring -- `GET /api/health` - Comprehensive health check including cluster status - -### ๐Ÿงช Testing & Development - -#### Test Scripts -```bash -# Send discovery messages to test the system -npm run test-discovery broadcast - -# Send to specific IP -npm run test-discovery 192.168.1.100 - -# Send multiple messages -npm run test-discovery broadcast 5 - -# Test random primary node selection -npm run test-random-selection - -# Monitor discovery in real-time -npm run demo-discovery -``` - -#### Manual Testing -```bash -# Check discovery status -curl http://localhost:3001/api/discovery/nodes - -# Check health +# Check if spore-gateway is running curl http://localhost:3001/api/health -# Manual refresh -curl -X POST http://localhost:3001/api/discovery/refresh - -# Random primary selection -curl -X POST http://localhost:3001/api/discovery/random-primary - -# Set specific primary -curl -X POST http://localhost:3001/api/discovery/primary/192.168.1.100 +# Verify gateway health +# Should return gateway health status ``` -### ๐Ÿ” Troubleshooting - -#### Common Issues - -**No Nodes Discovered** +**WebSocket connection issues** ```bash -# Check if backend is running -curl http://localhost:3001/api/health +# Check WebSocket endpoint +curl http://localhost:3001/api/test/websocket -# Verify UDP port is open -netstat -tulpn | grep 4210 - -# Send test discovery message -npm run test-discovery broadcast +# Verify gateway WebSocket server is running ``` -**UDP Port Already in Use** +**No cluster data** ```bash -# Check for conflicting processes -netstat -tulpn | grep 4210 - -# Kill conflicting processes or change port in code -# Restart backend server -``` - -**Client Not Initialized** -```bash -# Check discovery status +# Check gateway discovery status curl http://localhost:3001/api/discovery/nodes -# Verify nodes are sending discovery messages -# Check network connectivity +# Verify SPORE nodes are sending heartbeat messages ``` -#### Debug Commands -```bash -# Check discovery status -curl http://localhost:3001/api/discovery/nodes +## Architecture Benefits -# Check health -curl http://localhost:3001/api/health - -# Manual refresh -curl -X POST http://localhost:3001/api/discovery/refresh - -# Set primary node -curl -X POST http://localhost:3001/api/discovery/primary/192.168.1.100 -``` +1. **Separation of Concerns**: Frontend handles UI, gateway handles backend logic +2. **Scalability**: Gateway can handle multiple frontend instances +3. **Maintainability**: Clear separation between presentation and business logic +4. **Performance**: Gateway can optimize API calls and caching +5. **Reliability**: Gateway provides failover and health monitoring diff --git a/index-standalone.js b/index-standalone.js new file mode 100644 index 0000000..ce8041d --- /dev/null +++ b/index-standalone.js @@ -0,0 +1,1164 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const dgram = require('dgram'); +const SporeApiClient = require('./src/client'); +const cors = require('cors'); +const WebSocket = require('ws'); + +// Simple logging utility with level control +const logger = { + debug: (...args) => { + if (process.env.LOG_LEVEL === 'debug' || process.env.NODE_ENV === 'development') { + console.log('[DEBUG]', ...args); + } + }, + info: (...args) => console.log('[INFO]', ...args), + warn: (...args) => console.warn('[WARN]', ...args), + error: (...args) => console.error('[ERROR]', ...args) +}; + +const app = express(); +const PORT = process.env.PORT || 3001; + +// Middleware +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// File upload middleware +const fileUpload = require('express-fileupload'); +app.use(fileUpload({ + limits: { fileSize: 50 * 1024 * 1024 }, // 50MB limit + abortOnLimit: true, + responseOnLimit: 'File size limit has been reached', + debug: false +})); + +// Add CORS middleware +app.use(cors({ + origin: '*', // Or specify your phone's IP range like: ['http://192.168.1.0/24'] + methods: ['GET', 'POST', 'PUT', 'DELETE'], + allowedHeaders: ['Content-Type', 'Authorization'] +})); + +// UDP heartbeat-only configuration +const UDP_PORT = 4210; +const STALE_THRESHOLD_SECONDS = 8; // 8 seconds to accommodate 5-second heartbeat interval + +// Initialize UDP server for heartbeat-based cluster management +const udpServer = dgram.createSocket('udp4'); + +// Store discovered nodes and their IPs +const discoveredNodes = new Map(); +let primaryNodeIp = null; + +// UDP server event handlers +udpServer.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error(`UDP port ${UDP_PORT} is already in use. Please check if another instance is running.`); + } else { + console.error('UDP Server error:', err); + } + udpServer.close(); +}); + +udpServer.on('message', (msg, rinfo) => { + try { + const message = msg.toString().trim(); + const sourceIp = rinfo.address; + const sourcePort = rinfo.port; + + console.log(`๐Ÿ“จ UDP message received from ${sourceIp}:${sourcePort}: "${message}"`); + + if (message.startsWith('CLUSTER_HEARTBEAT:')) { + // Handle heartbeat messages that update member list + const hostname = message.substring('CLUSTER_HEARTBEAT:'.length); + updateNodeFromHeartbeat(sourceIp, sourcePort, hostname); + } else if (message.startsWith('NODE_UPDATE:')) { + // Handle node update messages that provide detailed node info + handleNodeUpdate(sourceIp, message); + } else if (!message.startsWith('RAW:')) { + console.log(`Received unknown message from ${sourceIp}:${sourcePort}: "${message}"`); + } + } catch (error) { + console.error('Error processing UDP message:', error); + } +}); + +udpServer.on('listening', () => { + const address = udpServer.address(); + console.log(`UDP heartbeat server listening on ${address.address}:${address.port}`); +}); + +// Bind UDP server to listen for heartbeat messages +udpServer.bind(UDP_PORT, () => { + console.log(`UDP heartbeat server bound to port ${UDP_PORT}`); +}); + +// Initialize the SPORE API client with dynamic IP +let sporeClient = null; + +// Function to initialize or update the SporeApiClient +function initializeSporeClient(nodeIp) { + if (!nodeIp) { + console.warn('No node IP available for SporeApiClient initialization'); + return null; + } + + try { + const client = new SporeApiClient(`http://${nodeIp}`); + console.log(`Initialized SporeApiClient with node IP: ${nodeIp}`); + return client; + } catch (error) { + console.error(`Failed to initialize SporeApiClient with IP ${nodeIp}:`, error); + return null; + } +} + +// 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()) { + 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 + logger.debug(`๐Ÿ“ก 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'); + + // 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 +function selectBestPrimaryNode() { + if (discoveredNodes.size === 0) { + return null; + } + + // If we already have a valid primary node, keep it + if (primaryNodeIp && discoveredNodes.has(primaryNodeIp)) { + return primaryNodeIp; + } + + // Select the most recently seen node as primary + let bestNode = null; + let mostRecent = new Date(0); + + for (const [ip, node] of discoveredNodes.entries()) { + if (node.lastSeen > mostRecent) { + mostRecent = node.lastSeen; + bestNode = ip; + } + } + + if (bestNode && bestNode !== primaryNodeIp) { + primaryNodeIp = bestNode; + console.log(`Selected new primary node: ${bestNode}`); + broadcastMemberListChange('primary node change'); + } + + return bestNode; +} + +// Function to randomly select a primary node +function selectRandomPrimaryNode() { + if (discoveredNodes.size === 0) { + return null; + } + + // Convert discovered nodes to array and filter out current primary + const availableNodes = Array.from(discoveredNodes.keys()).filter(ip => ip !== primaryNodeIp); + + if (availableNodes.length === 0) { + // If no other nodes available, keep current primary + return primaryNodeIp; + } + + // Randomly select from available nodes + const randomIndex = Math.floor(Math.random() * availableNodes.length); + const randomNode = availableNodes[randomIndex]; + + // Update primary node + primaryNodeIp = randomNode; + console.log(`Randomly selected new primary node: ${randomNode}`); + broadcastMemberListChange('random primary node selection'); + + return randomNode; +} + +// Initialize client when a node is discovered +function updateSporeClient() { + const nodeIp = selectBestPrimaryNode(); + if (nodeIp) { + sporeClient = initializeSporeClient(nodeIp); + } +} + +// Helper: perform an operation against the current primary, failing over to other discovered nodes if needed +async function performWithFailover(operation) { + // Build candidate list: current primary first, then others by most recently seen + const candidateIps = []; + if (primaryNodeIp && discoveredNodes.has(primaryNodeIp)) { + candidateIps.push(primaryNodeIp); + } + const others = Array.from(discoveredNodes.values()) + .filter(n => n.ip !== primaryNodeIp) + .sort((a, b) => b.lastSeen - a.lastSeen) + .map(n => n.ip); + candidateIps.push(...others); + + if (candidateIps.length === 0) { + throw new Error('No SPORE nodes discovered'); + } + + let lastError = null; + for (const ip of candidateIps) { + try { + const client = (sporeClient && ip === primaryNodeIp) + ? sporeClient + : initializeSporeClient(ip); + if (!client) { + throw new Error(`Failed to initialize client for ${ip}`); + } + const result = await operation(client, ip); + if (ip !== primaryNodeIp) { + primaryNodeIp = ip; + sporeClient = client; + logger.info(`Failover: switched primary node to ${ip}`); + broadcastMemberListChange('failover primary node switch'); + } + return result; + } catch (err) { + console.warn(`Primary attempt on ${ip} failed: ${err.message}`); + lastError = err; + continue; + } + } + + throw lastError || new Error('All discovered nodes failed'); +} + +// Function to update node from heartbeat message +function updateNodeFromHeartbeat(sourceIp, sourcePort, hostname) { + const existingNode = discoveredNodes.get(sourceIp); + const now = Math.floor(Date.now() / 1000); + + 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 + + logger.debug(`๐Ÿ’“ 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}"`); + } + + // Broadcast heartbeat update for immediate UI updates + const reason = wasStale ? 'node became active' : + hostnameChanged ? 'hostname update' : + 'active heartbeat'; + + logger.debug(`๐Ÿ“ก 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', + 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)`); + 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); + } +} + +// Function to handle NODE_UPDATE messages +function handleNodeUpdate(sourceIp, message) { + // Message format: "NODE_UPDATE:hostname:{json}" + const parts = message.split(':'); + if (parts.length < 3) { + console.warn(`Invalid NODE_UPDATE message format: ${message}`); + return; + } + + const hostname = parts[1]; + const jsonData = parts.slice(2).join(':'); + + try { + const nodeData = JSON.parse(jsonData); + + // Update the specific node with the new information + const existingNode = discoveredNodes.get(sourceIp); + if (existingNode) { + // Update hostname if provided + if (nodeData.hostname) { + existingNode.hostname = nodeData.hostname; + } + + // Update uptime if provided + if (nodeData.uptime) { + existingNode.uptime = nodeData.uptime; + } + + // Update labels if provided + if (nodeData.labels) { + existingNode.labels = nodeData.labels; + } + + existingNode.lastSeen = Math.floor(Date.now() / 1000); + existingNode.status = 'active'; + + console.log(`๐Ÿ”„ Updated node ${sourceIp} (${hostname}) from NODE_UPDATE`); + broadcastMemberListChange('node update'); + } else { + console.warn(`Received NODE_UPDATE for unknown node: ${sourceIp} (${hostname})`); + } + } catch (error) { + console.error(`Error parsing NODE_UPDATE JSON: ${error.message}`); + } +} + +// Set up periodic tasks +setInterval(() => { + markStaleNodes(); + if (!sporeClient || !primaryNodeIp || !discoveredNodes.has(primaryNodeIp)) { + updateSporeClient(); + } +}, 2000); // Check every 2 seconds for faster stale detection + +// Serve static files from public directory +app.use(express.static(path.join(__dirname, 'public'))); + +// Serve the main HTML page +app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +// API endpoint to get cluster nodes (heartbeat-based) +app.get('/api/discovery/nodes', (req, res) => { + const nodes = Array.from(discoveredNodes.values()).map(node => ({ + ...node, + discoveredAt: new Date(node.discoveredAt * 1000).toISOString(), + lastSeen: new Date(node.lastSeen * 1000).toISOString(), + isPrimary: node.ip === primaryNodeIp + })); + + res.json({ + primaryNode: primaryNodeIp, + totalNodes: discoveredNodes.size, + nodes: nodes, + clientInitialized: !!sporeClient, + clientBaseUrl: sporeClient ? sporeClient.baseUrl : null, + clusterStatus: { + udpPort: UDP_PORT, + serverRunning: udpServer.listening + } + }); +}); + +// API endpoint to manually trigger cluster refresh +app.post('/api/discovery/refresh', (req, res) => { + try { + // 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: 'Cluster refresh completed', + primaryNode: primaryNodeIp, + totalNodes: discoveredNodes.size, + clientInitialized: !!sporeClient + }); + } catch (error) { + console.error('Error during cluster refresh:', error); + res.status(500).json({ + error: 'Cluster refresh failed', + message: error.message + }); + } +}); + +// 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 { + if (discoveredNodes.size === 0) { + return res.status(404).json({ + error: 'No nodes available', + message: 'No SPORE nodes have been discovered yet' + }); + } + + // Randomly select a new primary node + const randomNode = selectRandomPrimaryNode(); + + if (!randomNode) { + return res.status(500).json({ + error: 'Selection failed', + message: 'Failed to select a random primary node' + }); + } + + // Update the client with the new primary node + updateSporeClient(); + + // Get current timestamp for the response + const timestamp = req.body && req.body.timestamp ? req.body.timestamp : new Date().toISOString(); + + res.json({ + success: true, + message: `Randomly selected new primary node: ${randomNode}`, + primaryNode: primaryNodeIp, + totalNodes: discoveredNodes.size, + clientInitialized: !!sporeClient, + timestamp: timestamp + }); + } catch (error) { + console.error('Error selecting random primary node:', error); + res.status(500).json({ + error: 'Random selection failed', + message: error.message + }); + } +}); + +// API endpoint to manually set primary node +app.post('/api/discovery/primary/:ip', (req, res) => { + try { + const requestedIp = req.params.ip; + + if (!discoveredNodes.has(requestedIp)) { + return res.status(404).json({ + error: 'Node not found', + message: `Node with IP ${requestedIp} has not been discovered` + }); + } + + primaryNodeIp = requestedIp; + updateSporeClient(); + broadcastMemberListChange('manual primary node setting'); + + res.json({ + success: true, + message: `Primary node set to ${requestedIp}`, + primaryNode: primaryNodeIp, + clientInitialized: !!sporeClient + }); + } catch (error) { + console.error('Error setting primary node:', error); + res.status(500).json({ + error: 'Failed to set primary node', + message: error.message + }); + } +}); + +// API endpoint to get cluster members +app.get('/api/cluster/members', async (req, res) => { + try { + if (discoveredNodes.size === 0) { + return res.status(503).json({ + error: 'Service unavailable', + message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_HEARTBEAT messages...', + discoveredNodes: Array.from(discoveredNodes.keys()) + }); + } + + const members = await performWithFailover((client) => client.getClusterStatus()); + res.json(members); + } catch (error) { + console.error('Error fetching cluster members:', error); + res.status(502).json({ + error: 'Failed to fetch cluster members', + message: error.message + }); + } +}); + +// API endpoint to get task status +app.get('/api/tasks/status', async (req, res) => { + try { + const { ip } = req.query; + + if (ip) { + try { + const nodeClient = new SporeApiClient(`http://${ip}`); + const taskStatus = await nodeClient.getTaskStatus(); + return res.json(taskStatus); + } catch (innerError) { + console.error('Error fetching task status from specific node:', innerError); + return res.status(500).json({ + error: 'Failed to fetch task status from node', + message: innerError.message + }); + } + } + + if (discoveredNodes.size === 0) { + return res.status(503).json({ + error: 'Service unavailable', + message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_HEARTBEAT messages...', + discoveredNodes: Array.from(discoveredNodes.keys()) + }); + } + + const taskStatus = await performWithFailover((client) => client.getTaskStatus()); + res.json(taskStatus); + } catch (error) { + console.error('Error fetching task status:', error); + res.status(502).json({ + error: 'Failed to fetch task status', + message: error.message + }); + } +}); + +// API endpoint to get system status +app.get('/api/node/status', async (req, res) => { + try { + if (discoveredNodes.size === 0) { + return res.status(503).json({ + error: 'Service unavailable', + message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_HEARTBEAT messages...', + discoveredNodes: Array.from(discoveredNodes.keys()) + }); + } + + const systemStatus = await performWithFailover((client) => client.getSystemStatus()); + res.json(systemStatus); + } catch (error) { + console.error('Error fetching system status:', error); + res.status(502).json({ + error: 'Failed to fetch system status', + message: error.message + }); + } +}); + +// Proxy endpoint to get node capabilities (optionally for a specific node via ?ip=) +app.get('/api/node/endpoints', async (req, res) => { + try { + const { ip } = req.query; + + if (ip) { + try { + const nodeClient = new SporeApiClient(`http://${ip}`); + const caps = await nodeClient.getCapabilities(); + return res.json(caps); + } catch (innerError) { + console.error('Error fetching endpoints from specific node:', innerError); + return res.status(500).json({ + error: 'Failed to fetch endpoints from node', + message: innerError.message + }); + } + } + + if (discoveredNodes.size === 0) { + return res.status(503).json({ + error: 'Service unavailable', + message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_HEARTBEAT messages...', + discoveredNodes: Array.from(discoveredNodes.keys()) + }); + } + + const caps = await performWithFailover((client) => client.getCapabilities()); + return res.json(caps); + } catch (error) { + console.error('Error fetching capabilities:', error); + return res.status(502).json({ + error: 'Failed to fetch capabilities', + message: error.message + }); + } +}); + +// Generic proxy to call a node capability directly +app.post('/api/proxy-call', async (req, res) => { + try { + const { ip, method, uri, params } = req.body || {}; + + if (!ip || !method || !uri) { + return res.status(400).json({ + error: 'Missing required fields', + message: 'Required: ip, method, uri' + }); + } + + // Build target URL + let targetPath = uri; + let queryParams = new URLSearchParams(); + let bodyParams = new URLSearchParams(); + + if (Array.isArray(params)) { + for (const p of params) { + const name = p?.name; + const value = p?.value ?? ''; + const location = (p?.location || 'body').toLowerCase(); + + if (!name) continue; + + if (location === 'query') { + queryParams.append(name, String(value)); + } else if (location === 'path') { + // Replace {name} or :name in path + targetPath = targetPath.replace(new RegExp(`[{:]${name}[}]?`, 'g'), encodeURIComponent(String(value))); + } else { + // Default to body + bodyParams.append(name, String(value)); + } + } + } + + const queryString = queryParams.toString(); + const fullUrl = `http://${ip}${targetPath}${queryString ? `?${queryString}` : ''}`; + + // Prepare fetch options + const upperMethod = String(method).toUpperCase(); + const fetchOptions = { method: upperMethod, headers: {} }; + + if (upperMethod !== 'GET') { + // Default to form-encoded body for generic proxy + fetchOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + fetchOptions.body = bodyParams.toString(); + } + + // Debug logging to trace upstream requests + try { + logger.debug('[proxy-call] โ†’', upperMethod, fullUrl); + if (upperMethod !== 'GET') { + logger.debug('[proxy-call] body:', fetchOptions.body); + } + } catch (_) { + // ignore logging errors + } + + // Execute request + const response = await fetch(fullUrl, fetchOptions); + const respContentType = response.headers.get('content-type') || ''; + + let data; + if (respContentType.includes('application/json')) { + data = await response.json(); + } else { + data = await response.text(); + } + + if (!response.ok) { + // Surface upstream failure details for easier debugging + console.warn('[proxy-call] Upstream error', response.status, response.statusText, 'for', upperMethod, fullUrl); + return res.status(response.status).json({ + error: 'Upstream request failed', + status: response.status, + statusText: response.statusText, + data + }); + } + + return res.json({ success: true, data }); + } catch (error) { + console.error('Error in /api/proxy-call:', error); + return res.status(500).json({ + error: 'Proxy call failed', + message: error.message + }); + } +}); + +// Proxy endpoint to get status from a specific node +app.get('/api/node/status/:ip', async (req, res) => { + try { + const nodeIp = req.params.ip; + + // Create a temporary client for the specific node + const nodeClient = new SporeApiClient(`http://${nodeIp}`); + const nodeStatus = await nodeClient.getSystemStatus(); + + res.json(nodeStatus); + } catch (error) { + console.error(`Error fetching status from node ${req.params.ip}:`, error); + res.status(500).json({ + error: `Failed to fetch status from node ${req.params.ip}`, + message: error.message + }); + } +}); + +// Endpoint to trigger a cluster refresh +app.post('/api/cluster/refresh', async (req, res) => { + try { + const { reason } = req.body || {}; + console.log(`๐Ÿ”„ Manual cluster refresh triggered: ${reason || 'unknown reason'}`); + console.log(`๐Ÿ“ก WebSocket clients connected: ${wsClients.size}`); + + // Trigger a cluster update broadcast + broadcastMemberListChange(reason || 'manual_refresh'); + + res.json({ + success: true, + message: 'Cluster refresh triggered', + reason: reason || 'manual_refresh', + wsClients: wsClients.size + }); + } catch (error) { + console.error('Error triggering cluster refresh:', error); + res.status(500).json({ + error: 'Failed to trigger cluster refresh', + message: error.message + }); + } +}); + +// File upload endpoint for firmware updates +app.post('/api/node/update', async (req, res) => { + try { + const nodeIp = req.query.ip || req.headers['x-node-ip']; + + if (!nodeIp) { + return res.status(400).json({ + error: 'Node IP address is required', + message: 'Please provide the target node IP address' + }); + } + + // Check if we have a file in the request + if (!req.files || !req.files.file) { + console.log('File upload request received but no file found:', { + hasFiles: !!req.files, + fileKeys: req.files ? Object.keys(req.files) : [], + contentType: req.headers['content-type'] + }); + return res.status(400).json({ + error: 'No file data received', + message: 'Please select a firmware file to upload' + }); + } + + const uploadedFile = req.files.file; + console.log(`File upload received:`, { + nodeIp: nodeIp, + filename: uploadedFile.name, + fileSize: uploadedFile.data.length, + mimetype: uploadedFile.mimetype, + encoding: uploadedFile.encoding + }); + + // Create a temporary client for the specific node + const nodeClient = new SporeApiClient(`http://${nodeIp}`); + console.log(`Created SPORE client for node ${nodeIp}`); + + // Send the firmware data to the node + console.log(`Starting firmware upload to SPORE device ${nodeIp}...`); + + try { + const updateResult = await nodeClient.updateFirmware(uploadedFile.data, uploadedFile.name); + console.log(`Firmware upload to SPORE device ${nodeIp} completed:`, updateResult); + + // Check if the SPORE device reported a failure + if (updateResult && updateResult.status === 'FAIL') { + console.error(`SPORE device ${nodeIp} reported firmware update failure:`, updateResult.message); + return res.status(400).json({ + success: false, + error: 'Firmware update failed', + message: updateResult.message || 'Firmware update failed on device', + nodeIp: nodeIp, + fileSize: uploadedFile.data.length, + filename: uploadedFile.name, + result: updateResult + }); + } + + res.json({ + success: true, + message: 'Firmware uploaded successfully', + nodeIp: nodeIp, + fileSize: uploadedFile.data.length, + filename: uploadedFile.name, + result: updateResult + }); + } catch (uploadError) { + console.error(`Firmware upload to SPORE device ${nodeIp} failed:`, uploadError); + throw new Error(`SPORE device upload failed: ${uploadError.message}`); + } + + } catch (error) { + console.error('Error uploading firmware:', error); + res.status(500).json({ + error: 'Failed to upload firmware', + message: error.message + }); + } +}); + +// Health check endpoint +app.get('/api/health', (req, res) => { + const health = { + status: 'healthy', + timestamp: new Date().toISOString(), + services: { + http: true, + udp: udpServer.listening, + sporeClient: !!sporeClient + }, + cluster: { + totalNodes: discoveredNodes.size, + primaryNode: primaryNodeIp, + udpPort: UDP_PORT, + serverRunning: udpServer.listening + } + }; + + // If no nodes discovered, mark as degraded + if (discoveredNodes.size === 0) { + health.status = 'degraded'; + health.message = 'No SPORE nodes discovered yet'; + } + + // If no client initialized, mark as degraded + if (!sporeClient) { + health.status = 'degraded'; + health.message = health.message ? + `${health.message}; SPORE client not initialized` : + 'SPORE client not initialized'; + } + + const statusCode = health.status === 'healthy' ? 200 : 503; + res.status(statusCode).json(health); +}); + + + +// 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(); + logger.debug(`๐Ÿ“ก [${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; + logger.debug(`๐Ÿ“ก [${new Date().toISOString()}] Broadcasting cluster update to ${wsClients.size} WebSocket clients (took ${broadcastTime}ms)`); + logger.debug(`๐Ÿ“Š 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(); + logger.debug(`๐Ÿ”„ [${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 + logger.debug(`๐Ÿ“ก Fetching real cluster data from ${discoveredNodes.size} nodes for WebSocket broadcast`); + const clusterResponse = await performWithFailover((client) => client.getClusterStatus()); + const apiMembers = clusterResponse.members || []; + + // Debug: Log the labels from the API response + apiMembers.forEach(member => { + if (member.labels && Object.keys(member.labels).length > 0) { + logger.debug(`๐Ÿท๏ธ API member ${member.ip} labels:`, member.labels); + } + }); + + // 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) { + logger.debug(`๐Ÿ”„ 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 + logger.debug(`๐Ÿ†• 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) { + logger.debug(`๐Ÿ“ก 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; + }); + + logger.debug(`๐Ÿ“Š 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 + logger.debug('โš ๏ธ 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 || {} + })); + + logger.debug(`๐Ÿ“Š 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) => { + logger.debug('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() + }; + logger.debug(`๐Ÿ”Œ 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', () => { + logger.debug('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 heartbeat 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_HEARTBEAT and NODE_UPDATE messages from SPORE nodes...'); +}); + +// Graceful shutdown handling +process.on('SIGINT', () => { + console.log('\nReceived SIGINT. Shutting down gracefully...'); + udpServer.close(() => { + console.log('UDP heartbeat server closed.'); + }); + server.close(() => { + console.log('HTTP server closed.'); + process.exit(0); + }); +}); + +process.on('SIGTERM', () => { + console.log('\nReceived SIGTERM. Shutting down gracefully...'); + udpServer.close(() => { + console.log('UDP heartbeat server closed.'); + }); + server.close(() => { + console.log('HTTP server closed.'); + process.exit(0); + }); +}); + +// Handle uncaught exceptions +process.on('uncaughtException', (err) => { + console.error('Uncaught Exception:', err); + udpServer.close(); + server.close(); + process.exit(1); +}); + +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + udpServer.close(); + server.close(); + process.exit(1); +}); \ No newline at end of file diff --git a/index.js b/index.js index ce8041d..21b13de 100644 --- a/index.js +++ b/index.js @@ -1,10 +1,5 @@ const express = require('express'); const path = require('path'); -const fs = require('fs'); -const dgram = require('dgram'); -const SporeApiClient = require('./src/client'); -const cors = require('cors'); -const WebSocket = require('ws'); // Simple logging utility with level control const logger = { @@ -19,363 +14,7 @@ const logger = { }; const app = express(); -const PORT = process.env.PORT || 3001; - -// Middleware -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); - -// File upload middleware -const fileUpload = require('express-fileupload'); -app.use(fileUpload({ - limits: { fileSize: 50 * 1024 * 1024 }, // 50MB limit - abortOnLimit: true, - responseOnLimit: 'File size limit has been reached', - debug: false -})); - -// Add CORS middleware -app.use(cors({ - origin: '*', // Or specify your phone's IP range like: ['http://192.168.1.0/24'] - methods: ['GET', 'POST', 'PUT', 'DELETE'], - allowedHeaders: ['Content-Type', 'Authorization'] -})); - -// UDP heartbeat-only configuration -const UDP_PORT = 4210; -const STALE_THRESHOLD_SECONDS = 8; // 8 seconds to accommodate 5-second heartbeat interval - -// Initialize UDP server for heartbeat-based cluster management -const udpServer = dgram.createSocket('udp4'); - -// Store discovered nodes and their IPs -const discoveredNodes = new Map(); -let primaryNodeIp = null; - -// UDP server event handlers -udpServer.on('error', (err) => { - if (err.code === 'EADDRINUSE') { - console.error(`UDP port ${UDP_PORT} is already in use. Please check if another instance is running.`); - } else { - console.error('UDP Server error:', err); - } - udpServer.close(); -}); - -udpServer.on('message', (msg, rinfo) => { - try { - const message = msg.toString().trim(); - const sourceIp = rinfo.address; - const sourcePort = rinfo.port; - - console.log(`๐Ÿ“จ UDP message received from ${sourceIp}:${sourcePort}: "${message}"`); - - if (message.startsWith('CLUSTER_HEARTBEAT:')) { - // Handle heartbeat messages that update member list - const hostname = message.substring('CLUSTER_HEARTBEAT:'.length); - updateNodeFromHeartbeat(sourceIp, sourcePort, hostname); - } else if (message.startsWith('NODE_UPDATE:')) { - // Handle node update messages that provide detailed node info - handleNodeUpdate(sourceIp, message); - } else if (!message.startsWith('RAW:')) { - console.log(`Received unknown message from ${sourceIp}:${sourcePort}: "${message}"`); - } - } catch (error) { - console.error('Error processing UDP message:', error); - } -}); - -udpServer.on('listening', () => { - const address = udpServer.address(); - console.log(`UDP heartbeat server listening on ${address.address}:${address.port}`); -}); - -// Bind UDP server to listen for heartbeat messages -udpServer.bind(UDP_PORT, () => { - console.log(`UDP heartbeat server bound to port ${UDP_PORT}`); -}); - -// Initialize the SPORE API client with dynamic IP -let sporeClient = null; - -// Function to initialize or update the SporeApiClient -function initializeSporeClient(nodeIp) { - if (!nodeIp) { - console.warn('No node IP available for SporeApiClient initialization'); - return null; - } - - try { - const client = new SporeApiClient(`http://${nodeIp}`); - console.log(`Initialized SporeApiClient with node IP: ${nodeIp}`); - return client; - } catch (error) { - console.error(`Failed to initialize SporeApiClient with IP ${nodeIp}:`, error); - return null; - } -} - -// 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()) { - 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 - logger.debug(`๐Ÿ“ก 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'); - - // 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 -function selectBestPrimaryNode() { - if (discoveredNodes.size === 0) { - return null; - } - - // If we already have a valid primary node, keep it - if (primaryNodeIp && discoveredNodes.has(primaryNodeIp)) { - return primaryNodeIp; - } - - // Select the most recently seen node as primary - let bestNode = null; - let mostRecent = new Date(0); - - for (const [ip, node] of discoveredNodes.entries()) { - if (node.lastSeen > mostRecent) { - mostRecent = node.lastSeen; - bestNode = ip; - } - } - - if (bestNode && bestNode !== primaryNodeIp) { - primaryNodeIp = bestNode; - console.log(`Selected new primary node: ${bestNode}`); - broadcastMemberListChange('primary node change'); - } - - return bestNode; -} - -// Function to randomly select a primary node -function selectRandomPrimaryNode() { - if (discoveredNodes.size === 0) { - return null; - } - - // Convert discovered nodes to array and filter out current primary - const availableNodes = Array.from(discoveredNodes.keys()).filter(ip => ip !== primaryNodeIp); - - if (availableNodes.length === 0) { - // If no other nodes available, keep current primary - return primaryNodeIp; - } - - // Randomly select from available nodes - const randomIndex = Math.floor(Math.random() * availableNodes.length); - const randomNode = availableNodes[randomIndex]; - - // Update primary node - primaryNodeIp = randomNode; - console.log(`Randomly selected new primary node: ${randomNode}`); - broadcastMemberListChange('random primary node selection'); - - return randomNode; -} - -// Initialize client when a node is discovered -function updateSporeClient() { - const nodeIp = selectBestPrimaryNode(); - if (nodeIp) { - sporeClient = initializeSporeClient(nodeIp); - } -} - -// Helper: perform an operation against the current primary, failing over to other discovered nodes if needed -async function performWithFailover(operation) { - // Build candidate list: current primary first, then others by most recently seen - const candidateIps = []; - if (primaryNodeIp && discoveredNodes.has(primaryNodeIp)) { - candidateIps.push(primaryNodeIp); - } - const others = Array.from(discoveredNodes.values()) - .filter(n => n.ip !== primaryNodeIp) - .sort((a, b) => b.lastSeen - a.lastSeen) - .map(n => n.ip); - candidateIps.push(...others); - - if (candidateIps.length === 0) { - throw new Error('No SPORE nodes discovered'); - } - - let lastError = null; - for (const ip of candidateIps) { - try { - const client = (sporeClient && ip === primaryNodeIp) - ? sporeClient - : initializeSporeClient(ip); - if (!client) { - throw new Error(`Failed to initialize client for ${ip}`); - } - const result = await operation(client, ip); - if (ip !== primaryNodeIp) { - primaryNodeIp = ip; - sporeClient = client; - logger.info(`Failover: switched primary node to ${ip}`); - broadcastMemberListChange('failover primary node switch'); - } - return result; - } catch (err) { - console.warn(`Primary attempt on ${ip} failed: ${err.message}`); - lastError = err; - continue; - } - } - - throw lastError || new Error('All discovered nodes failed'); -} - -// Function to update node from heartbeat message -function updateNodeFromHeartbeat(sourceIp, sourcePort, hostname) { - const existingNode = discoveredNodes.get(sourceIp); - const now = Math.floor(Date.now() / 1000); - - 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 - - logger.debug(`๐Ÿ’“ 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}"`); - } - - // Broadcast heartbeat update for immediate UI updates - const reason = wasStale ? 'node became active' : - hostnameChanged ? 'hostname update' : - 'active heartbeat'; - - logger.debug(`๐Ÿ“ก 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', - 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)`); - 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); - } -} - -// Function to handle NODE_UPDATE messages -function handleNodeUpdate(sourceIp, message) { - // Message format: "NODE_UPDATE:hostname:{json}" - const parts = message.split(':'); - if (parts.length < 3) { - console.warn(`Invalid NODE_UPDATE message format: ${message}`); - return; - } - - const hostname = parts[1]; - const jsonData = parts.slice(2).join(':'); - - try { - const nodeData = JSON.parse(jsonData); - - // Update the specific node with the new information - const existingNode = discoveredNodes.get(sourceIp); - if (existingNode) { - // Update hostname if provided - if (nodeData.hostname) { - existingNode.hostname = nodeData.hostname; - } - - // Update uptime if provided - if (nodeData.uptime) { - existingNode.uptime = nodeData.uptime; - } - - // Update labels if provided - if (nodeData.labels) { - existingNode.labels = nodeData.labels; - } - - existingNode.lastSeen = Math.floor(Date.now() / 1000); - existingNode.status = 'active'; - - console.log(`๐Ÿ”„ Updated node ${sourceIp} (${hostname}) from NODE_UPDATE`); - broadcastMemberListChange('node update'); - } else { - console.warn(`Received NODE_UPDATE for unknown node: ${sourceIp} (${hostname})`); - } - } catch (error) { - console.error(`Error parsing NODE_UPDATE JSON: ${error.message}`); - } -} - -// Set up periodic tasks -setInterval(() => { - markStaleNodes(); - if (!sporeClient || !primaryNodeIp || !discoveredNodes.has(primaryNodeIp)) { - updateSporeClient(); - } -}, 2000); // Check every 2 seconds for faster stale detection +const PORT = process.env.PORT || 3000; // Serve static files from public directory app.use(express.static(path.join(__dirname, 'public'))); @@ -385,780 +24,20 @@ app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); -// API endpoint to get cluster nodes (heartbeat-based) -app.get('/api/discovery/nodes', (req, res) => { - const nodes = Array.from(discoveredNodes.values()).map(node => ({ - ...node, - discoveredAt: new Date(node.discoveredAt * 1000).toISOString(), - lastSeen: new Date(node.lastSeen * 1000).toISOString(), - isPrimary: node.ip === primaryNodeIp - })); - - res.json({ - primaryNode: primaryNodeIp, - totalNodes: discoveredNodes.size, - nodes: nodes, - clientInitialized: !!sporeClient, - clientBaseUrl: sporeClient ? sporeClient.baseUrl : null, - clusterStatus: { - udpPort: UDP_PORT, - serverRunning: udpServer.listening - } - }); -}); - -// API endpoint to manually trigger cluster refresh -app.post('/api/discovery/refresh', (req, res) => { - try { - // 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: 'Cluster refresh completed', - primaryNode: primaryNodeIp, - totalNodes: discoveredNodes.size, - clientInitialized: !!sporeClient - }); - } catch (error) { - console.error('Error during cluster refresh:', error); - res.status(500).json({ - error: 'Cluster refresh failed', - message: error.message - }); - } -}); - -// 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 { - if (discoveredNodes.size === 0) { - return res.status(404).json({ - error: 'No nodes available', - message: 'No SPORE nodes have been discovered yet' - }); - } - - // Randomly select a new primary node - const randomNode = selectRandomPrimaryNode(); - - if (!randomNode) { - return res.status(500).json({ - error: 'Selection failed', - message: 'Failed to select a random primary node' - }); - } - - // Update the client with the new primary node - updateSporeClient(); - - // Get current timestamp for the response - const timestamp = req.body && req.body.timestamp ? req.body.timestamp : new Date().toISOString(); - - res.json({ - success: true, - message: `Randomly selected new primary node: ${randomNode}`, - primaryNode: primaryNodeIp, - totalNodes: discoveredNodes.size, - clientInitialized: !!sporeClient, - timestamp: timestamp - }); - } catch (error) { - console.error('Error selecting random primary node:', error); - res.status(500).json({ - error: 'Random selection failed', - message: error.message - }); - } -}); - -// API endpoint to manually set primary node -app.post('/api/discovery/primary/:ip', (req, res) => { - try { - const requestedIp = req.params.ip; - - if (!discoveredNodes.has(requestedIp)) { - return res.status(404).json({ - error: 'Node not found', - message: `Node with IP ${requestedIp} has not been discovered` - }); - } - - primaryNodeIp = requestedIp; - updateSporeClient(); - broadcastMemberListChange('manual primary node setting'); - - res.json({ - success: true, - message: `Primary node set to ${requestedIp}`, - primaryNode: primaryNodeIp, - clientInitialized: !!sporeClient - }); - } catch (error) { - console.error('Error setting primary node:', error); - res.status(500).json({ - error: 'Failed to set primary node', - message: error.message - }); - } -}); - -// API endpoint to get cluster members -app.get('/api/cluster/members', async (req, res) => { - try { - if (discoveredNodes.size === 0) { - return res.status(503).json({ - error: 'Service unavailable', - message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_HEARTBEAT messages...', - discoveredNodes: Array.from(discoveredNodes.keys()) - }); - } - - const members = await performWithFailover((client) => client.getClusterStatus()); - res.json(members); - } catch (error) { - console.error('Error fetching cluster members:', error); - res.status(502).json({ - error: 'Failed to fetch cluster members', - message: error.message - }); - } -}); - -// API endpoint to get task status -app.get('/api/tasks/status', async (req, res) => { - try { - const { ip } = req.query; - - if (ip) { - try { - const nodeClient = new SporeApiClient(`http://${ip}`); - const taskStatus = await nodeClient.getTaskStatus(); - return res.json(taskStatus); - } catch (innerError) { - console.error('Error fetching task status from specific node:', innerError); - return res.status(500).json({ - error: 'Failed to fetch task status from node', - message: innerError.message - }); - } - } - - if (discoveredNodes.size === 0) { - return res.status(503).json({ - error: 'Service unavailable', - message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_HEARTBEAT messages...', - discoveredNodes: Array.from(discoveredNodes.keys()) - }); - } - - const taskStatus = await performWithFailover((client) => client.getTaskStatus()); - res.json(taskStatus); - } catch (error) { - console.error('Error fetching task status:', error); - res.status(502).json({ - error: 'Failed to fetch task status', - message: error.message - }); - } -}); - -// API endpoint to get system status -app.get('/api/node/status', async (req, res) => { - try { - if (discoveredNodes.size === 0) { - return res.status(503).json({ - error: 'Service unavailable', - message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_HEARTBEAT messages...', - discoveredNodes: Array.from(discoveredNodes.keys()) - }); - } - - const systemStatus = await performWithFailover((client) => client.getSystemStatus()); - res.json(systemStatus); - } catch (error) { - console.error('Error fetching system status:', error); - res.status(502).json({ - error: 'Failed to fetch system status', - message: error.message - }); - } -}); - -// Proxy endpoint to get node capabilities (optionally for a specific node via ?ip=) -app.get('/api/node/endpoints', async (req, res) => { - try { - const { ip } = req.query; - - if (ip) { - try { - const nodeClient = new SporeApiClient(`http://${ip}`); - const caps = await nodeClient.getCapabilities(); - return res.json(caps); - } catch (innerError) { - console.error('Error fetching endpoints from specific node:', innerError); - return res.status(500).json({ - error: 'Failed to fetch endpoints from node', - message: innerError.message - }); - } - } - - if (discoveredNodes.size === 0) { - return res.status(503).json({ - error: 'Service unavailable', - message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_HEARTBEAT messages...', - discoveredNodes: Array.from(discoveredNodes.keys()) - }); - } - - const caps = await performWithFailover((client) => client.getCapabilities()); - return res.json(caps); - } catch (error) { - console.error('Error fetching capabilities:', error); - return res.status(502).json({ - error: 'Failed to fetch capabilities', - message: error.message - }); - } -}); - -// Generic proxy to call a node capability directly -app.post('/api/proxy-call', async (req, res) => { - try { - const { ip, method, uri, params } = req.body || {}; - - if (!ip || !method || !uri) { - return res.status(400).json({ - error: 'Missing required fields', - message: 'Required: ip, method, uri' - }); - } - - // Build target URL - let targetPath = uri; - let queryParams = new URLSearchParams(); - let bodyParams = new URLSearchParams(); - - if (Array.isArray(params)) { - for (const p of params) { - const name = p?.name; - const value = p?.value ?? ''; - const location = (p?.location || 'body').toLowerCase(); - - if (!name) continue; - - if (location === 'query') { - queryParams.append(name, String(value)); - } else if (location === 'path') { - // Replace {name} or :name in path - targetPath = targetPath.replace(new RegExp(`[{:]${name}[}]?`, 'g'), encodeURIComponent(String(value))); - } else { - // Default to body - bodyParams.append(name, String(value)); - } - } - } - - const queryString = queryParams.toString(); - const fullUrl = `http://${ip}${targetPath}${queryString ? `?${queryString}` : ''}`; - - // Prepare fetch options - const upperMethod = String(method).toUpperCase(); - const fetchOptions = { method: upperMethod, headers: {} }; - - if (upperMethod !== 'GET') { - // Default to form-encoded body for generic proxy - fetchOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded'; - fetchOptions.body = bodyParams.toString(); - } - - // Debug logging to trace upstream requests - try { - logger.debug('[proxy-call] โ†’', upperMethod, fullUrl); - if (upperMethod !== 'GET') { - logger.debug('[proxy-call] body:', fetchOptions.body); - } - } catch (_) { - // ignore logging errors - } - - // Execute request - const response = await fetch(fullUrl, fetchOptions); - const respContentType = response.headers.get('content-type') || ''; - - let data; - if (respContentType.includes('application/json')) { - data = await response.json(); - } else { - data = await response.text(); - } - - if (!response.ok) { - // Surface upstream failure details for easier debugging - console.warn('[proxy-call] Upstream error', response.status, response.statusText, 'for', upperMethod, fullUrl); - return res.status(response.status).json({ - error: 'Upstream request failed', - status: response.status, - statusText: response.statusText, - data - }); - } - - return res.json({ success: true, data }); - } catch (error) { - console.error('Error in /api/proxy-call:', error); - return res.status(500).json({ - error: 'Proxy call failed', - message: error.message - }); - } -}); - -// Proxy endpoint to get status from a specific node -app.get('/api/node/status/:ip', async (req, res) => { - try { - const nodeIp = req.params.ip; - - // Create a temporary client for the specific node - const nodeClient = new SporeApiClient(`http://${nodeIp}`); - const nodeStatus = await nodeClient.getSystemStatus(); - - res.json(nodeStatus); - } catch (error) { - console.error(`Error fetching status from node ${req.params.ip}:`, error); - res.status(500).json({ - error: `Failed to fetch status from node ${req.params.ip}`, - message: error.message - }); - } -}); - -// Endpoint to trigger a cluster refresh -app.post('/api/cluster/refresh', async (req, res) => { - try { - const { reason } = req.body || {}; - console.log(`๐Ÿ”„ Manual cluster refresh triggered: ${reason || 'unknown reason'}`); - console.log(`๐Ÿ“ก WebSocket clients connected: ${wsClients.size}`); - - // Trigger a cluster update broadcast - broadcastMemberListChange(reason || 'manual_refresh'); - - res.json({ - success: true, - message: 'Cluster refresh triggered', - reason: reason || 'manual_refresh', - wsClients: wsClients.size - }); - } catch (error) { - console.error('Error triggering cluster refresh:', error); - res.status(500).json({ - error: 'Failed to trigger cluster refresh', - message: error.message - }); - } -}); - -// File upload endpoint for firmware updates -app.post('/api/node/update', async (req, res) => { - try { - const nodeIp = req.query.ip || req.headers['x-node-ip']; - - if (!nodeIp) { - return res.status(400).json({ - error: 'Node IP address is required', - message: 'Please provide the target node IP address' - }); - } - - // Check if we have a file in the request - if (!req.files || !req.files.file) { - console.log('File upload request received but no file found:', { - hasFiles: !!req.files, - fileKeys: req.files ? Object.keys(req.files) : [], - contentType: req.headers['content-type'] - }); - return res.status(400).json({ - error: 'No file data received', - message: 'Please select a firmware file to upload' - }); - } - - const uploadedFile = req.files.file; - console.log(`File upload received:`, { - nodeIp: nodeIp, - filename: uploadedFile.name, - fileSize: uploadedFile.data.length, - mimetype: uploadedFile.mimetype, - encoding: uploadedFile.encoding - }); - - // Create a temporary client for the specific node - const nodeClient = new SporeApiClient(`http://${nodeIp}`); - console.log(`Created SPORE client for node ${nodeIp}`); - - // Send the firmware data to the node - console.log(`Starting firmware upload to SPORE device ${nodeIp}...`); - - try { - const updateResult = await nodeClient.updateFirmware(uploadedFile.data, uploadedFile.name); - console.log(`Firmware upload to SPORE device ${nodeIp} completed:`, updateResult); - - // Check if the SPORE device reported a failure - if (updateResult && updateResult.status === 'FAIL') { - console.error(`SPORE device ${nodeIp} reported firmware update failure:`, updateResult.message); - return res.status(400).json({ - success: false, - error: 'Firmware update failed', - message: updateResult.message || 'Firmware update failed on device', - nodeIp: nodeIp, - fileSize: uploadedFile.data.length, - filename: uploadedFile.name, - result: updateResult - }); - } - - res.json({ - success: true, - message: 'Firmware uploaded successfully', - nodeIp: nodeIp, - fileSize: uploadedFile.data.length, - filename: uploadedFile.name, - result: updateResult - }); - } catch (uploadError) { - console.error(`Firmware upload to SPORE device ${nodeIp} failed:`, uploadError); - throw new Error(`SPORE device upload failed: ${uploadError.message}`); - } - - } catch (error) { - console.error('Error uploading firmware:', error); - res.status(500).json({ - error: 'Failed to upload firmware', - message: error.message - }); - } -}); - // Health check endpoint -app.get('/api/health', (req, res) => { - const health = { +app.get('/health', (req, res) => { + res.json({ status: 'healthy', + service: 'spore-ui-frontend', timestamp: new Date().toISOString(), - services: { - http: true, - udp: udpServer.listening, - sporeClient: !!sporeClient - }, - cluster: { - totalNodes: discoveredNodes.size, - primaryNode: primaryNodeIp, - udpPort: UDP_PORT, - serverRunning: udpServer.listening - } - }; - - // If no nodes discovered, mark as degraded - if (discoveredNodes.size === 0) { - health.status = 'degraded'; - health.message = 'No SPORE nodes discovered yet'; - } - - // If no client initialized, mark as degraded - if (!sporeClient) { - health.status = 'degraded'; - health.message = health.message ? - `${health.message}; SPORE client not initialized` : - 'SPORE client not initialized'; - } - - const statusCode = health.status === 'healthy' ? 200 : 503; - res.status(statusCode).json(health); + note: 'Frontend server - API calls are handled by spore-gateway on port 3001' + }); }); - - -// 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(); - logger.debug(`๐Ÿ“ก [${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; - logger.debug(`๐Ÿ“ก [${new Date().toISOString()}] Broadcasting cluster update to ${wsClients.size} WebSocket clients (took ${broadcastTime}ms)`); - logger.debug(`๐Ÿ“Š 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(); - logger.debug(`๐Ÿ”„ [${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 - logger.debug(`๐Ÿ“ก Fetching real cluster data from ${discoveredNodes.size} nodes for WebSocket broadcast`); - const clusterResponse = await performWithFailover((client) => client.getClusterStatus()); - const apiMembers = clusterResponse.members || []; - - // Debug: Log the labels from the API response - apiMembers.forEach(member => { - if (member.labels && Object.keys(member.labels).length > 0) { - logger.debug(`๐Ÿท๏ธ API member ${member.ip} labels:`, member.labels); - } - }); - - // 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) { - logger.debug(`๐Ÿ”„ 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 - logger.debug(`๐Ÿ†• 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) { - logger.debug(`๐Ÿ“ก 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; - }); - - logger.debug(`๐Ÿ“Š 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 - logger.debug('โš ๏ธ 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 || {} - })); - - logger.debug(`๐Ÿ“Š 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) => { - logger.debug('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() - }; - logger.debug(`๐Ÿ”Œ 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', () => { - logger.debug('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}`); +app.listen(PORT, '0.0.0.0', () => { + console.log(`SPORE UI Frontend Server is running on http://0.0.0.0:${PORT}`); console.log(`Accessible from: http://YOUR_COMPUTER_IP:${PORT}`); - console.log(`UDP heartbeat 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_HEARTBEAT and NODE_UPDATE messages from SPORE nodes...'); -}); - -// Graceful shutdown handling -process.on('SIGINT', () => { - console.log('\nReceived SIGINT. Shutting down gracefully...'); - udpServer.close(() => { - console.log('UDP heartbeat server closed.'); - }); - server.close(() => { - console.log('HTTP server closed.'); - process.exit(0); - }); -}); - -process.on('SIGTERM', () => { - console.log('\nReceived SIGTERM. Shutting down gracefully...'); - udpServer.close(() => { - console.log('UDP heartbeat server closed.'); - }); - server.close(() => { - console.log('HTTP server closed.'); - process.exit(0); - }); -}); - -// Handle uncaught exceptions -process.on('uncaughtException', (err) => { - console.error('Uncaught Exception:', err); - udpServer.close(); - server.close(); - process.exit(1); -}); - -process.on('unhandledRejection', (reason, promise) => { - console.error('Unhandled Rejection at:', promise, 'reason:', reason); - udpServer.close(); - server.close(); - process.exit(1); -}); \ No newline at end of file + console.log(`Frontend connects to spore-gateway for API and WebSocket functionality`); + console.log(`Make sure spore-gateway is running on port 3001`); +}); \ No newline at end of file diff --git a/public/scripts/api-client.js b/public/scripts/api-client.js index aae118b..1fc9034 100644 --- a/public/scripts/api-client.js +++ b/public/scripts/api-client.js @@ -159,9 +159,9 @@ class WebSocketClient { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; if (currentHost === 'localhost' || currentHost === '127.0.0.1') { - this.wsUrl = `${wsProtocol}//localhost:3001`; + this.wsUrl = `${wsProtocol}//localhost:3001/ws`; } else { - this.wsUrl = `${wsProtocol}//${currentHost}:3001`; + this.wsUrl = `${wsProtocol}//${currentHost}:3001/ws`; } logger.debug('WebSocket Client initialized with URL:', this.wsUrl); -- 2.49.1 From 85802c68db1b85dfccce0f3db762e2d54689ccb7 Mon Sep 17 00:00:00 2001 From: 0x1d Date: Sun, 19 Oct 2025 22:42:39 +0200 Subject: [PATCH 2/4] fix: configureNodeWiFi method to properly check the response structure --- public/scripts/components/WiFiConfigComponent.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/public/scripts/components/WiFiConfigComponent.js b/public/scripts/components/WiFiConfigComponent.js index 468b991..79050b0 100644 --- a/public/scripts/components/WiFiConfigComponent.js +++ b/public/scripts/components/WiFiConfigComponent.js @@ -285,7 +285,7 @@ class WiFiConfigComponent extends Component { async configureNodeWiFi(node, ssid, password) { logger.debug('WiFiConfigComponent: Configuring WiFi for node:', node.ip); - + const response = await window.apiClient.callEndpoint({ ip: node.ip, method: 'POST', @@ -295,11 +295,13 @@ class WiFiConfigComponent extends Component { { name: 'password', value: password, location: 'body' } ] }); - - if (!response.success) { - throw new Error(response.error || 'Failed to configure WiFi'); + + // Check if the API call was successful based on the response structure + if (!response || response.status !== 200) { + const errorMessage = response?.data?.message || response?.error || 'Failed to configure WiFi'; + throw new Error(errorMessage); } - + return response; } -- 2.49.1 From 6ed42f9c903723fc332c1b8f62a2cfc0900a5b75 Mon Sep 17 00:00:00 2001 From: 0x1d Date: Tue, 21 Oct 2025 13:00:02 +0200 Subject: [PATCH 3/4] feat: update firmware upload status through websocket --- public/scripts/api-client.js | 3 + .../scripts/components/FirmwareComponent.js | 323 +++++++++++++++--- .../components/FirmwareUploadComponent.js | 308 ++++++++++++++--- 3 files changed, 529 insertions(+), 105 deletions(-) diff --git a/public/scripts/api-client.js b/public/scripts/api-client.js index 1fc9034..fa0a458 100644 --- a/public/scripts/api-client.js +++ b/public/scripts/api-client.js @@ -225,6 +225,9 @@ class WebSocketClient { 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); } diff --git a/public/scripts/components/FirmwareComponent.js b/public/scripts/components/FirmwareComponent.js index 535578c..2352603 100644 --- a/public/scripts/components/FirmwareComponent.js +++ b/public/scripts/components/FirmwareComponent.js @@ -27,12 +27,15 @@ class FirmwareComponent extends Component { if (globalFirmwareFile) { this.addEventListener(globalFirmwareFile, 'change', this.handleFileSelect.bind(this)); } - + // Setup target selection const targetRadios = this.findAllElements('input[name="target-type"]'); targetRadios.forEach(radio => { this.addEventListener(radio, 'change', this.handleTargetChange.bind(this)); }); + + // Setup WebSocket listener for real-time firmware upload status + this.setupWebSocketListeners(); // Setup specific node select change handler const specificNodeSelect = this.findElement('#specific-node-select'); @@ -258,8 +261,10 @@ class FirmwareComponent extends Component { await this.uploadToLabelFilteredNodes(file); } - // Reset interface after successful upload - this.viewModel.resetUploadState(); + // NOTE: Don't reset upload state here! + // The upload state should remain active until websocket confirms completion + // Status updates and finalization happen via websocket messages in checkAndFinalizeUploadResults() + logger.debug('Firmware upload HTTP requests completed, waiting for websocket status updates'); } catch (error) { logger.error('Firmware deployment failed:', error); @@ -271,7 +276,7 @@ class FirmwareComponent extends Component { onConfirm: () => {}, onCancel: null }); - } finally { + // Only complete upload on error this.viewModel.completeUpload(); } } @@ -300,8 +305,8 @@ class FirmwareComponent extends Component { // Start batch upload const results = await this.performBatchUpload(file, nodes); - // Display results - this.displayUploadResults(results); + // Don't display results here - wait for websocket to confirm all uploads complete + logger.debug('Batch upload HTTP requests completed, waiting for websocket confirmations'); } catch (error) { logger.error('Failed to upload firmware to all nodes:', error); @@ -313,27 +318,23 @@ class FirmwareComponent extends Component { try { // Show upload progress area this.showUploadProgress(file, [{ ip: nodeIp, hostname: nodeIp }]); - - // Update progress to show starting - this.updateNodeProgress(1, 1, nodeIp, 'Uploading...'); - - // Perform single node upload + + // Note: Status updates will come via websocket messages + // We don't update progress here as the HTTP response is just an acknowledgment + + // Perform single node upload (this sends the file and gets acknowledgment) const result = await this.performSingleUpload(file, nodeIp); - - // Update progress to show completion - this.updateNodeProgress(1, 1, nodeIp, 'Completed'); - this.updateOverallProgress(1, 1); - - // Display results - this.displayUploadResults([result]); - + + // Don't immediately mark as completed - wait for websocket status updates + logger.debug(`Firmware upload initiated for node ${nodeIp}, waiting for completion status via websocket`); + } catch (error) { logger.error(`Failed to upload firmware to node ${nodeIp}:`, error); - - // Update progress to show failure + + // For HTTP errors, we can immediately mark as failed since the upload didn't start this.updateNodeProgress(1, 1, nodeIp, 'Failed'); this.updateOverallProgress(0, 1); - + // Display error results const errorResult = { nodeIp: nodeIp, @@ -343,7 +344,7 @@ class FirmwareComponent extends Component { timestamp: new Date().toISOString() }; this.displayUploadResults([errorResult]); - + throw error; } } @@ -369,8 +370,8 @@ class FirmwareComponent extends Component { // Start batch upload const results = await this.performBatchUpload(file, nodes); - // Display results - this.displayUploadResults(results); + // Don't display results here - wait for websocket to confirm all uploads complete + logger.debug('Label-filtered upload HTTP requests completed, waiting for websocket confirmations'); } catch (error) { logger.error('Failed to upload firmware to label-filtered nodes:', error); throw error; @@ -381,24 +382,26 @@ class FirmwareComponent extends Component { const results = []; const totalNodes = nodes.length; let successfulUploads = 0; - + + // Initialize all nodes as uploading first for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; const nodeIp = node.ip; - + this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Uploading...'); + } + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const nodeIp = node.ip; + try { - // Update progress - this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Uploading...'); - - // Upload to this node + // Upload to this node (HTTP call just initiates the upload) const result = await this.performSingleUpload(file, nodeIp); + + // Don't immediately mark as completed - wait for websocket status + logger.debug(`Firmware upload initiated for node ${nodeIp}, waiting for completion status via websocket`); results.push(result); - successfulUploads++; - - // Update progress - this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Completed'); - this.updateOverallProgress(successfulUploads, totalNodes); - + } catch (error) { logger.error(`Failed to upload to node ${nodeIp}:`, error); const errorResult = { @@ -409,18 +412,17 @@ class FirmwareComponent extends Component { timestamp: new Date().toISOString() }; results.push(errorResult); - - // Update progress + + // For HTTP errors, we can immediately mark as failed since the upload didn't start this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Failed'); - this.updateOverallProgress(successfulUploads, totalNodes); } - + // Small delay between uploads if (i < nodes.length - 1) { await new Promise(resolve => setTimeout(resolve, 1000)); } } - + return results; } @@ -428,10 +430,16 @@ class FirmwareComponent extends Component { try { const result = await window.apiClient.uploadFirmware(file, nodeIp); + // IMPORTANT: This HTTP response is just an acknowledgment that the gateway received the file + // The actual firmware processing happens asynchronously on the device + // Status updates will come via WebSocket messages, NOT from this HTTP response + logger.debug(`HTTP acknowledgment received for ${nodeIp}:`, result); + logger.debug(`This does NOT mean upload is complete - waiting for WebSocket status updates`); + return { nodeIp: nodeIp, hostname: nodeIp, - success: true, + httpAcknowledged: true, // Changed from 'success' to make it clear this is just HTTP ack result: result, timestamp: new Date().toISOString() }; @@ -467,7 +475,7 @@ class FirmwareComponent extends Component { 0/${nodes.length} Successful (0%)
- Status: Preparing upload... + Status: Upload in progress...
@@ -477,7 +485,7 @@ class FirmwareComponent extends Component { ${node.hostname || node.ip} ${node.ip}
-
Pending...
+
Uploading...
`).join('')} @@ -490,7 +498,7 @@ class FirmwareComponent extends Component { // Initialize progress for single-node uploads if (nodes.length === 1) { const node = nodes[0]; - this.updateNodeProgress(1, 1, node.ip, 'Pending...'); + this.updateNodeProgress(1, 1, node.ip, 'Uploading...'); } } @@ -543,15 +551,9 @@ class FirmwareComponent extends Component { progressBar.style.backgroundColor = '#fbbf24'; } - // Update progress summary for single-node uploads - const progressSummary = this.findElement('#progress-summary'); - if (progressSummary && totalNodes === 1) { - if (successfulUploads === 1) { - progressSummary.innerHTML = 'Status: Upload completed successfully'; - } else if (successfulUploads === 0) { - progressSummary.innerHTML = 'Status: Upload failed'; - } - } + // NOTE: Don't update progress summary here for single-node uploads + // The summary should only be updated via websocket status updates + // This prevents premature "completed successfully" messages } } @@ -635,6 +637,217 @@ class FirmwareComponent extends Component { } } + setupWebSocketListeners() { + // Listen for real-time firmware upload status updates + window.wsClient.on('firmwareUploadStatus', (data) => { + this.handleFirmwareUploadStatus(data); + }); + } + + handleFirmwareUploadStatus(data) { + const { nodeIp, status, filename, fileSize, timestamp } = data; + + logger.debug('Firmware upload status received:', { nodeIp, status, filename, timestamp: new Date(timestamp).toLocaleTimeString() }); + + // Check if there's currently an upload in progress + const isUploading = this.viewModel.get('isUploading'); + if (!isUploading) { + logger.debug('No active upload, ignoring status update'); + return; + } + + // Find the progress item for this node + const progressItem = this.findElement(`[data-node-ip="${nodeIp}"]`); + if (!progressItem) { + logger.debug('No progress item found for node:', nodeIp); + return; + } + + // Update the status display based on the received status + const statusElement = progressItem.querySelector('.progress-status'); + const timeElement = progressItem.querySelector('.progress-time'); + + if (statusElement) { + let displayStatus = status; + let statusClass = ''; + + logger.debug(`Updating status for node ${nodeIp}: ${status} -> ${displayStatus}`); + + switch (status) { + case 'uploading': + displayStatus = 'Uploading...'; + statusClass = 'uploading'; + break; + case 'completed': + displayStatus = 'Completed'; + statusClass = 'success'; + logger.debug(`Node ${nodeIp} marked as completed`); + break; + case 'failed': + displayStatus = 'Failed'; + statusClass = 'error'; + break; + default: + displayStatus = status; + break; + } + + statusElement.textContent = displayStatus; + statusElement.className = `progress-status ${statusClass}`; + + // Update timestamp for completed/failed uploads + if ((status === 'completed' || status === 'failed') && timeElement) { + timeElement.textContent = new Date(timestamp).toLocaleTimeString(); + } else if (status === 'uploading' && timeElement) { + timeElement.textContent = 'Started: ' + new Date(timestamp).toLocaleTimeString(); + } + } + + // Update overall progress if we have multiple nodes + this.updateOverallProgressFromStatus(); + + // Check if all uploads are complete and finalize results + this.checkAndFinalizeUploadResults(); + } + + checkAndFinalizeUploadResults() { + const progressItems = this.findAllElements('.progress-item'); + if (progressItems.length === 0) return; + + // Check if all uploads are complete (either completed or failed) + let allComplete = true; + let hasAnyCompleted = false; + let hasAnyFailed = false; + let uploadingCount = 0; + + const statuses = []; + progressItems.forEach(item => { + const statusElement = item.querySelector('.progress-status'); + if (statusElement) { + const status = statusElement.textContent; + statuses.push(status); + + if (status !== 'Completed' && status !== 'Failed') { + allComplete = false; + if (status === 'Uploading...') { + uploadingCount++; + } + } + if (status === 'Completed') { + hasAnyCompleted = true; + } + if (status === 'Failed') { + hasAnyFailed = true; + } + } + }); + + logger.debug('Upload status check:', { + totalItems: progressItems.length, + allComplete, + uploadingCount, + hasAnyCompleted, + hasAnyFailed, + statuses + }); + + // If all uploads are complete, finalize the results + if (allComplete) { + logger.debug('All firmware uploads complete, finalizing results'); + + // Generate results based on current status + const results = progressItems.map(item => { + const nodeIp = item.getAttribute('data-node-ip'); + const nodeName = item.querySelector('.node-name')?.textContent || nodeIp; + const statusElement = item.querySelector('.progress-status'); + const status = statusElement?.textContent || 'Unknown'; + + return { + nodeIp: nodeIp, + hostname: nodeName, + success: status === 'Completed', + error: status === 'Failed' ? 'Upload failed' : undefined, + timestamp: new Date().toISOString() + }; + }); + + // Update the header and summary to show final results + this.displayUploadResults(results); + + // Now that all uploads are truly complete (confirmed via websocket), mark upload as complete + this.viewModel.completeUpload(); + + // Reset upload state after a short delay to allow user to see results + setTimeout(() => { + this.viewModel.resetUploadState(); + }, 5000); + } else if (uploadingCount > 0) { + logger.debug(`${uploadingCount} uploads still in progress, not finalizing yet`); + } else { + logger.debug('Some uploads may have unknown status, but not finalizing yet'); + } + } + + updateOverallProgressFromStatus() { + const progressItems = this.findAllElements('.progress-item'); + if (progressItems.length <= 1) { + return; // Only update for multi-node uploads + } + + let completedCount = 0; + let failedCount = 0; + let uploadingCount = 0; + + progressItems.forEach(item => { + const statusElement = item.querySelector('.progress-status'); + if (statusElement) { + const status = statusElement.textContent; + if (status === 'Completed') { + completedCount++; + } else if (status === 'Failed') { + failedCount++; + } else if (status === 'Uploading...') { + uploadingCount++; + } + } + }); + + const totalNodes = progressItems.length; + const successfulUploads = completedCount; + const successPercentage = Math.round((successfulUploads / totalNodes) * 100); + + // Update overall progress bar + const progressBar = this.findElement('#overall-progress-bar'); + const progressText = this.findElement('.progress-text'); + + if (progressBar && progressText) { + progressBar.style.width = `${successPercentage}%`; + + // Update progress bar color based on completion + if (successPercentage === 100) { + progressBar.style.backgroundColor = '#4ade80'; + } else if (successPercentage > 50) { + progressBar.style.backgroundColor = '#60a5fa'; + } else { + progressBar.style.backgroundColor = '#fbbf24'; + } + + progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`; + } + + // Update progress summary + const progressSummary = this.findElement('#progress-summary'); + if (progressSummary) { + if (failedCount > 0) { + progressSummary.innerHTML = `${window.icon('warning', { width: 14, height: 14 })} Upload in progress... (${uploadingCount} uploading, ${failedCount} failed)`; + } else if (uploadingCount > 0) { + progressSummary.innerHTML = `${window.icon('info', { width: 14, height: 14 })} Upload in progress... (${uploadingCount} uploading)`; + } else if (completedCount === totalNodes) { + progressSummary.innerHTML = `${window.icon('success', { width: 14, height: 14 })} All uploads completed successfully at ${new Date().toLocaleTimeString()}`; + } + } + } + populateNodeSelect() { const select = this.findElement('#specific-node-select'); if (!select) { diff --git a/public/scripts/components/FirmwareUploadComponent.js b/public/scripts/components/FirmwareUploadComponent.js index 05d3859..6467077 100644 --- a/public/scripts/components/FirmwareUploadComponent.js +++ b/public/scripts/components/FirmwareUploadComponent.js @@ -17,12 +17,15 @@ class FirmwareUploadComponent extends Component { if (firmwareFile) { this.addEventListener(firmwareFile, 'change', this.handleFileSelect.bind(this)); } - + // Setup deploy button const deployBtn = this.findElement('#deploy-btn'); if (deployBtn) { this.addEventListener(deployBtn, 'click', this.handleDeploy.bind(this)); } + + // Setup WebSocket listener for real-time firmware upload status + this.setupWebSocketListeners(); } setupViewModelListeners() { @@ -35,6 +38,213 @@ class FirmwareUploadComponent extends Component { this.subscribeToProperty('uploadResults', this.updateUploadResults.bind(this)); } + setupWebSocketListeners() { + // Listen for real-time firmware upload status updates + window.wsClient.on('firmwareUploadStatus', (data) => { + this.handleFirmwareUploadStatus(data); + }); + } + + handleFirmwareUploadStatus(data) { + const { nodeIp, status, filename, fileSize, timestamp } = data; + + logger.debug('FirmwareUploadComponent: Firmware upload status received:', { nodeIp, status, filename }); + + // Check if there's currently an upload in progress + const isUploading = this.viewModel.get('isUploading'); + if (!isUploading) { + logger.debug('FirmwareUploadComponent: No active upload, ignoring status update'); + return; + } + + // Find the target node item for this node + const targetNodeItem = this.findElement(`[data-node-ip="${nodeIp}"]`); + if (!targetNodeItem) { + logger.debug('FirmwareUploadComponent: No target node item found for node:', nodeIp); + return; + } + + // Update the status display based on the received status + const statusElement = targetNodeItem.querySelector('.status-indicator'); + + if (statusElement) { + let displayStatus = status; + let statusClass = ''; + + logger.debug(`FirmwareUploadComponent: Updating status for node ${nodeIp}: ${status} -> ${displayStatus}`); + + switch (status) { + case 'uploading': + displayStatus = 'Uploading...'; + statusClass = 'uploading'; + break; + case 'completed': + displayStatus = 'Completed'; + statusClass = 'success'; + logger.debug(`FirmwareUploadComponent: Node ${nodeIp} marked as completed`); + break; + case 'failed': + displayStatus = 'Failed'; + statusClass = 'error'; + break; + default: + displayStatus = status; + break; + } + + statusElement.textContent = displayStatus; + statusElement.className = `status-indicator ${statusClass}`; + } + + // Update overall progress if we have multiple nodes + this.updateOverallProgressFromStatus(); + + // Check if all uploads are complete and finalize results + this.checkAndFinalizeUploadResults(); + } + + updateOverallProgressFromStatus() { + const targetNodeItems = Array.from(this.findAllElements('.target-node-item')); + if (targetNodeItems.length <= 1) { + return; // Only update for multi-node uploads + } + + let completedCount = 0; + let failedCount = 0; + let uploadingCount = 0; + + targetNodeItems.forEach(item => { + const statusElement = item.querySelector('.status-indicator'); + if (statusElement) { + const status = statusElement.textContent; + if (status === 'Completed') { + completedCount++; + } else if (status === 'Failed') { + failedCount++; + } else if (status === 'Uploading...') { + uploadingCount++; + } + } + }); + + const totalNodes = targetNodeItems.length; + const successfulUploads = completedCount; + const successPercentage = Math.round((successfulUploads / totalNodes) * 100); + + // Update overall progress bar + const progressBar = this.findElement('#overall-progress-bar'); + const progressText = this.findElement('.progress-text'); + + if (progressBar && progressText) { + progressBar.style.width = `${successPercentage}%`; + + // Update progress bar color based on completion + if (successPercentage === 100) { + progressBar.style.backgroundColor = '#4ade80'; + } else if (successPercentage > 50) { + progressBar.style.backgroundColor = '#60a5fa'; + } else { + progressBar.style.backgroundColor = '#fbbf24'; + } + + progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`; + } + + // Update progress summary + const progressSummary = this.findElement('#progress-summary'); + if (progressSummary) { + if (failedCount > 0) { + progressSummary.innerHTML = `${window.icon('warning', { width: 14, height: 14 })} Upload in progress... (${uploadingCount} uploading, ${failedCount} failed)`; + } else if (uploadingCount > 0) { + progressSummary.innerHTML = `${window.icon('info', { width: 14, height: 14 })} Upload in progress... (${uploadingCount} uploading)`; + } else if (completedCount === totalNodes) { + progressSummary.innerHTML = `${window.icon('success', { width: 14, height: 14 })} All uploads completed successfully at ${new Date().toLocaleTimeString()}`; + } + } + } + + checkAndFinalizeUploadResults() { + const targetNodeItems = Array.from(this.findAllElements('.target-node-item')); + if (targetNodeItems.length === 0) return; + + // Check if all uploads are complete (either completed or failed) + let allComplete = true; + let hasAnyCompleted = false; + let hasAnyFailed = false; + let uploadingCount = 0; + + const statuses = []; + targetNodeItems.forEach(item => { + const statusElement = item.querySelector('.status-indicator'); + if (statusElement) { + const status = statusElement.textContent; + statuses.push(status); + + if (status !== 'Completed' && status !== 'Failed') { + allComplete = false; + if (status === 'Uploading...') { + uploadingCount++; + } + } + if (status === 'Completed') { + hasAnyCompleted = true; + } + if (status === 'Failed') { + hasAnyFailed = true; + } + } + }); + + logger.debug('FirmwareUploadComponent: Upload status check:', { + totalItems: targetNodeItems.length, + allComplete, + uploadingCount, + hasAnyCompleted, + hasAnyFailed, + statuses + }); + + // If all uploads are complete, finalize the results + if (allComplete) { + logger.debug('FirmwareUploadComponent: All firmware uploads complete, finalizing results'); + + // Generate results based on current status + const results = targetNodeItems.map(item => { + const nodeIp = item.getAttribute('data-node-ip'); + const nodeName = item.querySelector('.node-name')?.textContent || nodeIp; + const statusElement = item.querySelector('.status-indicator'); + const status = statusElement?.textContent || 'Unknown'; + + return { + nodeIp: nodeIp, + hostname: nodeName, + success: status === 'Completed', + error: status === 'Failed' ? 'Upload failed' : undefined, + timestamp: new Date().toISOString() + }; + }); + + // Update the header and summary to show final results + this.displayUploadResults(results); + + // Hide the progress overlay since upload is complete + this.hideProgressOverlay(); + + // Now that all uploads are truly complete (confirmed via websocket), mark upload as complete + this.viewModel.completeUpload(); + + // Reset upload state after a short delay to allow user to see results and re-enable deploy button + setTimeout(() => { + this.viewModel.resetUploadState(); + logger.debug('FirmwareUploadComponent: Upload state reset, deploy button should be re-enabled'); + }, 5000); + } else if (uploadingCount > 0) { + logger.debug(`FirmwareUploadComponent: ${uploadingCount} uploads still in progress, not finalizing yet`); + } else { + logger.debug('FirmwareUploadComponent: Some uploads may have unknown status, but not finalizing yet'); + } + } + mount() { super.mount(); @@ -135,24 +345,23 @@ class FirmwareUploadComponent extends Component { async performDeployment(file, targetNodes) { try { this.viewModel.startUpload(); - + // Show progress overlay to block UI interactions this.showProgressOverlay(); - + // Show upload progress area this.showUploadProgress(file, targetNodes); - + // Start batch upload const results = await this.performBatchUpload(file, targetNodes); - - // Display results - this.displayUploadResults(results); - - // Reset interface after successful upload - this.viewModel.resetUploadState(); - + + // NOTE: Don't display results or reset state here! + // The upload state should remain active until websocket confirms completion + // Status updates and finalization happen via websocket messages in checkAndFinalizeUploadResults() + logger.debug('FirmwareUploadComponent: Firmware upload HTTP requests completed, waiting for websocket status updates'); + } catch (error) { - logger.error('Firmware deployment failed:', error); + logger.error('FirmwareUploadComponent: Firmware deployment failed:', error); this.showConfirmationDialog({ title: 'Deployment Failed', message: `Deployment failed: ${error.message}`, @@ -161,7 +370,7 @@ class FirmwareUploadComponent extends Component { onConfirm: () => {}, onCancel: null }); - } finally { + // Only complete upload on error this.viewModel.completeUpload(); this.hideProgressOverlay(); } @@ -171,26 +380,28 @@ class FirmwareUploadComponent extends Component { const results = []; const totalNodes = nodes.length; let successfulUploads = 0; - + + // Initialize all nodes as uploading first for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; const nodeIp = node.ip; - + this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Uploading...'); + } + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const nodeIp = node.ip; + try { - // Update progress - this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Uploading...'); - - // Upload to this node + // Upload to this node (HTTP call just initiates the upload) const result = await this.performSingleUpload(file, nodeIp); + + // Don't immediately mark as completed - wait for websocket status + logger.debug(`FirmwareUploadComponent: Firmware upload initiated for node ${nodeIp}, waiting for completion status via websocket`); results.push(result); - successfulUploads++; - - // Update progress - this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Completed'); - this.updateOverallProgress(successfulUploads, totalNodes); - + } catch (error) { - logger.error(`Failed to upload to node ${nodeIp}:`, error); + logger.error(`FirmwareUploadComponent: Failed to upload to node ${nodeIp}:`, error); const errorResult = { nodeIp: nodeIp, hostname: node.hostname || nodeIp, @@ -199,33 +410,38 @@ class FirmwareUploadComponent extends Component { timestamp: new Date().toISOString() }; results.push(errorResult); - - // Update progress + + // For HTTP errors, we can immediately mark as failed since the upload didn't start this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Failed'); - this.updateOverallProgress(successfulUploads, totalNodes); } - + // Small delay between uploads if (i < nodes.length - 1) { await new Promise(resolve => setTimeout(resolve, 1000)); } } - + return results; } async performSingleUpload(file, nodeIp) { try { const result = await window.apiClient.uploadFirmware(file, nodeIp); - + + // IMPORTANT: This HTTP response is just an acknowledgment that the gateway received the file + // The actual firmware processing happens asynchronously on the device + // Status updates will come via WebSocket messages, NOT from this HTTP response + logger.debug(`FirmwareUploadComponent: HTTP acknowledgment received for ${nodeIp}:`, result); + logger.debug(`FirmwareUploadComponent: This does NOT mean upload is complete - waiting for WebSocket status updates`); + return { nodeIp: nodeIp, hostname: nodeIp, - success: true, + httpAcknowledged: true, // Changed from 'success' to make it clear this is just HTTP ack result: result, timestamp: new Date().toISOString() }; - + } catch (error) { throw new Error(`Upload to ${nodeIp} failed: ${error.message}`); } @@ -260,7 +476,7 @@ class FirmwareUploadComponent extends Component { 0/${nodes.length} Successful (0%)
- Status: Preparing upload... + Status: Upload in progress...
`; @@ -283,7 +499,7 @@ class FirmwareUploadComponent extends Component { ${node.ip}
- Pending... + Uploading...
`).join(''); @@ -315,12 +531,12 @@ class FirmwareUploadComponent extends Component { updateOverallProgress(successfulUploads, totalNodes) { const progressBar = this.findElement('#overall-progress-bar'); const progressText = this.findElement('.progress-text'); - + if (progressBar && progressText) { const successPercentage = Math.round((successfulUploads / totalNodes) * 100); progressBar.style.width = `${successPercentage}%`; progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`; - + // Update progress bar color based on completion if (successPercentage === 100) { progressBar.style.backgroundColor = '#4ade80'; @@ -329,16 +545,10 @@ class FirmwareUploadComponent extends Component { } else { progressBar.style.backgroundColor = '#fbbf24'; } - - // Update progress summary for single-node uploads - const progressSummary = this.findElement('#progress-summary'); - if (progressSummary && totalNodes === 1) { - if (successfulUploads === 1) { - progressSummary.innerHTML = 'Status: Upload completed successfully'; - } else if (successfulUploads === 0) { - progressSummary.innerHTML = 'Status: Upload failed'; - } - } + + // NOTE: Don't update progress summary here for single-node uploads + // The summary should only be updated via websocket status updates + // This prevents premature "completed successfully" messages } } @@ -426,8 +636,6 @@ class FirmwareUploadComponent extends Component { `; } } - - this.updateDeployButton(); } updateUploadProgress() { -- 2.49.1 From 30d88d68843d7f514ea407ed4d753c74aaac9b3a Mon Sep 17 00:00:00 2001 From: 0x1d Date: Tue, 21 Oct 2025 13:14:36 +0200 Subject: [PATCH 4/4] feat: remove borders and shadows on the theme-switcher --- public/styles/theme.css | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/public/styles/theme.css b/public/styles/theme.css index db8b4a2..7ad138b 100644 --- a/public/styles/theme.css +++ b/public/styles/theme.css @@ -69,20 +69,11 @@ display: flex; align-items: center; gap: 0.5rem; - background: var(--bg-tertiary); - border: 1px solid var(--border-primary); border-radius: 12px; padding: 0.5rem; - backdrop-filter: var(--backdrop-blur); transition: all 0.3s ease; } -.theme-switcher:hover { - background: var(--bg-hover); - border-color: var(--border-hover); - box-shadow: var(--shadow-secondary); -} - .theme-toggle { background: none; border: none; @@ -98,9 +89,8 @@ } .theme-toggle:hover { - background: var(--bg-hover); color: var(--text-primary); - transform: scale(1.05); + transform: scale(1.1); } .theme-toggle:active { @@ -232,16 +222,9 @@ } [data-theme="light"] .theme-switcher { - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(20px); - border: 1px solid rgba(148, 163, 184, 0.25); - box-shadow: 0 4px 16px rgba(148, 163, 184, 0.08); } [data-theme="light"] .theme-switcher:hover { - background: rgba(255, 255, 255, 0.15); - border: 1px solid rgba(148, 163, 184, 0.35); - box-shadow: 0 6px 20px rgba(148, 163, 184, 0.12); } [data-theme="light"] .view-content { -- 2.49.1