diff --git a/package.json b/package.json index c273296..11cc955 100644 --- a/package.json +++ b/package.json @@ -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": [], diff --git a/public/scripts/components/ClusterMembersComponent.js b/public/scripts/components/ClusterMembersComponent.js index e5fbb8f..c2642e7 100644 --- a/public/scripts/components/ClusterMembersComponent.js +++ b/public/scripts/components/ClusterMembersComponent.js @@ -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); diff --git a/public/scripts/components/NodeDetailsComponent.js b/public/scripts/components/NodeDetailsComponent.js index 34b613b..bcc0034 100644 --- a/public/scripts/components/NodeDetailsComponent.js +++ b/public/scripts/components/NodeDetailsComponent.js @@ -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 += `
Filesystem: - ${monitoringResources.filesystem.usage_percent ? monitoringResources.filesystem.usage_percent.toFixed(1) + '%' : 'N/A'} (${usedKB}KB / ${totalKB}KB) + ${usagePercent}% (${usedKB}KB / ${totalKB}KB)
`; } - // System Uptime + // System Information if (monitoringResources.system) { html += `
@@ -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 += ` +
+ WiFi RSSI: + ${monitoringResources.network.wifi_rssi || 'N/A'} dBm +
+
+ Network Uptime: + ${uptimeFormatted} +
+ `; + } + html += `
`; } @@ -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); diff --git a/public/scripts/components/TopologyGraphComponent.js b/public/scripts/components/TopologyGraphComponent.js index 0f33c29..2c29285 100644 --- a/public/scripts/components/TopologyGraphComponent.js +++ b/public/scripts/components/TopologyGraphComponent.js @@ -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 `
diff --git a/public/scripts/view-models.js b/public/scripts/view-models.js index fe5bcae..38cfba1 100644 --- a/public/scripts/view-models.js +++ b/public/scripts/view-models.js @@ -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); diff --git a/test/demo-discovery.js b/test/demo-discovery.js deleted file mode 100644 index f96b557..0000000 --- a/test/demo-discovery.js +++ /dev/null @@ -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); \ No newline at end of file diff --git a/test/demo-frontend.js b/test/demo-frontend.js deleted file mode 100644 index 2a67ac0..0000000 --- a/test/demo-frontend.js +++ /dev/null @@ -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); \ No newline at end of file diff --git a/test/mock-api-client.js b/test/mock-api-client.js new file mode 100644 index 0000000..8b87edf --- /dev/null +++ b/test/mock-api-client.js @@ -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); +}); diff --git a/test/mock-cli.js b/test/mock-cli.js new file mode 100644 index 0000000..16a2ba4 --- /dev/null +++ b/test/mock-cli.js @@ -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 [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 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 '); + console.log(' node mock-cli.js info '); + 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 '); + 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 '); + 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 +}; diff --git a/test/mock-configs.js b/test/mock-configs.js new file mode 100644 index 0000000..c17f979 --- /dev/null +++ b/test/mock-configs.js @@ -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 +}; diff --git a/test/mock-server.js b/test/mock-server.js new file mode 100644 index 0000000..1eec2dd --- /dev/null +++ b/test/mock-server.js @@ -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(` + + + + + + SPORE UI - Mock Server Info + + + +
+

๐Ÿš€ SPORE UI Mock Server

+ +
+ Mock Mode Active: This is a complete simulation of the SPORE embedded system. + No real hardware or UDP ports are required. +
+ +
+

๐Ÿ“Š Server Status

+

Status: Running

+

Port: ${MOCK_CONFIG.port}

+

Configuration: ${MOCK_CONFIG.name}

+

Mock Nodes: ${MOCK_CONFIG.nodes.length}

+

Primary Node: ${mockData.getPrimaryNode().ip}

+
+ +
+

๐ŸŒ Access Points

+

Mock UI (Port 3002) - Full UI with mock data

+

Mock Frontend - Custom mock frontend

+

Real UI (Port 3002) - Real UI connected to mock server

+

API Health - Check server status

+
+ +
+

๐Ÿ”— Available Endpoints

+
GET /api/health - Health check
+
GET /api/discovery/nodes - Discovery status
+
GET /api/tasks/status - Task status
+
POST /api/tasks/control - Control tasks
+
GET /api/node/status - System status
+
GET /api/cluster/members - Cluster members
+
POST /api/proxy-call - Generic proxy
+
+ +
+

๐ŸŽฎ Mock Features

+
    +
  • โœ… Multiple simulated SPORE nodes
  • +
  • โœ… Realistic data that changes over time
  • +
  • โœ… No UDP port conflicts
  • +
  • โœ… All API endpoints implemented
  • +
  • โœ… Random failures simulation
  • +
  • โœ… Primary node rotation
  • +
+
+ +
+

๐Ÿ”ง Configuration

+

Use npm scripts to change configuration:

+
    +
  • npm run mock:healthy - Healthy cluster (3 nodes)
  • +
  • npm run mock:degraded - Degraded cluster (some inactive)
  • +
  • npm run mock:large - Large cluster (8 nodes)
  • +
  • npm run mock:unstable - Unstable cluster (high failure rate)
  • +
  • npm run mock:single - Single node
  • +
  • npm run mock:empty - Empty cluster
  • +
+
+
+ + + `); +}); + +// 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 }; diff --git a/test/mock-test.js b/test/mock-test.js new file mode 100644 index 0000000..6888237 --- /dev/null +++ b/test/mock-test.js @@ -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 }; diff --git a/test/mock-ui.html b/test/mock-ui.html new file mode 100644 index 0000000..ab96904 --- /dev/null +++ b/test/mock-ui.html @@ -0,0 +1,181 @@ + + + + + + SPORE UI - Mock Mode + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+
+ Primary Node: + Discovering... + +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+

Firmware Management

+ +
+
+ +
+
+
+
+ + + + + + + diff --git a/test/test-discovery.js b/test/test-discovery.js deleted file mode 100644 index 0ffe263..0000000 --- a/test/test-discovery.js +++ /dev/null @@ -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); \ No newline at end of file diff --git a/test/test-random-selection.js b/test/test-random-selection.js deleted file mode 100644 index c0f2b6c..0000000 --- a/test/test-random-selection.js +++ /dev/null @@ -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); \ No newline at end of file