feat: add mock mode

This commit is contained in:
2025-09-17 22:22:11 +02:00
parent bfe973afe6
commit 1062691e7b
15 changed files with 1964 additions and 456 deletions

View File

@@ -11,6 +11,18 @@
"demo-discovery": "node test/demo-discovery.js",
"demo-frontend": "node test/demo-frontend.js",
"test-random-selection": "node test/test-random-selection.js",
"mock": "node test/mock-cli.js",
"mock:start": "node test/mock-cli.js start",
"mock:list": "node test/mock-cli.js list",
"mock:info": "node test/mock-cli.js info",
"mock:healthy": "node test/mock-cli.js start healthy",
"mock:degraded": "node test/mock-cli.js start degraded",
"mock:large": "node test/mock-cli.js start large",
"mock:unstable": "node test/mock-cli.js start unstable",
"mock:single": "node test/mock-cli.js start single",
"mock:empty": "node test/mock-cli.js start empty",
"mock:test": "node test/mock-test.js",
"mock:integration": "node test/test-mock-integration.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],

View File

@@ -298,8 +298,8 @@ class ClusterMembersComponent extends Component {
// Update status
const statusElement = card.querySelector('.member-status');
if (statusElement) {
const statusClass = member.status === 'active' ? 'status-online' : 'status-offline';
const statusIcon = member.status === 'active' ? '🟢' : '🔴';
const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' : 'status-offline';
const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? '🟢' : '🔴';
statusElement.className = `member-status ${statusClass}`;
statusElement.innerHTML = `${statusIcon}`;
@@ -402,9 +402,9 @@ class ClusterMembersComponent extends Component {
logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members');
const membersHTML = members.map(member => {
const statusClass = member.status === 'active' ? 'status-online' : 'status-offline';
const statusText = member.status === 'active' ? 'Online' : 'Offline';
const statusIcon = member.status === 'active' ? '🟢' : '🔴';
const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' : 'status-offline';
const statusText = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'Online' : 'Offline';
const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? '🟢' : '🔴';
logger.debug('ClusterMembersComponent: Rendering member:', member);

View File

@@ -263,15 +263,18 @@ class NodeDetailsComponent extends Component {
if (monitoringResources.filesystem) {
const usedKB = Math.round(monitoringResources.filesystem.used_bytes / 1024);
const totalKB = Math.round(monitoringResources.filesystem.total_bytes / 1024);
const usagePercent = monitoringResources.filesystem.total_bytes > 0
? ((monitoringResources.filesystem.used_bytes / monitoringResources.filesystem.total_bytes) * 100).toFixed(1)
: '0.0';
html += `
<div class="detail-row">
<span class="detail-label">Filesystem:</span>
<span class="detail-value">${monitoringResources.filesystem.usage_percent ? monitoringResources.filesystem.usage_percent.toFixed(1) + '%' : 'N/A'} (${usedKB}KB / ${totalKB}KB)</span>
<span class="detail-value">${usagePercent}% (${usedKB}KB / ${totalKB}KB)</span>
</div>
`;
}
// System Uptime
// System Information
if (monitoringResources.system) {
html += `
<div class="detail-row">
@@ -281,6 +284,25 @@ class NodeDetailsComponent extends Component {
`;
}
// Network Information
if (monitoringResources.network) {
const uptimeSeconds = monitoringResources.network.uptime_seconds || 0;
const uptimeHours = Math.floor(uptimeSeconds / 3600);
const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
const uptimeFormatted = `${uptimeHours}h ${uptimeMinutes}m`;
html += `
<div class="detail-row">
<span class="detail-label">WiFi RSSI:</span>
<span class="detail-value">${monitoringResources.network.wifi_rssi || 'N/A'} dBm</span>
</div>
<div class="detail-row">
<span class="detail-label">Network Uptime:</span>
<span class="detail-value">${uptimeFormatted}</span>
</div>
`;
}
html += `</div>`;
}
@@ -291,9 +313,9 @@ class NodeDetailsComponent extends Component {
// Get values with fallbacks and ensure they are numbers
const cpuUsage = parseFloat(monitoringResources.cpu?.average_usage) || 0;
const heapUsage = parseFloat(monitoringResources.memory?.heap_usage_percent) || 0;
const filesystemUsage = parseFloat(monitoringResources.filesystem?.usage_percent) || 0;
const filesystemUsed = parseFloat(monitoringResources.filesystem?.used_bytes) || 0;
const filesystemTotal = parseFloat(monitoringResources.filesystem?.total_bytes) || 0;
const filesystemUsage = filesystemTotal > 0 ? (filesystemUsed / filesystemTotal) * 100 : 0;
// Convert filesystem bytes to KB
const filesystemUsedKB = Math.round(filesystemUsed / 1024);

View File

@@ -831,10 +831,10 @@ class MemberCardOverlayComponent extends Component {
}
renderMemberCard(member) {
const statusClass = member.status === 'active' ? 'status-online' :
member.status === 'inactive' ? 'status-inactive' : 'status-offline';
const statusIcon = member.status === 'active' ? '🟢' :
member.status === 'inactive' ? '🟠' : '🔴';
const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' :
(member.status && member.status.toUpperCase() === 'INACTIVE') ? 'status-inactive' : 'status-offline';
const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? '🟢' :
(member.status && member.status.toUpperCase() === 'INACTIVE') ? '🟠' : '🔴';
return `
<div class="member-overlay-content">

View File

@@ -42,7 +42,7 @@ class ClusterViewModel extends ViewModel {
const members = response.members || [];
const onlineNodes = Array.isArray(members)
? members.filter(m => m && m.status === 'active').length
? members.filter(m => m && m.status && m.status.toUpperCase() === 'ACTIVE').length
: 0;
// Use batch update to preserve UI state
@@ -281,7 +281,9 @@ class NodeDetailsViewModel extends ViewModel {
try {
const ip = this.get('nodeIp');
const response = await window.apiClient.getEndpoints(ip);
this.set('endpoints', response || null);
// Handle both real API (wrapped in endpoints) and mock API (direct array)
const endpointsData = (response && response.endpoints) ? response : { endpoints: response };
this.set('endpoints', endpointsData || null);
} catch (error) {
console.error('Failed to load endpoints:', error);
this.set('endpoints', null);
@@ -293,8 +295,8 @@ class NodeDetailsViewModel extends ViewModel {
try {
const ip = this.get('nodeIp');
const response = await window.apiClient.getMonitoringResources(ip);
// The proxy call returns { data: {...} }, so we need to extract the data
const monitoringData = (response && response.data) ? response.data : null;
// Handle both real API (wrapped in data) and mock API (direct response)
const monitoringData = (response && response.data) ? response.data : response;
this.set('monitoringResources', monitoringData);
} catch (error) {
console.error('Failed to load monitoring resources:', error);

View File

@@ -1,124 +0,0 @@
#!/usr/bin/env node
/**
* Demo script for UDP discovery functionality
* Monitors the discovery endpoints to show how nodes are discovered
*/
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 checkHealth() {
try {
const response = await makeRequest('/api/health');
console.log('\n=== Health Check ===');
console.log(`Status: ${response.data.status}`);
console.log(`HTTP Service: ${response.data.services.http}`);
console.log(`UDP Service: ${response.data.services.udp}`);
console.log(`SPORE Client: ${response.data.services.sporeClient}`);
console.log(`Total Nodes: ${response.data.discovery.totalNodes}`);
console.log(`Primary Node: ${response.data.discovery.primaryNode || 'None'}`);
if (response.data.message) {
console.log(`Message: ${response.data.message}`);
}
} catch (error) {
console.error('Health check failed:', error.message);
}
}
async function checkDiscovery() {
try {
const response = await makeRequest('/api/discovery/nodes');
console.log('\n=== Discovery Status ===');
console.log(`Primary Node: ${response.data.primaryNode || 'None'}`);
console.log(`Total Nodes: ${response.data.totalNodes}`);
console.log(`Client Initialized: ${response.data.clientInitialized}`);
if (response.data.clientBaseUrl) {
console.log(`Client Base URL: ${response.data.clientBaseUrl}`);
}
if (response.data.nodes.length > 0) {
console.log('\nDiscovered Nodes:');
response.data.nodes.forEach((node, index) => {
console.log(` ${index + 1}. ${node.ip}:${node.port} (${node.isPrimary ? 'PRIMARY' : 'secondary'})`);
console.log(` Discovered: ${node.discoveredAt}`);
console.log(` Last Seen: ${node.lastSeen}`);
});
} else {
console.log('No nodes discovered yet.');
}
} catch (error) {
console.error('Discovery check failed:', error.message);
}
}
async function runDemo() {
console.log('🚀 SPORE UDP Discovery Demo');
console.log('============================');
console.log('This demo monitors the discovery endpoints to show how nodes are discovered.');
console.log('Start the backend server with: npm start');
console.log('Send discovery messages with: npm run test-discovery broadcast');
console.log('');
// Initial check
await checkHealth();
await checkDiscovery();
// Set up periodic monitoring
console.log('\n📡 Monitoring discovery endpoints every 5 seconds...');
console.log('Press Ctrl+C to stop\n');
setInterval(async () => {
await checkHealth();
await checkDiscovery();
}, 5000);
}
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('\n\n👋 Demo stopped. Goodbye!');
process.exit(0);
});
// Run the demo
runDemo().catch(console.error);

View File

@@ -1,102 +0,0 @@
#!/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);

132
test/mock-api-client.js Normal file
View File

@@ -0,0 +1,132 @@
// Mock API Client for communicating with the mock server
// This replaces the original API client to use port 3002
class MockApiClient {
constructor() {
// Use port 3002 for mock server
const currentHost = window.location.hostname;
this.baseUrl = `http://${currentHost}:3002`;
console.log('Mock API Client initialized with base URL:', this.baseUrl);
}
async request(path, { method = 'GET', headers = {}, body = undefined, query = undefined, isForm = false } = {}) {
const url = new URL(`${this.baseUrl}${path}`);
if (query && typeof query === 'object') {
Object.entries(query).forEach(([k, v]) => {
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
});
}
const finalHeaders = { 'Accept': 'application/json', ...headers };
const options = { method, headers: finalHeaders };
if (body !== undefined) {
if (isForm) {
options.body = body;
} else {
options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json';
options.body = typeof body === 'string' ? body : JSON.stringify(body);
}
}
const response = await fetch(url.toString(), options);
let data;
const text = await response.text();
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = text; // Non-JSON payload
}
if (!response.ok) {
const message = (data && data.message) || `HTTP ${response.status}: ${response.statusText}`;
throw new Error(message);
}
return data;
}
async getClusterMembers() {
return this.request('/api/cluster/members', { method: 'GET' });
}
async getClusterMembersFromNode(ip) {
return this.request(`/api/cluster/members`, {
method: 'GET',
query: { ip: ip }
});
}
async getDiscoveryInfo() {
return this.request('/api/discovery/nodes', { method: 'GET' });
}
async selectRandomPrimaryNode() {
return this.request('/api/discovery/random-primary', {
method: 'POST',
body: { timestamp: new Date().toISOString() }
});
}
async getNodeStatus(ip) {
return this.request('/api/node/status', {
method: 'GET',
query: { ip: ip }
});
}
async getTasksStatus(ip) {
return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined });
}
async getEndpoints(ip) {
return this.request('/api/node/endpoints', { method: 'GET', query: ip ? { ip } : undefined });
}
async callEndpoint({ ip, method, uri, params }) {
return this.request('/api/proxy-call', {
method: 'POST',
body: { ip, method, uri, params }
});
}
async uploadFirmware(file, nodeIp) {
const formData = new FormData();
formData.append('file', file);
const data = await this.request(`/api/node/update`, {
method: 'POST',
query: { ip: nodeIp },
body: formData,
isForm: true,
headers: {},
});
// Some endpoints may return HTTP 200 with success=false on logical failure
if (data && data.success === false) {
const message = data.message || 'Firmware upload failed';
throw new Error(message);
}
return data;
}
async getMonitoringResources(ip) {
return this.request('/api/proxy-call', {
method: 'POST',
body: {
ip: ip,
method: 'GET',
uri: '/api/monitoring/resources',
params: []
}
});
}
}
// Override the global API client
window.apiClient = new MockApiClient();
// Add debugging
console.log('Mock API Client loaded and initialized');
console.log('API Client base URL:', window.apiClient.baseUrl);
// Test API call
window.apiClient.getDiscoveryInfo().then(data => {
console.log('Mock API test successful:', data);
}).catch(error => {
console.error('Mock API test failed:', error);
});

232
test/mock-cli.js Normal file
View File

@@ -0,0 +1,232 @@
#!/usr/bin/env node
/**
* Mock Server CLI Tool
*
* Command-line interface for managing the SPORE UI mock server
* with different configurations and scenarios
*/
const { spawn } = require('child_process');
const path = require('path');
const { getMockConfig, listMockConfigs, createCustomConfig } = require('./mock-configs');
// Colors for console output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m'
};
function colorize(text, color) {
return `${colors[color]}${text}${colors.reset}`;
}
function printHeader() {
console.log(colorize('🚀 SPORE UI Mock Server CLI', 'cyan'));
console.log(colorize('=============================', 'cyan'));
console.log('');
}
function printHelp() {
console.log('Usage: node mock-cli.js <command> [options]');
console.log('');
console.log('Commands:');
console.log(' start [config] Start mock server with specified config');
console.log(' list List available configurations');
console.log(' info <config> Show detailed info about a configuration');
console.log(' help Show this help message');
console.log('');
console.log('Available Configurations:');
listMockConfigs().forEach(config => {
console.log(` ${colorize(config.name, 'green')} - ${config.description} (${config.nodeCount} nodes)`);
});
console.log('');
console.log('Examples:');
console.log(' node mock-cli.js start healthy');
console.log(' node mock-cli.js start degraded');
console.log(' node mock-cli.js list');
console.log(' node mock-cli.js info large');
}
function printConfigInfo(configName) {
const config = getMockConfig(configName);
console.log(colorize(`📋 Configuration: ${config.name}`, 'blue'));
console.log(colorize('='.repeat(50), 'blue'));
console.log(`Description: ${config.description}`);
console.log(`Nodes: ${config.nodes.length}`);
console.log('');
if (config.nodes.length > 0) {
console.log(colorize('🌐 Mock Nodes:', 'yellow'));
config.nodes.forEach((node, index) => {
const statusColor = node.status === 'ACTIVE' ? 'green' :
node.status === 'INACTIVE' ? 'yellow' : 'red';
console.log(` ${index + 1}. ${colorize(node.hostname, 'cyan')} (${node.ip}) - ${colorize(node.status, statusColor)}`);
});
console.log('');
}
console.log(colorize('⚙️ Simulation Settings:', 'yellow'));
console.log(` Time Progression: ${config.simulation.enableTimeProgression ? colorize('Enabled', 'green') : colorize('Disabled', 'red')}`);
console.log(` Random Failures: ${config.simulation.enableRandomFailures ? colorize('Enabled', 'green') : colorize('Disabled', 'red')}`);
if (config.simulation.enableRandomFailures) {
console.log(` Failure Rate: ${(config.simulation.failureRate * 100).toFixed(1)}%`);
}
console.log(` Update Interval: ${config.simulation.updateInterval}ms`);
console.log(` Primary Rotation: ${config.simulation.primaryNodeRotation ? colorize('Enabled', 'green') : colorize('Disabled', 'red')}`);
if (config.simulation.primaryNodeRotation) {
console.log(` Rotation Interval: ${config.simulation.rotationInterval}ms`);
}
console.log('');
}
function startMockServer(configName) {
const config = getMockConfig(configName);
console.log(colorize(`🚀 Starting mock server with '${config.name}' configuration...`, 'green'));
console.log('');
// Set environment variables for the mock server
const env = {
...process.env,
MOCK_CONFIG: configName,
MOCK_PORT: process.env.MOCK_PORT || '3002'
};
// Start the mock server
const mockServerPath = path.join(__dirname, 'mock-server.js');
const child = spawn('node', [mockServerPath], {
env: env,
stdio: 'inherit',
cwd: path.join(__dirname, '..')
});
// Handle process termination
process.on('SIGINT', () => {
console.log(colorize('\n\n🛑 Stopping mock server...', 'yellow'));
child.kill('SIGINT');
process.exit(0);
});
child.on('close', (code) => {
if (code !== 0) {
console.log(colorize(`\n❌ Mock server exited with code ${code}`, 'red'));
} else {
console.log(colorize('\n✅ Mock server stopped gracefully', 'green'));
}
});
child.on('error', (error) => {
console.error(colorize(`\n❌ Failed to start mock server: ${error.message}`, 'red'));
process.exit(1);
});
}
function listConfigurations() {
console.log(colorize('📋 Available Mock Configurations', 'blue'));
console.log(colorize('================================', 'blue'));
console.log('');
const configs = listMockConfigs();
configs.forEach(config => {
console.log(colorize(`🔧 ${config.displayName}`, 'green'));
console.log(` Key: ${colorize(config.name, 'cyan')}`);
console.log(` Description: ${config.description}`);
console.log(` Nodes: ${config.nodeCount}`);
console.log('');
});
console.log(colorize('💡 Usage:', 'yellow'));
console.log(' node mock-cli.js start <config-key>');
console.log(' node mock-cli.js info <config-key>');
console.log('');
}
// Main CLI logic
function main() {
const args = process.argv.slice(2);
const command = args[0];
const configName = args[1];
printHeader();
switch (command) {
case 'start':
if (!configName) {
console.log(colorize('❌ Error: Configuration name required', 'red'));
console.log('Usage: node mock-cli.js start <config-name>');
console.log('Run "node mock-cli.js list" to see available configurations');
process.exit(1);
}
const config = getMockConfig(configName);
if (!config) {
console.log(colorize(`❌ Error: Unknown configuration '${configName}'`, 'red'));
console.log('Run "node mock-cli.js list" to see available configurations');
process.exit(1);
}
printConfigInfo(configName);
startMockServer(configName);
break;
case 'list':
listConfigurations();
break;
case 'info':
if (!configName) {
console.log(colorize('❌ Error: Configuration name required', 'red'));
console.log('Usage: node mock-cli.js info <config-name>');
console.log('Run "node mock-cli.js list" to see available configurations');
process.exit(1);
}
const infoConfig = getMockConfig(configName);
if (!infoConfig) {
console.log(colorize(`❌ Error: Unknown configuration '${configName}'`, 'red'));
console.log('Run "node mock-cli.js list" to see available configurations');
process.exit(1);
}
printConfigInfo(configName);
break;
case 'help':
case '--help':
case '-h':
printHelp();
break;
default:
if (!command) {
console.log(colorize('❌ Error: Command required', 'red'));
console.log('');
printHelp();
} else {
console.log(colorize(`❌ Error: Unknown command '${command}'`, 'red'));
console.log('');
printHelp();
}
process.exit(1);
}
}
// Run the CLI
if (require.main === module) {
main();
}
module.exports = {
getMockConfig,
listMockConfigs,
printConfigInfo,
startMockServer
};

291
test/mock-configs.js Normal file
View File

@@ -0,0 +1,291 @@
/**
* Mock Configuration Presets
*
* Different scenarios for testing the SPORE UI with various conditions
*/
const mockConfigs = {
// Default healthy cluster
healthy: {
name: "Healthy Cluster",
description: "All nodes active and functioning normally",
nodes: [
{
ip: '192.168.1.100',
hostname: 'spore-node-1',
chipId: 12345678,
status: 'ACTIVE',
latency: 5
},
{
ip: '192.168.1.101',
hostname: 'spore-node-2',
chipId: 87654321,
status: 'ACTIVE',
latency: 8
},
{
ip: '192.168.1.102',
hostname: 'spore-node-3',
chipId: 11223344,
status: 'ACTIVE',
latency: 12
}
],
simulation: {
enableTimeProgression: true,
enableRandomFailures: false,
failureRate: 0.0,
updateInterval: 5000,
primaryNodeRotation: false,
rotationInterval: 30000
}
},
// Single node scenario
single: {
name: "Single Node",
description: "Only one node in the cluster",
nodes: [
{
ip: '192.168.1.100',
hostname: 'spore-node-1',
chipId: 12345678,
status: 'ACTIVE',
latency: 5
}
],
simulation: {
enableTimeProgression: true,
enableRandomFailures: false,
failureRate: 0.0,
updateInterval: 5000,
primaryNodeRotation: false,
rotationInterval: 30000
}
},
// Large cluster
large: {
name: "Large Cluster",
description: "Many nodes in the cluster",
nodes: [
{ ip: '192.168.1.100', hostname: 'spore-node-1', chipId: 12345678, status: 'ACTIVE', latency: 5 },
{ ip: '192.168.1.101', hostname: 'spore-node-2', chipId: 87654321, status: 'ACTIVE', latency: 8 },
{ ip: '192.168.1.102', hostname: 'spore-node-3', chipId: 11223344, status: 'ACTIVE', latency: 12 },
{ ip: '192.168.1.103', hostname: 'spore-node-4', chipId: 44332211, status: 'ACTIVE', latency: 15 },
{ ip: '192.168.1.104', hostname: 'spore-node-5', chipId: 55667788, status: 'ACTIVE', latency: 7 },
{ ip: '192.168.1.105', hostname: 'spore-node-6', chipId: 99887766, status: 'ACTIVE', latency: 20 },
{ ip: '192.168.1.106', hostname: 'spore-node-7', chipId: 11223355, status: 'ACTIVE', latency: 9 },
{ ip: '192.168.1.107', hostname: 'spore-node-8', chipId: 66778899, status: 'ACTIVE', latency: 11 }
],
simulation: {
enableTimeProgression: true,
enableRandomFailures: false,
failureRate: 0.0,
updateInterval: 5000,
primaryNodeRotation: true,
rotationInterval: 30000
}
},
// Degraded cluster with some failures
degraded: {
name: "Degraded Cluster",
description: "Some nodes are inactive or dead",
nodes: [
{
ip: '192.168.1.100',
hostname: 'spore-node-1',
chipId: 12345678,
status: 'ACTIVE',
latency: 5
},
{
ip: '192.168.1.101',
hostname: 'spore-node-2',
chipId: 87654321,
status: 'INACTIVE',
latency: 8
},
{
ip: '192.168.1.102',
hostname: 'spore-node-3',
chipId: 11223344,
status: 'DEAD',
latency: 12
},
{
ip: '192.168.1.103',
hostname: 'spore-node-4',
chipId: 44332211,
status: 'ACTIVE',
latency: 15
}
],
simulation: {
enableTimeProgression: true,
enableRandomFailures: true,
failureRate: 0.1,
updateInterval: 5000,
primaryNodeRotation: false,
rotationInterval: 30000
}
},
// High failure rate scenario
unstable: {
name: "Unstable Cluster",
description: "High failure rate with frequent node changes",
nodes: [
{
ip: '192.168.1.100',
hostname: 'spore-node-1',
chipId: 12345678,
status: 'ACTIVE',
latency: 5
},
{
ip: '192.168.1.101',
hostname: 'spore-node-2',
chipId: 87654321,
status: 'ACTIVE',
latency: 8
},
{
ip: '192.168.1.102',
hostname: 'spore-node-3',
chipId: 11223344,
status: 'ACTIVE',
latency: 12
}
],
simulation: {
enableTimeProgression: true,
enableRandomFailures: true,
failureRate: 0.3, // 30% chance of failures
updateInterval: 2000, // Update every 2 seconds
primaryNodeRotation: true,
rotationInterval: 15000 // Rotate every 15 seconds
}
},
// No nodes scenario
empty: {
name: "Empty Cluster",
description: "No nodes discovered",
nodes: [],
simulation: {
enableTimeProgression: false,
enableRandomFailures: false,
failureRate: 0.0,
updateInterval: 5000,
primaryNodeRotation: false,
rotationInterval: 30000
}
},
// Development scenario with custom settings
development: {
name: "Development Mode",
description: "Custom settings for development and testing",
nodes: [
{
ip: '192.168.1.100',
hostname: 'dev-node-1',
chipId: 12345678,
status: 'ACTIVE',
latency: 5
},
{
ip: '192.168.1.101',
hostname: 'dev-node-2',
chipId: 87654321,
status: 'ACTIVE',
latency: 8
}
],
simulation: {
enableTimeProgression: true,
enableRandomFailures: true,
failureRate: 0.05, // 5% failure rate
updateInterval: 3000, // Update every 3 seconds
primaryNodeRotation: true,
rotationInterval: 20000 // Rotate every 20 seconds
}
}
};
/**
* Get a mock configuration by name
* @param {string} configName - Name of the configuration preset
* @returns {Object} Mock configuration object
*/
function getMockConfig(configName = 'healthy') {
const config = mockConfigs[configName];
if (!config) {
console.warn(`Unknown mock config: ${configName}. Using 'healthy' instead.`);
return mockConfigs.healthy;
}
return config;
}
/**
* List all available mock configurations
* @returns {Array} Array of configuration names and descriptions
*/
function listMockConfigs() {
return Object.keys(mockConfigs).map(key => ({
name: key,
displayName: mockConfigs[key].name,
description: mockConfigs[key].description,
nodeCount: mockConfigs[key].nodes.length
}));
}
/**
* Create a custom mock configuration
* @param {Object} options - Configuration options
* @returns {Object} Custom mock configuration
*/
function createCustomConfig(options = {}) {
const defaultConfig = {
name: "Custom Configuration",
description: "User-defined mock configuration",
nodes: [
{
ip: '192.168.1.100',
hostname: 'custom-node-1',
chipId: 12345678,
status: 'ACTIVE',
latency: 5
}
],
simulation: {
enableTimeProgression: true,
enableRandomFailures: false,
failureRate: 0.0,
updateInterval: 5000,
primaryNodeRotation: false,
rotationInterval: 30000
}
};
// Merge with provided options
return {
...defaultConfig,
...options,
nodes: options.nodes || defaultConfig.nodes,
simulation: {
...defaultConfig.simulation,
...options.simulation
}
};
}
module.exports = {
mockConfigs,
getMockConfig,
listMockConfigs,
createCustomConfig
};

791
test/mock-server.js Normal file
View File

@@ -0,0 +1,791 @@
#!/usr/bin/env node
/**
* Complete Mock Server for SPORE UI
*
* This mock server provides a complete simulation of the SPORE embedded system
* without requiring actual hardware or UDP port conflicts. It simulates:
* - Multiple SPORE nodes with different IPs
* - All API endpoints from the OpenAPI specification
* - Discovery system without UDP conflicts
* - Realistic data that changes over time
* - Different scenarios (healthy, degraded, error states)
*/
const express = require('express');
const cors = require('cors');
const path = require('path');
const { getMockConfig } = require('./mock-configs');
// Load mock configuration
const configName = process.env.MOCK_CONFIG || 'healthy';
const baseConfig = getMockConfig(configName);
// Mock server configuration
const MOCK_CONFIG = {
// Server settings
port: process.env.MOCK_PORT || 3002,
baseUrl: process.env.MOCK_BASE_URL || 'http://localhost:3002',
// Load configuration from preset
...baseConfig
};
// Initialize Express app
const app = express();
app.use(cors({
origin: true,
credentials: true,
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Mock data generators
class MockDataGenerator {
constructor() {
this.startTime = Date.now();
this.nodeStates = new Map();
this.primaryNodeIndex = 0;
this.initializeNodeStates();
}
initializeNodeStates() {
MOCK_CONFIG.nodes.forEach((node, index) => {
this.nodeStates.set(node.ip, {
...node,
freeHeap: this.generateFreeHeap(),
uptime: 0,
lastSeen: Date.now(),
tasks: this.generateTasks(),
systemInfo: this.generateSystemInfo(node),
apiEndpoints: this.generateApiEndpoints()
});
});
}
generateFreeHeap() {
// Simulate realistic ESP8266 memory usage
const base = 30000;
const variation = 20000;
return Math.floor(base + Math.random() * variation);
}
generateSystemInfo(node) {
return {
freeHeap: this.generateFreeHeap(),
chipId: node.chipId,
sdkVersion: "3.1.2",
cpuFreqMHz: 80,
flashChipSize: 1048576
};
}
generateTasks() {
return [
{
name: "discovery_send",
interval: 1000,
enabled: true,
running: true,
autoStart: true
},
{
name: "heartbeat",
interval: 2000,
enabled: true,
running: true,
autoStart: true
},
{
name: "status_update",
interval: 1000,
enabled: true,
running: true,
autoStart: true
},
{
name: "wifi_monitor",
interval: 5000,
enabled: true,
running: Math.random() > 0.1, // 90% chance of running
autoStart: true
},
{
name: "ota_check",
interval: 30000,
enabled: true,
running: Math.random() > 0.2, // 80% chance of running
autoStart: true
},
{
name: "cluster_sync",
interval: 10000,
enabled: true,
running: Math.random() > 0.05, // 95% chance of running
autoStart: true
}
];
}
generateApiEndpoints() {
return [
{ uri: "/api/node/status", method: "GET" },
{ uri: "/api/tasks/status", method: "GET" },
{ uri: "/api/tasks/control", method: "POST" },
{ uri: "/api/cluster/members", method: "GET" },
{ uri: "/api/node/update", method: "POST" },
{ uri: "/api/node/restart", method: "POST" }
];
}
updateNodeStates() {
if (!MOCK_CONFIG.simulation.enableTimeProgression) return;
this.nodeStates.forEach((nodeState, ip) => {
// Update uptime
nodeState.uptime = Date.now() - this.startTime;
// Update free heap (simulate memory usage changes)
const currentHeap = nodeState.freeHeap;
const change = Math.floor((Math.random() - 0.5) * 1000);
nodeState.freeHeap = Math.max(10000, currentHeap + change);
// Update last seen
nodeState.lastSeen = Date.now();
// Simulate random failures
if (MOCK_CONFIG.simulation.enableRandomFailures && Math.random() < MOCK_CONFIG.simulation.failureRate) {
nodeState.status = Math.random() > 0.5 ? 'INACTIVE' : 'DEAD';
} else {
nodeState.status = 'ACTIVE';
}
// Update task states
nodeState.tasks.forEach(task => {
if (task.enabled && Math.random() > 0.05) { // 95% chance of running when enabled
task.running = true;
} else {
task.running = false;
}
});
});
// Rotate primary node if enabled
if (MOCK_CONFIG.simulation.primaryNodeRotation) {
this.primaryNodeIndex = (this.primaryNodeIndex + 1) % MOCK_CONFIG.nodes.length;
}
}
getPrimaryNode() {
return MOCK_CONFIG.nodes[this.primaryNodeIndex];
}
getAllNodes() {
return Array.from(this.nodeStates.values());
}
getNodeByIp(ip) {
return this.nodeStates.get(ip);
}
}
// Initialize mock data generator
const mockData = new MockDataGenerator();
// Update data periodically
setInterval(() => {
mockData.updateNodeStates();
}, MOCK_CONFIG.simulation.updateInterval);
// API Routes
// Health check endpoint
app.get('/api/health', (req, res) => {
const primaryNode = mockData.getPrimaryNode();
const allNodes = mockData.getAllNodes();
const activeNodes = allNodes.filter(node => node.status === 'ACTIVE');
const health = {
status: activeNodes.length > 0 ? 'healthy' : 'degraded',
timestamp: new Date().toISOString(),
services: {
http: true,
udp: false, // Mock server doesn't use UDP
sporeClient: true
},
discovery: {
totalNodes: allNodes.length,
primaryNode: primaryNode.ip,
udpPort: 4210,
serverRunning: false // Mock server doesn't use UDP
},
mock: {
enabled: true,
nodes: allNodes.length,
activeNodes: activeNodes.length,
simulationMode: MOCK_CONFIG.simulation.enableTimeProgression
}
};
if (activeNodes.length === 0) {
health.status = 'degraded';
health.message = 'No active nodes in mock simulation';
}
res.json(health);
});
// Discovery endpoints (simulated)
app.get('/api/discovery/nodes', (req, res) => {
const primaryNode = mockData.getPrimaryNode();
const allNodes = mockData.getAllNodes();
const response = {
primaryNode: primaryNode.ip,
totalNodes: allNodes.length,
clientInitialized: true,
clientBaseUrl: `http://${primaryNode.ip}`,
nodes: allNodes.map(node => ({
ip: node.ip,
port: 80,
discoveredAt: new Date(node.lastSeen - 60000).toISOString(), // 1 minute ago
lastSeen: new Date(node.lastSeen).toISOString(),
isPrimary: node.ip === primaryNode.ip,
hostname: node.hostname,
status: node.status
}))
};
res.json(response);
});
app.post('/api/discovery/refresh', (req, res) => {
// Simulate discovery refresh
mockData.updateNodeStates();
res.json({
success: true,
message: 'Discovery refresh completed',
timestamp: new Date().toISOString()
});
});
app.post('/api/discovery/primary/:ip', (req, res) => {
const { ip } = req.params;
const node = mockData.getNodeByIp(ip);
if (!node) {
return res.status(404).json({
success: false,
message: `Node ${ip} not found`
});
}
// Find and set as primary
const nodeIndex = MOCK_CONFIG.nodes.findIndex(n => n.ip === ip);
if (nodeIndex !== -1) {
mockData.primaryNodeIndex = nodeIndex;
}
res.json({
success: true,
message: `Primary node set to ${ip}`,
primaryNode: ip
});
});
app.post('/api/discovery/random-primary', (req, res) => {
const allNodes = mockData.getAllNodes();
const activeNodes = allNodes.filter(node => node.status === 'ACTIVE');
if (activeNodes.length === 0) {
return res.status(503).json({
success: false,
message: 'No active nodes available for selection'
});
}
// Randomly select a new primary
const randomIndex = Math.floor(Math.random() * activeNodes.length);
const newPrimary = activeNodes[randomIndex];
const nodeIndex = MOCK_CONFIG.nodes.findIndex(n => n.ip === newPrimary.ip);
if (nodeIndex !== -1) {
mockData.primaryNodeIndex = nodeIndex;
}
res.json({
success: true,
message: `Primary node randomly selected: ${newPrimary.ip}`,
primaryNode: newPrimary.ip,
totalNodes: allNodes.length,
clientInitialized: true
});
});
// Task management endpoints
app.get('/api/tasks/status', (req, res) => {
const { ip } = req.query;
let nodeData;
if (ip) {
nodeData = mockData.getNodeByIp(ip);
if (!nodeData) {
return res.status(404).json({
error: 'Node not found',
message: `Node ${ip} not found in mock simulation`
});
}
} else {
// Use primary node
const primaryNode = mockData.getPrimaryNode();
nodeData = mockData.getNodeByIp(primaryNode.ip);
}
const tasks = nodeData.tasks;
const activeTasks = tasks.filter(task => task.enabled && task.running).length;
const response = {
summary: {
totalTasks: tasks.length,
activeTasks: activeTasks
},
tasks: tasks,
system: {
freeHeap: nodeData.freeHeap,
uptime: nodeData.uptime
}
};
res.json(response);
});
app.post('/api/tasks/control', (req, res) => {
const { task, action } = req.body;
if (!task || !action) {
return res.status(400).json({
success: false,
message: 'Missing parameters. Required: task, action',
example: '{"task": "discovery_send", "action": "status"}'
});
}
const validActions = ['enable', 'disable', 'start', 'stop', 'status'];
if (!validActions.includes(action)) {
return res.status(400).json({
success: false,
message: 'Invalid action. Use: enable, disable, start, stop, or status',
task: task,
action: action
});
}
// Simulate task control
const primaryNode = mockData.getPrimaryNode();
const nodeData = mockData.getNodeByIp(primaryNode.ip);
const taskData = nodeData.tasks.find(t => t.name === task);
if (!taskData) {
return res.status(404).json({
success: false,
message: `Task ${task} not found`
});
}
// Apply action
switch (action) {
case 'enable':
taskData.enabled = true;
break;
case 'disable':
taskData.enabled = false;
taskData.running = false;
break;
case 'start':
if (taskData.enabled) {
taskData.running = true;
}
break;
case 'stop':
taskData.running = false;
break;
case 'status':
// Return detailed status
return res.json({
success: true,
message: 'Task status retrieved',
task: task,
action: action,
taskDetails: {
name: taskData.name,
enabled: taskData.enabled,
running: taskData.running,
interval: taskData.interval,
system: {
freeHeap: nodeData.freeHeap,
uptime: nodeData.uptime
}
}
});
}
res.json({
success: true,
message: `Task ${action}d`,
task: task,
action: action
});
});
// System status endpoint
app.get('/api/node/status', (req, res) => {
const { ip } = req.query;
let nodeData;
if (ip) {
nodeData = mockData.getNodeByIp(ip);
if (!nodeData) {
return res.status(404).json({
error: 'Node not found',
message: `Node ${ip} not found in mock simulation`
});
}
} else {
// Use primary node
const primaryNode = mockData.getPrimaryNode();
nodeData = mockData.getNodeByIp(primaryNode.ip);
}
const response = {
freeHeap: nodeData.freeHeap,
chipId: nodeData.chipId,
sdkVersion: nodeData.systemInfo.sdkVersion,
cpuFreqMHz: nodeData.systemInfo.cpuFreqMHz,
flashChipSize: nodeData.systemInfo.flashChipSize,
api: nodeData.apiEndpoints
};
res.json(response);
});
// Cluster members endpoint
app.get('/api/cluster/members', (req, res) => {
const allNodes = mockData.getAllNodes();
const members = allNodes.map(node => ({
hostname: node.hostname,
ip: node.ip,
lastSeen: Math.floor(node.lastSeen / 1000), // Convert to seconds
latency: node.latency,
status: node.status,
resources: {
freeHeap: node.freeHeap,
chipId: node.chipId,
sdkVersion: node.systemInfo.sdkVersion,
cpuFreqMHz: node.systemInfo.cpuFreqMHz,
flashChipSize: node.systemInfo.flashChipSize
},
api: node.apiEndpoints
}));
res.json({ members });
});
// Node endpoints endpoint
app.get('/api/node/endpoints', (req, res) => {
const { ip } = req.query;
let nodeData;
if (ip) {
nodeData = mockData.getNodeByIp(ip);
if (!nodeData) {
return res.status(404).json({
error: 'Node not found',
message: `Node ${ip} not found in mock simulation`
});
}
} else {
// Use primary node
const primaryNode = mockData.getPrimaryNode();
nodeData = mockData.getNodeByIp(primaryNode.ip);
}
res.json(nodeData.apiEndpoints);
});
// Generic proxy endpoint
app.post('/api/proxy-call', (req, res) => {
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'
});
}
// Simulate proxy call by routing to appropriate mock endpoint
const nodeData = mockData.getNodeByIp(ip);
if (!nodeData) {
return res.status(404).json({
error: 'Node not found',
message: `Node ${ip} not found in mock simulation`
});
}
// Simulate different responses based on URI
if (uri === '/api/node/status') {
return res.json({
freeHeap: nodeData.freeHeap,
chipId: nodeData.chipId,
sdkVersion: nodeData.systemInfo.sdkVersion,
cpuFreqMHz: nodeData.systemInfo.cpuFreqMHz,
flashChipSize: nodeData.systemInfo.flashChipSize,
api: nodeData.apiEndpoints
});
} else if (uri === '/api/tasks/status') {
const tasks = nodeData.tasks;
const activeTasks = tasks.filter(task => task.enabled && task.running).length;
return res.json({
summary: {
totalTasks: tasks.length,
activeTasks: activeTasks
},
tasks: tasks,
system: {
freeHeap: nodeData.freeHeap,
uptime: nodeData.uptime
}
});
} else if (uri === '/api/monitoring/resources') {
// Return realistic monitoring resources data
const totalHeap = nodeData.systemInfo.flashChipSize || 1048576; // 1MB default
const freeHeap = nodeData.freeHeap;
const usedHeap = totalHeap - freeHeap;
const heapUsagePercent = (usedHeap / totalHeap) * 100;
return res.json({
cpu: {
average_usage: Math.random() * 30 + 10, // 10-40% CPU usage
current_usage: Math.random() * 50 + 5, // 5-55% current usage
frequency_mhz: nodeData.systemInfo.cpuFreqMHz || 80
},
memory: {
total_heap: totalHeap,
free_heap: freeHeap,
used_heap: usedHeap,
heap_usage_percent: heapUsagePercent,
min_free_heap: Math.floor(freeHeap * 0.8), // 80% of current free heap
max_alloc_heap: Math.floor(totalHeap * 0.9) // 90% of total heap
},
filesystem: {
total_bytes: 3145728, // 3MB SPIFFS
used_bytes: Math.floor(3145728 * (0.3 + Math.random() * 0.4)), // 30-70% used
free_bytes: 0 // Will be calculated
},
network: {
wifi_rssi: -30 - Math.floor(Math.random() * 40), // -30 to -70 dBm
wifi_connected: true,
uptime_seconds: nodeData.uptime
},
timestamp: new Date().toISOString()
});
} else {
return res.json({
success: true,
message: `Mock response for ${method} ${uri}`,
node: ip,
timestamp: new Date().toISOString()
});
}
});
// Firmware update endpoint
app.post('/api/node/update', (req, res) => {
// Simulate firmware update
res.json({
status: 'updating',
message: 'Firmware update in progress (mock simulation)'
});
});
// System restart endpoint
app.post('/api/node/restart', (req, res) => {
// Simulate system restart
res.json({
status: 'restarting'
});
});
// Test route
app.get('/test', (req, res) => {
res.send('Mock server is working!');
});
// Serve the mock UI (main UI with modified API client)
app.get('/', (req, res) => {
const filePath = path.join(__dirname, 'mock-ui.html');
console.log('Serving mock UI from:', filePath);
res.sendFile(filePath);
});
// Serve the original mock frontend
app.get('/frontend', (req, res) => {
res.sendFile(path.join(__dirname, 'mock-frontend.html'));
});
// Serve the main UI with modified API client
app.get('/ui', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});
// Serve static files from public directory (after custom routes)
// Only serve static files for specific paths, not the root
app.use('/static', express.static(path.join(__dirname, '../public')));
app.use('/styles', express.static(path.join(__dirname, '../public/styles')));
app.use('/scripts', express.static(path.join(__dirname, '../public/scripts')));
app.use('/vendor', express.static(path.join(__dirname, '../public/vendor')));
// Serve mock API client
app.get('/test/mock-api-client.js', (req, res) => {
res.sendFile(path.join(__dirname, 'mock-api-client.js'));
});
// Serve test page
app.get('/test-page', (req, res) => {
res.sendFile(path.join(__dirname, 'test-page.html'));
});
// Serve favicon to prevent 404 errors
app.get('/favicon.ico', (req, res) => {
res.status(204).end(); // No content
});
// Serve mock server info page
app.get('/info', (req, res) => {
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPORE UI - Mock Server Info</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }
.container { max-width: 800px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #333; text-align: center; }
.status { background: #e8f5e8; padding: 15px; border-radius: 5px; margin: 20px 0; }
.info { background: #f0f8ff; padding: 15px; border-radius: 5px; margin: 20px 0; }
.endpoint { background: #f9f9f9; padding: 10px; margin: 5px 0; border-left: 4px solid #007acc; }
.mock-note { background: #fff3cd; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #ffc107; }
.btn { display: inline-block; padding: 10px 20px; background: #007acc; color: white; text-decoration: none; border-radius: 5px; margin: 5px; }
.btn:hover { background: #005a9e; }
</style>
</head>
<body>
<div class="container">
<h1>🚀 SPORE UI Mock Server</h1>
<div class="mock-note">
<strong>Mock Mode Active:</strong> This is a complete simulation of the SPORE embedded system.
No real hardware or UDP ports are required.
</div>
<div class="status">
<h3>📊 Server Status</h3>
<p><strong>Status:</strong> Running</p>
<p><strong>Port:</strong> ${MOCK_CONFIG.port}</p>
<p><strong>Configuration:</strong> ${MOCK_CONFIG.name}</p>
<p><strong>Mock Nodes:</strong> ${MOCK_CONFIG.nodes.length}</p>
<p><strong>Primary Node:</strong> ${mockData.getPrimaryNode().ip}</p>
</div>
<div class="info">
<h3>🌐 Access Points</h3>
<p><a href="/" class="btn">Mock UI (Port 3002)</a> - Full UI with mock data</p>
<p><a href="/frontend" class="btn">Mock Frontend</a> - Custom mock frontend</p>
<p><a href="/ui" class="btn">Real UI (Port 3002)</a> - Real UI connected to mock server</p>
<p><a href="/api/health" class="btn">API Health</a> - Check server status</p>
</div>
<div class="info">
<h3>🔗 Available Endpoints</h3>
<div class="endpoint"><strong>GET</strong> /api/health - Health check</div>
<div class="endpoint"><strong>GET</strong> /api/discovery/nodes - Discovery status</div>
<div class="endpoint"><strong>GET</strong> /api/tasks/status - Task status</div>
<div class="endpoint"><strong>POST</strong> /api/tasks/control - Control tasks</div>
<div class="endpoint"><strong>GET</strong> /api/node/status - System status</div>
<div class="endpoint"><strong>GET</strong> /api/cluster/members - Cluster members</div>
<div class="endpoint"><strong>POST</strong> /api/proxy-call - Generic proxy</div>
</div>
<div class="info">
<h3>🎮 Mock Features</h3>
<ul>
<li>✅ Multiple simulated SPORE nodes</li>
<li>✅ Realistic data that changes over time</li>
<li>✅ No UDP port conflicts</li>
<li>✅ All API endpoints implemented</li>
<li>✅ Random failures simulation</li>
<li>✅ Primary node rotation</li>
</ul>
</div>
<div class="info">
<h3>🔧 Configuration</h3>
<p>Use npm scripts to change configuration:</p>
<ul>
<li><code>npm run mock:healthy</code> - Healthy cluster (3 nodes)</li>
<li><code>npm run mock:degraded</code> - Degraded cluster (some inactive)</li>
<li><code>npm run mock:large</code> - Large cluster (8 nodes)</li>
<li><code>npm run mock:unstable</code> - Unstable cluster (high failure rate)</li>
<li><code>npm run mock:single</code> - Single node</li>
<li><code>npm run mock:empty</code> - Empty cluster</li>
</ul>
</div>
</div>
</body>
</html>
`);
});
// Start the mock server
const server = app.listen(MOCK_CONFIG.port, () => {
console.log('🚀 SPORE UI Mock Server Started');
console.log('================================');
console.log(`Configuration: ${MOCK_CONFIG.name}`);
console.log(`Description: ${MOCK_CONFIG.description}`);
console.log(`Port: ${MOCK_CONFIG.port}`);
console.log(`URL: http://localhost:${MOCK_CONFIG.port}`);
console.log(`Mock Nodes: ${MOCK_CONFIG.nodes.length}`);
console.log(`Primary Node: ${mockData.getPrimaryNode().ip}`);
console.log('');
console.log('📡 Available Mock Nodes:');
MOCK_CONFIG.nodes.forEach((node, index) => {
console.log(` ${index + 1}. ${node.hostname} (${node.ip}) - ${node.status}`);
});
console.log('');
console.log('🎮 Mock Features:');
console.log(' ✅ No UDP port conflicts');
console.log(' ✅ Realistic data simulation');
console.log(' ✅ All API endpoints');
console.log(` ✅ Time-based data updates (${MOCK_CONFIG.simulation.updateInterval}ms)`);
console.log(` ✅ Random failure simulation (${MOCK_CONFIG.simulation.enableRandomFailures ? 'Enabled' : 'Disabled'})`);
console.log(` ✅ Primary node rotation (${MOCK_CONFIG.simulation.primaryNodeRotation ? 'Enabled' : 'Disabled'})`);
console.log('');
console.log('Press Ctrl+C to stop');
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n\n👋 Mock server stopped. Goodbye!');
server.close(() => {
process.exit(0);
});
});
module.exports = { app, mockData, MOCK_CONFIG };

285
test/mock-test.js Normal file
View File

@@ -0,0 +1,285 @@
#!/usr/bin/env node
/**
* Mock Server Integration Test
*
* Tests the mock server functionality to ensure all endpoints work correctly
*/
const http = require('http');
const MOCK_SERVER_URL = 'http://localhost:3002';
const TIMEOUT = 5000; // 5 seconds
function makeRequest(path, method = 'GET', body = null) {
return new Promise((resolve, reject) => {
const options = {
hostname: 'localhost',
port: 3002,
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.setTimeout(TIMEOUT, () => {
req.destroy();
reject(new Error('Request timeout'));
});
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
async function testEndpoint(name, testFn) {
try {
console.log(`🧪 Testing ${name}...`);
const result = await testFn();
console.log(`${name}: PASS`);
return { name, status: 'PASS', result };
} catch (error) {
console.log(`${name}: FAIL - ${error.message}`);
return { name, status: 'FAIL', error: error.message };
}
}
async function runTests() {
console.log('🚀 SPORE UI Mock Server Integration Tests');
console.log('==========================================');
console.log('');
const results = [];
// Test 1: Health Check
results.push(await testEndpoint('Health Check', async () => {
const response = await makeRequest('/api/health');
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
if (!response.data.status) {
throw new Error('Missing status field');
}
if (!response.data.mock) {
throw new Error('Missing mock field');
}
return response.data;
}));
// Test 2: Discovery Nodes
results.push(await testEndpoint('Discovery Nodes', async () => {
const response = await makeRequest('/api/discovery/nodes');
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
if (!response.data.primaryNode) {
throw new Error('Missing primaryNode field');
}
if (!Array.isArray(response.data.nodes)) {
throw new Error('Nodes should be an array');
}
return response.data;
}));
// Test 3: Task Status
results.push(await testEndpoint('Task Status', async () => {
const response = await makeRequest('/api/tasks/status');
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
if (!response.data.summary) {
throw new Error('Missing summary field');
}
if (!Array.isArray(response.data.tasks)) {
throw new Error('Tasks should be an array');
}
return response.data;
}));
// Test 4: Task Control
results.push(await testEndpoint('Task Control', async () => {
const response = await makeRequest('/api/tasks/control', 'POST', {
task: 'heartbeat',
action: 'status'
});
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
if (!response.data.success) {
throw new Error('Task control should succeed');
}
return response.data;
}));
// Test 5: System Status
results.push(await testEndpoint('System Status', async () => {
const response = await makeRequest('/api/node/status');
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
if (typeof response.data.freeHeap !== 'number') {
throw new Error('freeHeap should be a number');
}
if (!response.data.chipId) {
throw new Error('Missing chipId field');
}
return response.data;
}));
// Test 6: Cluster Members
results.push(await testEndpoint('Cluster Members', async () => {
const response = await makeRequest('/api/cluster/members');
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
if (!Array.isArray(response.data.members)) {
throw new Error('Members should be an array');
}
return response.data;
}));
// Test 7: Random Primary Selection
results.push(await testEndpoint('Random Primary Selection', async () => {
const response = await makeRequest('/api/discovery/random-primary', 'POST', {
timestamp: new Date().toISOString()
});
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
if (!response.data.success) {
throw new Error('Random selection should succeed');
}
return response.data;
}));
// Test 8: Proxy Call
results.push(await testEndpoint('Proxy Call', async () => {
const response = await makeRequest('/api/proxy-call', 'POST', {
ip: '192.168.1.100',
method: 'GET',
uri: '/api/node/status'
});
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
return response.data;
}));
// Test 9: Error Handling
results.push(await testEndpoint('Error Handling', async () => {
const response = await makeRequest('/api/tasks/control', 'POST', {
task: 'nonexistent',
action: 'status'
});
if (response.status !== 404) {
throw new Error(`Expected status 404, got ${response.status}`);
}
return response.data;
}));
// Test 10: Invalid Parameters
results.push(await testEndpoint('Invalid Parameters', async () => {
const response = await makeRequest('/api/tasks/control', 'POST', {
// Missing required fields
});
if (response.status !== 400) {
throw new Error(`Expected status 400, got ${response.status}`);
}
return response.data;
}));
// Print Results
console.log('');
console.log('📊 Test Results');
console.log('===============');
const passed = results.filter(r => r.status === 'PASS').length;
const failed = results.filter(r => r.status === 'FAIL').length;
const total = results.length;
results.forEach(result => {
const status = result.status === 'PASS' ? '✅' : '❌';
console.log(`${status} ${result.name}`);
if (result.status === 'FAIL') {
console.log(` Error: ${result.error}`);
}
});
console.log('');
console.log(`Total: ${total} | Passed: ${passed} | Failed: ${failed}`);
if (failed === 0) {
console.log('');
console.log('🎉 All tests passed! Mock server is working correctly.');
} else {
console.log('');
console.log('⚠️ Some tests failed. Check the mock server configuration.');
}
return failed === 0;
}
// Check if mock server is running
async function checkMockServer() {
try {
const response = await makeRequest('/api/health');
return response.status === 200;
} catch (error) {
return false;
}
}
async function main() {
console.log('🔍 Checking if mock server is running...');
const isRunning = await checkMockServer();
if (!isRunning) {
console.log('❌ Mock server is not running!');
console.log('');
console.log('Please start the mock server first:');
console.log(' npm run mock:healthy');
console.log('');
process.exit(1);
}
console.log('✅ Mock server is running');
console.log('');
const success = await runTests();
process.exit(success ? 0 : 1);
}
// Run tests
if (require.main === module) {
main().catch(error => {
console.error('❌ Test runner failed:', error.message);
process.exit(1);
});
}
module.exports = { runTests, checkMockServer };

181
test/mock-ui.html Normal file
View File

@@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPORE UI - Mock Mode</title>
<!-- Include all the same styles as the main UI -->
<link rel="stylesheet" href="/styles/main.css">
<link rel="stylesheet" href="/styles/theme.css">
<!-- Include D3.js for topology visualization -->
<script src="/vendor/d3.v7.min.js"></script>
<!-- Include framework and components in correct order -->
<script src="/scripts/constants.js"></script>
<script src="/scripts/framework.js"></script>
<script src="/test/mock-api-client.js"></script>
<script src="/scripts/view-models.js"></script>
<script src="/scripts/components/DrawerComponent.js"></script>
<script src="/scripts/components/PrimaryNodeComponent.js"></script>
<script src="/scripts/components/NodeDetailsComponent.js"></script>
<script src="/scripts/components/ClusterMembersComponent.js"></script>
<script src="/scripts/components/FirmwareComponent.js"></script>
<script src="/scripts/components/FirmwareViewComponent.js"></script>
<script src="/scripts/components/ClusterViewComponent.js"></script>
<script src="/scripts/components/ClusterStatusComponent.js"></script>
<script src="/scripts/components/TopologyGraphComponent.js"></script>
<script src="/scripts/components/ComponentsLoader.js"></script>
<script src="/scripts/theme-manager.js"></script>
<script src="/scripts/app.js"></script>
</head>
<body>
<div class="container">
<div class="main-navigation">
<button class="burger-btn" id="burger-btn" aria-label="Menu" title="Menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
</button>
<div class="nav-left">
<button class="nav-tab active" data-view="cluster">🌐 Cluster</button>
<button class="nav-tab" data-view="topology">🔗 Topology</button>
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
</div>
<div class="nav-right">
<div class="theme-switcher">
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
</button>
</div>
<div class="cluster-status">🚀 Cluster Online</div>
</div>
</div>
<div id="cluster-view" class="view-content active">
<div class="cluster-section">
<div class="cluster-header">
<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" id="select-random-primary-btn"
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>
<div class="cluster-header-right">
<button class="refresh-btn" id="refresh-cluster-btn" title="Refresh cluster data">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16"
height="16">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M3 21v-5h5" />
</svg>
</button>
</div>
</div>
<div class="cluster-members" id="cluster-members-container">
<!-- Cluster members will be rendered here -->
</div>
</div>
</div>
<div id="topology-view" class="view-content">
<div class="topology-section">
<div class="topology-graph" id="topology-graph-container">
<!-- Topology graph will be rendered here -->
</div>
</div>
</div>
<div id="firmware-view" class="view-content">
<div class="firmware-section">
<div class="firmware-header">
<h2>Firmware Management</h2>
<button class="refresh-btn" id="refresh-firmware-btn" title="Refresh firmware">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16"
height="16">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M3 21v-5h5" />
</svg>
</button>
</div>
<div class="firmware-content" id="firmware-container">
<!-- Firmware content will be rendered here -->
</div>
</div>
</div>
</div>
<script>
// Mock server status indicator
document.addEventListener('DOMContentLoaded', function() {
const mockStatus = document.getElementById('mock-status');
const mockInfoBtn = document.getElementById('mock-info-btn');
mockInfoBtn.addEventListener('click', function() {
alert('🎭 Mock\n\n' +
'This UI is connected to the mock server on port 3002.\n' +
'All data is simulated and updates automatically.\n\n' +
'To switch to real server:\n' +
'1. Start real server: npm start\n' +
'2. Open: http://localhost:3001\n\n' +
'To change mock configuration:\n' +
'npm run mock:degraded\n' +
'npm run mock:large\n' +
'npm run mock:unstable');
});
});
</script>
<style>
.mock-status {
position: fixed;
top: 20px;
right: 20px;
background: rgba(255, 193, 7, 0.9);
color: #000;
padding: 10px 15px;
border-radius: 25px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 1000;
font-size: 14px;
font-weight: 500;
}
.mock-status-content {
display: flex;
align-items: center;
gap: 8px;
}
.mock-status-icon {
font-size: 16px;
}
.mock-status-text {
white-space: nowrap;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
border-radius: 12px;
}
</style>
</body>
</html>

View File

@@ -1,77 +0,0 @@
#!/usr/bin/env node
/**
* Test script for UDP discovery
* Sends CLUSTER_DISCOVERY messages to test the backend discovery functionality
*/
const dgram = require('dgram');
const client = dgram.createSocket('udp4');
const DISCOVERY_MESSAGE = 'CLUSTER_DISCOVERY';
const TARGET_PORT = 4210;
const BROADCAST_ADDRESS = '255.255.255.255';
// Enable broadcast
client.setBroadcast(true);
function sendDiscoveryMessage() {
const message = Buffer.from(DISCOVERY_MESSAGE);
client.send(message, 0, message.length, TARGET_PORT, BROADCAST_ADDRESS, (err) => {
if (err) {
console.error('Error sending discovery message:', err);
} else {
console.log(`Sent CLUSTER_DISCOVERY message to ${BROADCAST_ADDRESS}:${TARGET_PORT}`);
}
});
}
function sendDiscoveryToSpecificIP(ip) {
const message = Buffer.from(DISCOVERY_MESSAGE);
client.send(message, 0, message.length, TARGET_PORT, ip, (err) => {
if (err) {
console.error(`Error sending discovery message to ${ip}:`, err);
} else {
console.log(`Sent CLUSTER_DISCOVERY message to ${ip}:${TARGET_PORT}`);
}
});
}
// Main execution
const args = process.argv.slice(2);
if (args.length === 0) {
console.log('Usage: node test-discovery.js [broadcast|ip] [count]');
console.log(' broadcast: Send to broadcast address (default)');
console.log(' ip: Send to specific IP address');
console.log(' count: Number of messages to send (default: 1)');
process.exit(1);
}
const target = args[0];
const count = parseInt(args[1]) || 1;
console.log(`Sending ${count} discovery message(s) to ${target === 'broadcast' ? 'broadcast' : target}`);
if (target === 'broadcast') {
for (let i = 0; i < count; i++) {
setTimeout(() => {
sendDiscoveryMessage();
}, i * 1000); // Send one message per second
}
} else {
// Assume it's an IP address
for (let i = 0; i < count; i++) {
setTimeout(() => {
sendDiscoveryToSpecificIP(target);
}, i * 1000); // Send one message per second
}
}
// Close the client after sending all messages
setTimeout(() => {
client.close();
console.log('Test completed');
}, (count + 1) * 1000);

View File

@@ -1,137 +0,0 @@
#!/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);