Compare commits

...

2 Commits

Author SHA1 Message Date
be3cd771fc docs: update readme 2025-08-25 12:15:55 +02:00
5977a37d6c feat: auto-discovery 2025-08-25 12:05:51 +02:00
9 changed files with 649 additions and 25 deletions

133
README.md
View File

@@ -1,10 +1,10 @@
# SPORE UI
A modern web interface for monitoring and managing SPORE embedded systems.
Zero-configuration web interface for monitoring and managing SPORE embedded systems.
## Features
- **🌐 Cluster Monitoring**: Real-time view of all cluster members
- **🌐 Cluster Monitoring**: Real-time view of all cluster members with auto-discovery
- **📊 Node Details**: Expandable cards with detailed system information
- **🚀 Modern UI**: Beautiful glassmorphism design with smooth animations
- **📱 Responsive**: Works on all devices and screen sizes
@@ -28,7 +28,6 @@ spore-ui/
│ ├── index.html # Main HTML page
│ ├── styles.css # All CSS styles
│ ├── script.js # All JavaScript functionality
│ └── README.md # Frontend documentation
└── README.md # This file
```
@@ -53,32 +52,63 @@ spore-ui/
- **API**: SPORE Embedded System API
- **Design**: Glassmorphism, CSS Grid, Flexbox
## TODO
## UDP Auto Discovery
The backend now includes automatic UDP discovery for SPORE nodes on the network. This eliminates the need for hardcoded IP addresses.
The backend now includes automatic UDP 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
### 🚀 How It Works
1. **UDP Server**: The backend listens on port 4210 for UDP messages
2. **Discovery Message**: Nodes send `CLUSTER_DISCOVERY` messages to broadcast address `255.255.255.255:4210`
3. **Auto Configuration**: When a discovery 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
### Discovery Endpoints
### 📡 Discovery Protocol
- **Port**: 4210 (configurable via `UDP_PORT` constant)
- **Message**: `CLUSTER_DISCOVERY` (configurable via `DISCOVERY_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 discovery server on port 4210
# - Wait for CLUSTER_DISCOVERY messages
```
#### Node Configuration
SPORE nodes should send discovery messages periodically:
```bash
# Recommended: Send every 30-60 seconds
# Message format: "CLUSTER_DISCOVERY"
# Target: 255.255.255.255:4210
```
### 🌐 Discovery Endpoints
#### Discovery Management
- `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
- `GET /api/health` - Health check including discovery status
- `POST /api/discovery/random-primary` - Randomly select a new primary node
### Testing Discovery
#### Health Monitoring
- `GET /api/health` - Comprehensive health check including discovery status
Use the included test script to send discovery messages:
### 🧪 Testing & Development
#### Test Scripts
```bash
# Send to broadcast address
# Send discovery messages to test the system
npm run test-discovery broadcast
# Send to specific IP
@@ -86,18 +116,77 @@ 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
```
### Node Behavior
#### Manual Testing
```bash
# Check discovery status
curl http://localhost:3001/api/discovery/nodes
- Nodes should send `CLUSTER_DISCOVERY` messages periodically (recommended: every 30-60 seconds)
- The backend automatically cleans up stale nodes (not seen for 5+ minutes)
- The most recently seen node becomes the primary connection
- All API endpoints automatically use the discovered node IPs
# Check health
curl http://localhost:3001/api/health
### Configuration
# Manual refresh
curl -X POST http://localhost:3001/api/discovery/refresh
- UDP Discovery Port: 4210 (configurable via `UDP_PORT` constant)
- Discovery Message: `CLUSTER_DISCOVERY` (configurable via `DISCOVERY_MESSAGE` constant)
- Stale Node Timeout: 5 minutes (configurable in `cleanupStaleNodes()` function)
- Health Check Interval: 5 seconds (configurable in `setInterval`)
# 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
```
### 🔍 Troubleshooting
#### Common Issues
**No Nodes Discovered**
```bash
# Check if backend is running
curl http://localhost:3001/api/health
# Verify UDP port is open
netstat -tulpn | grep 4210
# Send test discovery message
npm run test-discovery broadcast
```
**UDP Port Already in Use**
```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
curl http://localhost:3001/api/discovery/nodes
# Verify nodes are sending discovery messages
# Check network connectivity
```
#### Debug Commands
```bash
# Check discovery status
curl http://localhost:3001/api/discovery/nodes
# 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
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 198 KiB

View File

@@ -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 {

View File

@@ -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": [],

View File

@@ -21,7 +21,18 @@
<div id="cluster-view" class="view-content active">
<div class="cluster-section">
<div class="cluster-header">
<div class="cluster-header-left"></div>
<div class="cluster-header-left">
<div class="primary-node-info">
<span class="primary-node-label">Primary Node:</span>
<span class="primary-node-ip" id="primary-node-ip">Discovering...</span>
<button class="primary-node-refresh" onclick="selectRandomPrimaryNode()" title="🎲 Select random primary node">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<path d="M1 4v6h6M23 20v-6h-6"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
</button>
</div>
</div>
<button class="refresh-btn" onclick="refreshClusterMembers()">
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6"/>

View File

@@ -23,6 +23,48 @@ class FrontendApiClient {
}
}
async getDiscoveryInfo() {
try {
const response = await fetch('/api/discovery/nodes', {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
}
async selectRandomPrimaryNode() {
try {
const response = await fetch('/api/discovery/random-primary', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
timestamp: new Date().toISOString()
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
}
async getNodeStatus(ip) {
try {
// Create a proxy endpoint that forwards the request to the specific node
@@ -66,6 +108,83 @@ class FrontendApiClient {
// Global client instance
const client = new FrontendApiClient();
// Function to update primary node display
async function updatePrimaryNodeDisplay() {
const primaryNodeElement = document.getElementById('primary-node-ip');
if (!primaryNodeElement) return;
try {
// Set discovering state
primaryNodeElement.textContent = 'Discovering...';
primaryNodeElement.className = 'primary-node-ip discovering';
const discoveryInfo = await client.getDiscoveryInfo();
if (discoveryInfo.primaryNode) {
const status = discoveryInfo.clientInitialized ? '✅' : '⚠️';
const nodeCount = discoveryInfo.totalNodes > 1 ? ` (${discoveryInfo.totalNodes} nodes)` : '';
primaryNodeElement.textContent = `${status} ${discoveryInfo.primaryNode}${nodeCount}`;
primaryNodeElement.className = 'primary-node-ip';
} else if (discoveryInfo.totalNodes > 0) {
// If we have nodes but no primary, show the first one
const firstNode = discoveryInfo.nodes[0];
primaryNodeElement.textContent = `⚠️ ${firstNode.ip} (No Primary)`;
primaryNodeElement.className = 'primary-node-ip error';
} else {
primaryNodeElement.textContent = '🔍 No Nodes Found';
primaryNodeElement.className = 'primary-node-ip error';
}
} catch (error) {
console.error('Failed to fetch discovery info:', error);
primaryNodeElement.textContent = '❌ Discovery Failed';
primaryNodeElement.className = 'primary-node-ip error';
}
}
// Function to randomly select a new primary node
async function selectRandomPrimaryNode() {
const primaryNodeElement = document.getElementById('primary-node-ip');
if (!primaryNodeElement) return;
try {
// Store current primary node for reference
const currentText = primaryNodeElement.textContent;
const currentPrimary = currentText.includes('✅') || currentText.includes('⚠️') ?
currentText.split(' ')[1] : 'unknown';
// Set selecting state
primaryNodeElement.textContent = '🎲 Selecting...';
primaryNodeElement.className = 'primary-node-ip selecting';
// Call the random selection API
const result = await client.selectRandomPrimaryNode();
if (result.success) {
// Show success message briefly
primaryNodeElement.textContent = `🎯 ${result.primaryNode}`;
primaryNodeElement.className = 'primary-node-ip';
// Update the display after a short delay
setTimeout(() => {
updatePrimaryNodeDisplay();
}, 1500);
console.log(`Randomly selected new primary node: ${result.primaryNode}`);
} else {
throw new Error(result.message || 'Random selection failed');
}
} catch (error) {
console.error('Failed to select random primary node:', error);
primaryNodeElement.textContent = '❌ Selection Failed';
primaryNodeElement.className = 'primary-node-ip error';
// Revert to normal display after error
setTimeout(() => {
updatePrimaryNodeDisplay();
}, 2000);
}
}
// Function to refresh cluster members
async function refreshClusterMembers() {
const container = document.getElementById('cluster-members-container');
@@ -97,6 +216,9 @@ async function refreshClusterMembers() {
const response = await client.getClusterMembers();
console.log(response);
displayClusterMembers(response.members, expandedCards);
// Update primary node display after cluster refresh
await updatePrimaryNodeDisplay();
} catch (error) {
console.error('Failed to fetch cluster members:', error);
container.innerHTML = `
@@ -105,6 +227,9 @@ async function refreshClusterMembers() {
${error.message}
</div>
`;
// 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

View File

@@ -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;

102
test/demo-frontend.js Normal file
View File

@@ -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);

View File

@@ -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);