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 @@
`;
+
+ // 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