From 5977a37d6cc24bf5db634d19cd97e0a37b1eb00d Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Mon, 25 Aug 2025 12:05:51 +0200 Subject: [PATCH] feat: auto-discovery --- README.md | 14 ++++ index.js | 72 ++++++++++++++++++ package.json | 6 +- public/index.html | 13 +++- public/script.js | 129 ++++++++++++++++++++++++++++++++ public/styles.css | 82 ++++++++++++++++++++ test/demo-frontend.js | 102 +++++++++++++++++++++++++ test/test-random-selection.js | 137 ++++++++++++++++++++++++++++++++++ 8 files changed, 552 insertions(+), 3 deletions(-) create mode 100644 test/demo-frontend.js create mode 100644 test/test-random-selection.js diff --git a/README.md b/README.md index 3f7b673..34e180f 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ The backend now includes automatic UDP discovery for SPORE nodes on the network. - `GET /api/discovery/nodes` - View all discovered nodes and current status - `POST /api/discovery/refresh` - Manually trigger discovery refresh - `POST /api/discovery/primary/:ip` - Manually set a specific node as primary +- `POST /api/discovery/random-primary` - Randomly select a new primary node - `GET /api/health` - Health check including discovery status ### Testing Discovery @@ -86,8 +87,21 @@ 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 ``` +### Frontend Features + +The frontend now includes: + +- **Primary Node Display**: Shows the current primary node IP in the cluster header +- **Random Selection**: Click the šŸŽ² button to randomly select a new primary node +- **Real-time Updates**: Primary node display updates automatically every 10 seconds +- **Visual Status Indicators**: Different colors and icons show node status +- **Manual Refresh**: Users can manually trigger discovery updates + ### Node Behavior - Nodes should send `CLUSTER_DISCOVERY` messages periodically (recommended: every 30-60 seconds) diff --git a/index.js b/index.js index 9f88dee..3c4f934 100644 --- a/index.js +++ b/index.js @@ -7,6 +7,10 @@ const SporeApiClient = require('./src/client'); const app = express(); const PORT = process.env.PORT || 3001; +// Middleware +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + // UDP discovery configuration const UDP_PORT = 4210; const DISCOVERY_MESSAGE = 'CLUSTER_DISCOVERY'; @@ -149,6 +153,31 @@ function selectBestPrimaryNode() { 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}`); + + return randomNode; +} + // Initialize client when a node is discovered function updateSporeClient() { const nodeIp = selectBestPrimaryNode(); @@ -221,6 +250,49 @@ app.post('/api/discovery/refresh', (req, res) => { } }); +// 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 { diff --git a/package.json b/package.json index 47040d1..a08de7b 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,10 @@ "start": "node index.js", "dev": "node index.js", "client-example": "node src/client/example.js", - "test-discovery": "node test-discovery.js", - "demo-discovery": "node demo-discovery.js", + "test-discovery": "node test/test-discovery.js", + "demo-discovery": "node test/demo-discovery.js", + "demo-frontend": "node test/demo-frontend.js", + "test-random-selection": "node test/test-random-selection.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/public/index.html b/public/index.html index 325fc75..c7c6ea1 100644 --- a/public/index.html +++ b/public/index.html @@ -21,7 +21,18 @@
-
+
+
+ Primary Node: + Discovering... + +
+
`; + + // Still try to update primary node display even if cluster fails + await updatePrimaryNodeDisplay(); } } @@ -533,8 +658,12 @@ function displayClusterMembers(members, expandedCards = new Map()) { // Load cluster members when page loads document.addEventListener('DOMContentLoaded', function() { refreshClusterMembers(); + updatePrimaryNodeDisplay(); // Also update primary node display setupNavigation(); setupFirmwareView(); + + // Set up periodic primary node updates (every 10 seconds) + setInterval(updatePrimaryNodeDisplay, 10000); }); // Auto-refresh every 30 seconds diff --git a/public/styles.css b/public/styles.css index e91ac37..3792669 100644 --- a/public/styles.css +++ b/public/styles.css @@ -48,6 +48,88 @@ p { /* Placeholder for future content if needed */ } +.primary-node-info { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); +} + +.primary-node-label { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.7); + font-weight: 500; +} + +.primary-node-ip { + font-size: 0.9rem; + color: #4ade80; + font-weight: 600; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + padding: 0.25rem 0.5rem; + background: rgba(74, 222, 128, 0.1); + border-radius: 4px; + border: 1px solid rgba(74, 222, 128, 0.2); +} + +.primary-node-ip.discovering { + color: #fbbf24; + background: rgba(251, 191, 36, 0.1); + border-color: rgba(251, 191, 36, 0.2); +} + +.primary-node-ip.error { + color: #f87171; + background: rgba(248, 113, 113, 0.1); + border-color: rgba(248, 113, 113, 0.2); + margin: 0; + padding: 0.25rem 0.5rem; +} + +.primary-node-ip.selecting { + color: #8b5cf6; + background: rgba(139, 92, 246, 0.1); + border-color: rgba(139, 92, 246, 0.2); + animation: pulse 1.5s ease-in-out infinite alternate; +} + +@keyframes pulse { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0.7; + transform: scale(1.05); + } +} + +.primary-node-refresh { + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + padding: 0.25rem; + border-radius: 4px; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.primary-node-refresh:hover { + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.1); +} + +.primary-node-refresh:active { + transform: scale(0.95); +} + .firmware-header { display: flex; justify-content: space-between; diff --git a/test/demo-frontend.js b/test/demo-frontend.js new file mode 100644 index 0000000..2a67ac0 --- /dev/null +++ b/test/demo-frontend.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +/** + * Demo script for Frontend Discovery Integration + * Shows how the frontend displays primary node information + */ + +const http = require('http'); + +const BASE_URL = 'http://localhost:3001'; + +function makeRequest(path, method = 'GET') { + return new Promise((resolve, reject) => { + const options = { + hostname: 'localhost', + port: 3001, + path: path, + method: method, + headers: { + 'Content-Type': 'application/json' + } + }; + + const req = http.request(options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + try { + const jsonData = JSON.parse(data); + resolve({ status: res.statusCode, data: jsonData }); + } catch (error) { + resolve({ status: res.statusCode, data: data }); + } + }); + }); + + req.on('error', (error) => { + reject(error); + }); + + req.end(); + }); +} + +async function showFrontendIntegration() { + console.log('šŸš€ Frontend Discovery Integration Demo'); + console.log('====================================='); + console.log('This demo shows how the frontend displays primary node information.'); + console.log('Open http://localhost:3001 in your browser to see the UI.'); + console.log(''); + + try { + // Check if backend is running + const healthResponse = await makeRequest('/api/health'); + console.log('āœ… Backend is running'); + + // Get discovery information + const discoveryResponse = await makeRequest('/api/discovery/nodes'); + console.log('\nšŸ“” Discovery Status:'); + console.log(` Primary Node: ${discoveryResponse.data.primaryNode || 'None'}`); + console.log(` Total Nodes: ${discoveryResponse.data.totalNodes}`); + console.log(` Client Initialized: ${discoveryResponse.data.clientInitialized}`); + + if (discoveryResponse.data.nodes.length > 0) { + console.log('\n🌐 Discovered Nodes:'); + discoveryResponse.data.nodes.forEach((node, index) => { + console.log(` ${index + 1}. ${node.ip}:${node.port} (${node.isPrimary ? 'PRIMARY' : 'secondary'})`); + console.log(` Last Seen: ${node.lastSeen}`); + }); + } + + console.log('\nšŸŽÆ Frontend Display:'); + console.log(' The frontend will show:'); + if (discoveryResponse.data.primaryNode) { + const status = discoveryResponse.data.clientInitialized ? 'āœ…' : 'āš ļø'; + const nodeCount = discoveryResponse.data.totalNodes > 1 ? ` (${discoveryResponse.data.totalNodes} nodes)` : ''; + console.log(` ${status} ${discoveryResponse.data.primaryNode}${nodeCount}`); + } else if (discoveryResponse.data.totalNodes > 0) { + const firstNode = discoveryResponse.data.nodes[0]; + console.log(` āš ļø ${firstNode.ip} (No Primary)`); + } else { + console.log(' šŸ” No Nodes Found'); + } + + console.log('\nšŸ’” To test the frontend:'); + console.log(' 1. Open http://localhost:3001 in your browser'); + console.log(' 2. Look at the cluster header for primary node info'); + console.log(' 3. Send discovery messages: npm run test-discovery broadcast'); + console.log(' 4. Watch the primary node display update in real-time'); + + } catch (error) { + console.error('āŒ Error:', error.message); + console.log('\nšŸ’” Make sure the backend is running: npm start'); + } +} + +// Run the demo +showFrontendIntegration().catch(console.error); \ No newline at end of file diff --git a/test/test-random-selection.js b/test/test-random-selection.js new file mode 100644 index 0000000..c0f2b6c --- /dev/null +++ b/test/test-random-selection.js @@ -0,0 +1,137 @@ +#!/usr/bin/env node + +/** + * Test script for Random Primary Node Selection + * Demonstrates how the random selection works + */ + +const http = require('http'); + +const BASE_URL = 'http://localhost:3001'; + +function makeRequest(path, method = 'POST', body = null) { + return new Promise((resolve, reject) => { + const options = { + hostname: 'localhost', + port: 3001, + path: path, + method: method, + headers: { + 'Content-Type': 'application/json' + } + }; + + const req = http.request(options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + try { + const jsonData = JSON.parse(data); + resolve({ status: res.statusCode, data: jsonData }); + } catch (error) { + resolve({ status: res.statusCode, data: data }); + } + }); + }); + + req.on('error', (error) => { + reject(error); + }); + + if (body) { + req.write(JSON.stringify(body)); + } + + req.end(); + }); +} + +async function testRandomSelection() { + console.log('šŸŽ² Testing Random Primary Node Selection'); + console.log('======================================'); + console.log(''); + + try { + // First, check current discovery status + console.log('1. Checking current discovery status...'); + const discoveryResponse = await makeRequest('/api/discovery/nodes', 'GET'); + + if (discoveryResponse.status !== 200) { + console.log('āŒ Failed to get discovery status'); + return; + } + + const discovery = discoveryResponse.data; + console.log(` Current Primary: ${discovery.primaryNode || 'None'}`); + console.log(` Total Nodes: ${discovery.totalNodes}`); + console.log(` Client Initialized: ${discovery.clientInitialized}`); + + if (discovery.nodes.length === 0) { + console.log('\nšŸ’” No nodes discovered yet. Send some discovery messages first:'); + console.log(' npm run test-discovery broadcast'); + return; + } + + console.log('\n2. Testing random primary node selection...'); + + // Store current primary for comparison + const currentPrimary = discovery.primaryNode; + const availableNodes = discovery.nodes.map(n => n.ip); + + console.log(` Available nodes: ${availableNodes.join(', ')}`); + console.log(` Current primary: ${currentPrimary}`); + + // Perform random selection + const randomResponse = await makeRequest('/api/discovery/random-primary', 'POST', { + timestamp: new Date().toISOString() + }); + + if (randomResponse.status === 200) { + const result = randomResponse.data; + console.log('\nāœ… Random selection successful!'); + console.log(` New Primary: ${result.primaryNode}`); + console.log(` Previous Primary: ${currentPrimary}`); + console.log(` Message: ${result.message}`); + console.log(` Total Nodes: ${result.totalNodes}`); + console.log(` Client Initialized: ${result.clientInitialized}`); + + // Verify the change + if (result.primaryNode !== currentPrimary) { + console.log('\nšŸŽÆ Primary node successfully changed!'); + } else { + console.log('\nāš ļø Primary node remained the same (only one node available)'); + } + + } else { + console.log('\nāŒ Random selection failed:'); + console.log(` Status: ${randomResponse.status}`); + console.log(` Error: ${randomResponse.data.error || 'Unknown error'}`); + } + + // Show updated status + console.log('\n3. Checking updated discovery status...'); + const updatedResponse = await makeRequest('/api/discovery/nodes', 'GET'); + if (updatedResponse.status === 200) { + const updated = updatedResponse.data; + console.log(` Current Primary: ${updated.primaryNode}`); + console.log(` Client Base URL: ${updated.clientBaseUrl}`); + } + + console.log('\nšŸ’” To test in the frontend:'); + console.log(' 1. Open http://localhost:3001 in your browser'); + console.log(' 2. Look at the cluster header for primary node info'); + console.log(' 3. Click the šŸŽ² button to randomly select a new primary node'); + console.log(' 4. Watch the display change in real-time'); + + } catch (error) { + console.error('\nāŒ Test failed:', error.message); + console.log('\nšŸ’” Make sure the backend is running: npm start'); + } +} + +// Run the test +testRandomSelection().catch(console.error); \ No newline at end of file