diff --git a/THEME_IMPROVEMENTS.md b/THEME_IMPROVEMENTS.md deleted file mode 100644 index 5ba6df9..0000000 --- a/THEME_IMPROVEMENTS.md +++ /dev/null @@ -1,67 +0,0 @@ -# Theme Improvements - Better Contrast and Readability - -## Issues Fixed - -### 1. **Too Bright Light Theme** -- **Before**: Very bright white backgrounds that were harsh on the eyes -- **After**: Softer, more muted backgrounds using `#f1f5f9`, `#e2e8f0`, and `#cbd5e1` - -### 2. **Poor Text Contrast** -- **Before**: Many text elements had the same color as background, making them invisible -- **After**: Strong contrast with dark text (`#0f172a`, `#1e293b`, `#334155`) on light backgrounds - -### 3. **Specific Text Color Issues Fixed** -- Member hostnames and IPs now use `var(--text-primary)` for maximum readability -- Latency labels and values have proper contrast -- Navigation tabs are clearly visible in both themes -- Form inputs and dropdowns have proper text contrast -- Status indicators have appropriate colors with good contrast - -## Color Palette Updates - -### Light Theme (Improved) -- **Background**: Softer gradients (`#f1f5f9` โ†’ `#e2e8f0` โ†’ `#cbd5e1`) -- **Text Primary**: `#0f172a` (very dark for maximum contrast) -- **Text Secondary**: `#1e293b` (dark gray) -- **Text Tertiary**: `#334155` (medium gray) -- **Text Muted**: `#475569` (lighter gray) -- **Borders**: Stronger borders (`rgba(30, 41, 59, 0.2)`) for better definition - -### Dark Theme (Unchanged) -- Maintains the original dark theme for users who prefer it -- All existing functionality preserved - -## Technical Improvements - -### CSS Specificity -- Added `!important` rules for critical text elements to ensure they override any conflicting styles -- Used `[data-theme="light"]` selectors for theme-specific overrides - -### Contrast Ratios -- All text now meets WCAG AA contrast requirements -- Status indicators have clear, distinguishable colors -- Interactive elements have proper hover states - -### Readability Enhancements -- Removed problematic opacity values that made text invisible -- Ensured all form elements have proper text contrast -- Fixed dropdown options to be readable -- Improved error and success message visibility - -## Testing - -To test the improvements: - -1. Open `http://localhost:8080` in your browser -2. Click the theme toggle to switch to light theme -3. Verify all text is clearly readable -4. Check that the background is not too bright -5. Test form interactions and dropdowns -6. Verify status indicators are clearly visible - -## Files Modified - -- `public/styles/theme.css` - Updated color palette and added contrast fixes -- `public/styles/main.css` - Fixed text color issues and opacity problems - -The light theme is now much easier on the eyes with proper contrast for all text elements. diff --git a/THEME_README.md b/THEME_README.md deleted file mode 100644 index 1e0f36a..0000000 --- a/THEME_README.md +++ /dev/null @@ -1,144 +0,0 @@ -# SPORE UI Theme System - -This document describes the theme system implementation for SPORE UI, which provides both dark and light themes with a user-friendly theme switcher. - -## Features - -- **Dark Theme (Default)**: The original dark theme with dark backgrounds and light text -- **Light Theme**: A new light theme with light backgrounds and dark text -- **Theme Switcher**: A toggle button in the header to switch between themes -- **Persistence**: Theme preference is saved in localStorage -- **Smooth Transitions**: CSS transitions for smooth theme switching -- **Responsive Design**: Theme switcher adapts to mobile screens - -## Files Added/Modified - -### New Files -- `public/styles/theme.css` - CSS variables and theme definitions -- `public/scripts/theme-manager.js` - JavaScript theme management -- `THEME_README.md` - This documentation - -### Modified Files -- `public/index.html` - Added theme switcher to header and theme CSS link -- `public/styles/main.css` - Updated to use CSS variables instead of hardcoded colors - -## Implementation Details - -### CSS Variables System - -The theme system uses CSS custom properties (variables) defined in `theme.css`: - -```css -:root { - /* Dark theme variables */ - --bg-primary: linear-gradient(135deg, #2c3e50 0%, #34495e 50%, #1a252f 100%); - --text-primary: #ecf0f1; - --border-primary: rgba(255, 255, 255, 0.1); - /* ... more variables */ -} - -[data-theme="light"] { - /* Light theme overrides */ - --bg-primary: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%); - --text-primary: #1e293b; - --border-primary: rgba(30, 41, 59, 0.1); - /* ... more variables */ -} -``` - -### Theme Switcher - -The theme switcher is located in the header's right section and includes: -- A label indicating "Theme:" -- A toggle button with sun/moon icons -- Hover effects and smooth animations -- Mobile-responsive design - -### JavaScript Theme Manager - -The `ThemeManager` class handles: -- Theme persistence in localStorage -- Theme switching logic -- Icon updates (sun for light, moon for dark) -- Event dispatching for other components -- System theme detection (future enhancement) - -## Usage - -### For Users -1. Click the theme toggle button in the header -2. The theme will switch immediately with smooth transitions -3. Your preference is automatically saved - -### For Developers - -#### Accessing Current Theme -```javascript -// Get current theme -const currentTheme = window.themeManager.getCurrentTheme(); - -// Listen for theme changes -window.addEventListener('themeChanged', (event) => { - console.log('Theme changed to:', event.detail.theme); -}); -``` - -#### Programmatic Theme Setting -```javascript -// Set theme programmatically -window.themeManager.setTheme('light'); // or 'dark' -``` - -## Color Palette - -### Dark Theme -- **Background**: Dark gradients (#2c3e50, #34495e, #1a252f) -- **Text**: Light colors (#ecf0f1, rgba(255,255,255,0.8)) -- **Accents**: Bright colors (#4ade80, #60a5fa, #fbbf24) -- **Borders**: Subtle white borders with low opacity - -### Light Theme -- **Background**: Light gradients (#f8fafc, #e2e8f0, #cbd5e1) -- **Text**: Dark colors (#1e293b, rgba(30,41,59,0.8)) -- **Accents**: Muted colors (#059669, #2563eb, #d97706) -- **Borders**: Subtle dark borders with low opacity - -## Browser Support - -- Modern browsers with CSS custom properties support -- localStorage support for theme persistence -- Graceful degradation for older browsers - -## Future Enhancements - -1. **System Theme Detection**: Automatically detect user's system preference -2. **More Themes**: Additional theme options (e.g., high contrast, colorblind-friendly) -3. **Theme Customization**: Allow users to customize accent colors -4. **Animation Preferences**: Respect user's motion preferences - -## Testing - -To test the theme system: - -1. Start the development server: `cd public && python3 -m http.server 8080` -2. Open `http://localhost:8080` in your browser -3. Click the theme toggle button in the header -4. Verify smooth transitions and proper color contrast -5. Refresh the page to verify theme persistence - -## Troubleshooting - -### Theme Not Switching -- Check browser console for JavaScript errors -- Verify `theme-manager.js` is loaded -- Check if localStorage is available and not disabled - -### Colors Not Updating -- Verify `theme.css` is loaded after `main.css` -- Check if CSS variables are supported in your browser -- Ensure the `data-theme` attribute is being set on the `` element - -### Mobile Issues -- Test on actual mobile devices, not just browser dev tools -- Verify touch events are working properly -- Check responsive CSS rules for theme switcher diff --git a/assets/cluster.png b/assets/cluster.png index ce87f94..8f0e534 100644 Binary files a/assets/cluster.png and b/assets/cluster.png differ diff --git a/assets/firmware.png b/assets/firmware.png index 51d7774..343f2a2 100644 Binary files a/assets/firmware.png and b/assets/firmware.png differ diff --git a/assets/topology.png b/assets/topology.png index 8272d9f..1d0059b 100644 Binary files a/assets/topology.png and b/assets/topology.png differ diff --git a/index.js b/index.js index 3725c9f..2083466 100644 --- a/index.js +++ b/index.js @@ -645,7 +645,21 @@ app.post('/api/node/update', async (req, res) => { try { const updateResult = await nodeClient.updateFirmware(uploadedFile.data, uploadedFile.name); - console.log(`Firmware upload to SPORE device ${nodeIp} completed successfully:`, updateResult); + console.log(`Firmware upload to SPORE device ${nodeIp} completed:`, updateResult); + + // Check if the SPORE device reported a failure + if (updateResult && updateResult.status === 'FAIL') { + console.error(`SPORE device ${nodeIp} reported firmware update failure:`, updateResult.message); + return res.status(400).json({ + success: false, + error: 'Firmware update failed', + message: updateResult.message || 'Firmware update failed on device', + nodeIp: nodeIp, + fileSize: uploadedFile.data.length, + filename: uploadedFile.name, + result: updateResult + }); + } res.json({ success: true, 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/index.html b/public/index.html index d77de05..32651e1 100644 --- a/public/index.html +++ b/public/index.html @@ -146,6 +146,7 @@ + diff --git a/public/scripts/api-client.js b/public/scripts/api-client.js index 4d3b817..d75b252 100644 --- a/public/scripts/api-client.js +++ b/public/scripts/api-client.js @@ -94,13 +94,31 @@ class ApiClient { async uploadFirmware(file, nodeIp) { const formData = new FormData(); formData.append('file', file); - return this.request(`/api/node/update`, { + 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: [] + } + }); } } diff --git a/public/scripts/components/ClusterMembersComponent.js b/public/scripts/components/ClusterMembersComponent.js index e6dcb14..c2642e7 100644 --- a/public/scripts/components/ClusterMembersComponent.js +++ b/public/scripts/components/ClusterMembersComponent.js @@ -19,6 +19,69 @@ class ClusterMembersComponent extends Component { this.render(); } }, 200); + + // Drawer state for desktop + this.drawer = new DrawerComponent(); + + // Selection state for highlighting + this.selectedMemberIp = null; + } + + // Determine if we should use desktop drawer behavior + isDesktop() { + return this.drawer.isDesktop(); + } + + + openDrawerForMember(memberIp) { + // Set selected member and update highlighting + this.setSelectedMember(memberIp); + + // Get display name for drawer title + let displayName = memberIp; + try { + const members = (this.viewModel && typeof this.viewModel.get === 'function') ? this.viewModel.get('members') : []; + const member = Array.isArray(members) ? members.find(m => m && m.ip === memberIp) : null; + const hostname = (member && member.hostname) ? member.hostname : ''; + const ip = (member && member.ip) ? member.ip : memberIp; + + if (hostname && ip) { + displayName = `${hostname} - ${ip}`; + } else if (hostname) { + displayName = hostname; + } else if (ip) { + displayName = ip; + } + } catch (_) { + // no-op if anything goes wrong, default title remains + } + + // Open drawer with content callback + this.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => { + // Load and mount NodeDetails into drawer + const nodeDetailsVM = new NodeDetailsViewModel(); + const nodeDetailsComponent = new NodeDetailsComponent(contentContainer, nodeDetailsVM, this.eventBus); + setActiveComponent(nodeDetailsComponent); + + nodeDetailsVM.loadNodeDetails(memberIp).then(() => { + nodeDetailsComponent.mount(); + }).catch((error) => { + logger.error('Failed to load node details for drawer:', error); + contentContainer.innerHTML = ` +
+ Error loading node details:
+ ${this.escapeHtml(error.message)} +
+ `; + }); + }, null, () => { + // Close callback - clear selection when drawer is closed + this.clearSelectedMember(); + }); + } + + closeDrawer() { + this.drawer.closeDrawer(); } mount() { @@ -235,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}`; @@ -339,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); @@ -409,8 +472,16 @@ class ClusterMembersComponent extends Component { this.addEventListener(card, 'click', async (e) => { if (e.target === expandIcon) return; - const isExpanding = !card.classList.contains('expanded'); + // On desktop, open slide-in drawer instead of inline expand + if (this.isDesktop()) { + e.preventDefault(); + e.stopPropagation(); + this.openDrawerForMember(memberIp); + return; + } + // Mobile/low-res: keep inline expand/collapse + const isExpanding = !card.classList.contains('expanded'); if (isExpanding) { await this.expandCard(card, memberIp, memberDetails); } else { @@ -422,9 +493,11 @@ class ClusterMembersComponent extends Component { if (expandIcon) { this.addEventListener(expandIcon, 'click', async (e) => { e.stopPropagation(); - + if (this.isDesktop()) { + this.openDrawerForMember(memberIp); + return; + } const isExpanding = !card.classList.contains('expanded'); - if (isExpanding) { await this.expandCard(card, memberIp, memberDetails); } else { @@ -623,6 +696,32 @@ class ClusterMembersComponent extends Component { // Don't re-render on resume - maintain current state return false; } + + // Set selected member and update highlighting + setSelectedMember(memberIp) { + // Clear previous selection + this.clearSelectedMember(); + + // Set new selection + this.selectedMemberIp = memberIp; + + // Add selected class to the member card + const card = this.findElement(`[data-member-ip="${memberIp}"]`); + if (card) { + card.classList.add('selected'); + } + } + + // Clear selected member highlighting + clearSelectedMember() { + if (this.selectedMemberIp) { + const card = this.findElement(`[data-member-ip="${this.selectedMemberIp}"]`); + if (card) { + card.classList.remove('selected'); + } + this.selectedMemberIp = null; + } + } } window.ClusterMembersComponent = ClusterMembersComponent; \ No newline at end of file diff --git a/public/scripts/components/ComponentsLoader.js b/public/scripts/components/ComponentsLoader.js index 4e8b580..a9d7230 100644 --- a/public/scripts/components/ComponentsLoader.js +++ b/public/scripts/components/ComponentsLoader.js @@ -1,7 +1,7 @@ (function(){ // Simple readiness flag once all component constructors are present function allReady(){ - return !!(window.PrimaryNodeComponent && window.ClusterMembersComponent && window.NodeDetailsComponent && window.FirmwareComponent && window.ClusterViewComponent && window.FirmwareViewComponent && window.TopologyGraphComponent && window.MemberCardOverlayComponent && window.ClusterStatusComponent); + return !!(window.PrimaryNodeComponent && window.ClusterMembersComponent && window.NodeDetailsComponent && window.FirmwareComponent && window.ClusterViewComponent && window.FirmwareViewComponent && window.TopologyGraphComponent && window.MemberCardOverlayComponent && window.ClusterStatusComponent && window.DrawerComponent); } window.waitForComponentsReady = function(timeoutMs = 5000){ return new Promise((resolve, reject) => { diff --git a/public/scripts/components/DrawerComponent.js b/public/scripts/components/DrawerComponent.js new file mode 100644 index 0000000..56b80e3 --- /dev/null +++ b/public/scripts/components/DrawerComponent.js @@ -0,0 +1,138 @@ +// Reusable Drawer Component for desktop slide-in panels +class DrawerComponent { + constructor() { + this.detailsDrawer = null; + this.detailsDrawerContent = null; + this.detailsDrawerBackdrop = null; + this.activeDrawerComponent = null; + this.onCloseCallback = null; + } + + // Determine if we should use desktop drawer behavior + isDesktop() { + try { + return window && window.innerWidth >= 1024; // desktop threshold + } catch (_) { + return false; + } + } + + ensureDrawer() { + if (this.detailsDrawer) return; + + // Create backdrop + this.detailsDrawerBackdrop = document.createElement('div'); + this.detailsDrawerBackdrop.className = 'details-drawer-backdrop'; + document.body.appendChild(this.detailsDrawerBackdrop); + + // Create drawer + this.detailsDrawer = document.createElement('div'); + this.detailsDrawer.className = 'details-drawer'; + + // Header with close button + const header = document.createElement('div'); + header.className = 'details-drawer-header'; + header.innerHTML = ` +
Node Details
+ + `; + this.detailsDrawer.appendChild(header); + + // Content container + this.detailsDrawerContent = document.createElement('div'); + this.detailsDrawerContent.className = 'details-drawer-content'; + this.detailsDrawer.appendChild(this.detailsDrawerContent); + + document.body.appendChild(this.detailsDrawer); + + // Close handlers + const close = () => this.closeDrawer(); + header.querySelector('.drawer-close').addEventListener('click', close); + this.detailsDrawerBackdrop.addEventListener('click', close); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') close(); + }); + } + + openDrawer(title, contentCallback, errorCallback, onCloseCallback) { + this.ensureDrawer(); + this.onCloseCallback = onCloseCallback; + + // Set drawer title + const titleEl = this.detailsDrawer.querySelector('.drawer-title'); + if (titleEl) { + titleEl.textContent = title; + } + + // Clear previous component if any + if (this.activeDrawerComponent && typeof this.activeDrawerComponent.unmount === 'function') { + try { + this.activeDrawerComponent.unmount(); + } catch (_) {} + } + this.detailsDrawerContent.innerHTML = '
Loading detailed information...
'; + + // Execute content callback + try { + contentCallback(this.detailsDrawerContent, (component) => { + this.activeDrawerComponent = component; + }); + } catch (error) { + logger.error('Failed to load drawer content:', error); + if (errorCallback) { + errorCallback(error); + } else { + this.detailsDrawerContent.innerHTML = ` +
+ Error loading content:
+ ${this.escapeHtml ? this.escapeHtml(error.message) : error.message} +
+ `; + } + } + + // Open drawer + this.detailsDrawer.classList.add('open'); + this.detailsDrawerBackdrop.classList.add('visible'); + } + + closeDrawer() { + if (this.detailsDrawer) this.detailsDrawer.classList.remove('open'); + if (this.detailsDrawerBackdrop) this.detailsDrawerBackdrop.classList.remove('visible'); + + // Call close callback if provided + if (this.onCloseCallback) { + this.onCloseCallback(); + this.onCloseCallback = null; + } + } + + // Clean up drawer elements + destroy() { + if (this.detailsDrawer && this.detailsDrawer.parentNode) { + this.detailsDrawer.parentNode.removeChild(this.detailsDrawer); + } + if (this.detailsDrawerBackdrop && this.detailsDrawerBackdrop.parentNode) { + this.detailsDrawerBackdrop.parentNode.removeChild(this.detailsDrawerBackdrop); + } + + this.detailsDrawer = null; + this.detailsDrawerContent = null; + this.detailsDrawerBackdrop = null; + this.activeDrawerComponent = null; + } + + // Helper method for HTML escaping (can be overridden) + escapeHtml(text) { + if (typeof text !== 'string') return text; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} + +window.DrawerComponent = DrawerComponent; diff --git a/public/scripts/components/NodeDetailsComponent.js b/public/scripts/components/NodeDetailsComponent.js index cc23619..bcc0034 100644 --- a/public/scripts/components/NodeDetailsComponent.js +++ b/public/scripts/components/NodeDetailsComponent.js @@ -2,6 +2,7 @@ class NodeDetailsComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); + this.suppressLoadingUI = false; } setupViewModelListeners() { @@ -11,12 +12,13 @@ class NodeDetailsComponent extends Component { this.subscribeToProperty('error', this.handleErrorUpdate.bind(this)); this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this)); this.subscribeToProperty('endpoints', this.handleEndpointsUpdate.bind(this)); + this.subscribeToProperty('monitoringResources', this.handleMonitoringResourcesUpdate.bind(this)); } // Handle node status update with state preservation handleNodeStatusUpdate(newStatus, previousStatus) { if (newStatus && !this.viewModel.get('isLoading')) { - this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('endpoints')); + this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('endpoints'), this.viewModel.get('monitoringResources')); } } @@ -24,13 +26,14 @@ class NodeDetailsComponent extends Component { handleTasksUpdate(newTasks, previousTasks) { const nodeStatus = this.viewModel.get('nodeStatus'); if (nodeStatus && !this.viewModel.get('isLoading')) { - this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('endpoints')); + this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('endpoints'), this.viewModel.get('monitoringResources')); } } // Handle loading state update handleLoadingUpdate(isLoading) { if (isLoading) { + if (this.suppressLoadingUI) return; this.renderLoading('
Loading detailed information...
'); } } @@ -53,7 +56,17 @@ class NodeDetailsComponent extends Component { const nodeStatus = this.viewModel.get('nodeStatus'); const tasks = this.viewModel.get('tasks'); if (nodeStatus && !this.viewModel.get('isLoading')) { - this.renderNodeDetails(nodeStatus, tasks, newEndpoints); + this.renderNodeDetails(nodeStatus, tasks, newEndpoints, this.viewModel.get('monitoringResources')); + } + } + + // Handle monitoring resources update with state preservation + handleMonitoringResourcesUpdate(newResources, previousResources) { + const nodeStatus = this.viewModel.get('nodeStatus'); + const tasks = this.viewModel.get('tasks'); + const endpoints = this.viewModel.get('endpoints'); + if (nodeStatus && !this.viewModel.get('isLoading')) { + this.renderNodeDetails(nodeStatus, tasks, endpoints, newResources); } } @@ -63,6 +76,7 @@ class NodeDetailsComponent extends Component { const isLoading = this.viewModel.get('isLoading'); const error = this.viewModel.get('error'); const endpoints = this.viewModel.get('endpoints'); + const monitoringResources = this.viewModel.get('monitoringResources'); if (isLoading) { this.renderLoading('
Loading detailed information...
'); @@ -79,44 +93,41 @@ class NodeDetailsComponent extends Component { return; } - this.renderNodeDetails(nodeStatus, tasks, endpoints); + this.renderNodeDetails(nodeStatus, tasks, endpoints, monitoringResources); } - renderNodeDetails(nodeStatus, tasks, endpoints) { + renderNodeDetails(nodeStatus, tasks, endpoints, monitoringResources) { // Use persisted active tab from the view model, default to 'status' const activeTab = (this.viewModel && typeof this.viewModel.get === 'function' && this.viewModel.get('activeTab')) || 'status'; logger.debug('NodeDetailsComponent: Rendering with activeTab:', activeTab); - + + // Build labels bar (above tabs) + const labelsObj = (nodeStatus && nodeStatus.labels) ? nodeStatus.labels : null; + const labelsBar = (labelsObj && Object.keys(labelsObj).length) + ? `
${Object.entries(labelsObj) + .map(([k, v]) => `${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}`) + .join('')}
` + : ''; + const html = ` + ${labelsBar}
+
-
- Free Heap: - ${Math.round(nodeStatus.freeHeap / 1024)}KB -
-
- Chip ID: - ${nodeStatus.chipId} -
-
- SDK Version: - ${nodeStatus.sdkVersion} -
-
- CPU Frequency: - ${nodeStatus.cpuFreqMHz}MHz -
-
- Flash Size: - ${Math.round(nodeStatus.flashChipSize / 1024)}KB -
+ ${this.renderStatusTab(nodeStatus, monitoringResources)}
@@ -136,6 +147,7 @@ class NodeDetailsComponent extends Component { this.setHTML('', html); this.setupTabs(); + this.setupTabRefreshButton(); // Restore last active tab from view model if available const restored = this.viewModel && typeof this.viewModel.get === 'function' ? this.viewModel.get('activeTab') : null; if (restored) { @@ -144,6 +156,218 @@ class NodeDetailsComponent extends Component { this.setupFirmwareUpload(); } + setupTabRefreshButton() { + const btn = this.findElement('.tab-refresh-btn'); + if (!btn) return; + this.addEventListener(btn, 'click', async (e) => { + e.stopPropagation(); + const original = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ` + + + + + + `; + + try { + const activeTab = (this.viewModel && typeof this.viewModel.get === 'function') ? (this.viewModel.get('activeTab') || 'status') : 'status'; + const nodeIp = (this.viewModel && typeof this.viewModel.get === 'function') ? this.viewModel.get('nodeIp') : null; + this.suppressLoadingUI = true; + + if (activeTab === 'endpoints' && typeof this.viewModel.loadEndpointsData === 'function') { + await this.viewModel.loadEndpointsData(); + } else if (activeTab === 'tasks' && typeof this.viewModel.loadTasksData === 'function') { + await this.viewModel.loadTasksData(); + } else if (activeTab === 'status' && typeof this.viewModel.loadMonitoringResources === 'function') { + // status tab: load monitoring resources + await this.viewModel.loadMonitoringResources(); + } else { + // firmware: refresh core node details + if (nodeIp && typeof this.viewModel.loadNodeDetails === 'function') { + await this.viewModel.loadNodeDetails(nodeIp); + } + } + } catch (err) { + logger.error('Tab refresh failed:', err); + } finally { + this.suppressLoadingUI = false; + btn.disabled = false; + btn.innerHTML = original; + } + }); + } + + renderStatusTab(nodeStatus, monitoringResources) { + let html = ''; + + // Add gauges section if monitoring resources are available + if (monitoringResources) { + html += this.renderResourceGauges(monitoringResources); + } + + html += ` +
+ Chip ID: + ${nodeStatus.chipId} +
+
+ SDK Version: + ${nodeStatus.sdkVersion} +
+
+ CPU Frequency: + ${nodeStatus.cpuFreqMHz}MHz +
+
+ Flash Size: + ${Math.round(nodeStatus.flashChipSize / 1024)}KB +
+ `; + + // Add monitoring resources if available + if (monitoringResources) { + html += ` +
+
Resources
+ `; + + // CPU Usage + if (monitoringResources.cpu) { + html += ` +
+ CPU Usage (Avg): + ${monitoringResources.cpu.average_usage ? monitoringResources.cpu.average_usage.toFixed(1) + '%' : 'N/A'} +
+ `; + } + + // Memory Usage + if (monitoringResources.memory) { + const heapUsagePercent = monitoringResources.memory.heap_usage_percent || 0; + const totalHeap = monitoringResources.memory.total_heap || 0; + const usedHeap = totalHeap - (monitoringResources.memory.free_heap || 0); + const usedHeapKB = Math.round(usedHeap / 1024); + const totalHeapKB = Math.round(totalHeap / 1024); + + html += ` +
+ Heap Usage: + ${heapUsagePercent.toFixed(1)}% (${usedHeapKB}KB / ${totalHeapKB}KB) +
+ `; + } + + // Filesystem Usage + 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: + ${usagePercent}% (${usedKB}KB / ${totalKB}KB) +
+ `; + } + + // System Information + if (monitoringResources.system) { + html += ` +
+ Uptime: + ${monitoringResources.system.uptime_formatted || 'N/A'} +
+ `; + } + + // 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 += `
`; + } + + return html; + } + + renderResourceGauges(monitoringResources) { + // 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 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); + const filesystemTotalKB = Math.round(filesystemTotal / 1024); + + // Helper function to get color class based on percentage + const getColorClass = (percentage) => { + const numPercentage = parseFloat(percentage); + + if (numPercentage === 0 || isNaN(numPercentage)) return 'gauge-empty'; + if (numPercentage < 50) return 'gauge-green'; + if (numPercentage < 80) return 'gauge-yellow'; + return 'gauge-red'; + }; + + return ` +
+
+
+
+
+
${cpuUsage.toFixed(1)}%
+
CPU
+
+
+
+
+
+
+
+
+
${heapUsage.toFixed(1)}%
+
Heap
+
+
+
+
+
+
+
+
+
${filesystemUsage.toFixed(1)}%
+
Storage
+ +
+
+
+
+
+ `; + } + renderEndpointsTab(endpoints) { if (!endpoints || !Array.isArray(endpoints.endpoints) || endpoints.endpoints.length === 0) { return ` @@ -475,9 +699,14 @@ class NodeDetailsComponent extends Component { uploadBtn.disabled = true; uploadBtn.textContent = 'โณ Uploading...'; - // Get the member IP from the card + // Get the member IP from the card if available, otherwise fallback to view model state const memberCard = this.container.closest('.member-card'); - const memberIp = memberCard.dataset.memberIp; + let memberIp = null; + if (memberCard && memberCard.dataset && memberCard.dataset.memberIp) { + memberIp = memberCard.dataset.memberIp; + } else if (this.viewModel && typeof this.viewModel.get === 'function') { + memberIp = this.viewModel.get('nodeIp'); + } if (!memberIp) { throw new Error('Could not determine target node IP address'); diff --git a/public/scripts/components/TopologyGraphComponent.js b/public/scripts/components/TopologyGraphComponent.js index cf6cc4e..2c29285 100644 --- a/public/scripts/components/TopologyGraphComponent.js +++ b/public/scripts/components/TopologyGraphComponent.js @@ -9,6 +9,106 @@ class TopologyGraphComponent extends Component { this.width = 0; // Will be set dynamically based on container size this.height = 0; // Will be set dynamically based on container size this.isInitialized = false; + + // Drawer state for desktop reuse (same pattern as ClusterMembersComponent) + this.drawer = new DrawerComponent(); + + // Tooltip for labels on hover + this.tooltipEl = null; + } + + // Determine desktop threshold + isDesktop() { + return this.drawer.isDesktop(); + } + + + openDrawerForNode(nodeData) { + // Get display name for drawer title + let displayName = 'Node Details'; + try { + const hostname = nodeData.hostname || ''; + const ip = nodeData.ip || ''; + + if (hostname && ip) { + displayName = `${hostname} - ${ip}`; + } else if (hostname) { + displayName = hostname; + } else if (ip) { + displayName = ip; + } + } catch (_) {} + + // Open drawer with content callback + this.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => { + // Mount NodeDetailsComponent + const nodeDetailsVM = new NodeDetailsViewModel(); + const nodeDetailsComponent = new NodeDetailsComponent(contentContainer, nodeDetailsVM, this.eventBus); + setActiveComponent(nodeDetailsComponent); + + const ip = nodeData.ip || nodeData.id; + nodeDetailsVM.loadNodeDetails(ip).then(() => { + nodeDetailsComponent.mount(); + }).catch((error) => { + logger.error('Failed to load node details (topology drawer):', error); + contentContainer.innerHTML = ` +
+ Error loading node details:
+ ${this.escapeHtml(error.message)} +
+ `; + }); + }); + } + + closeDrawer() { + this.drawer.closeDrawer(); + } + + // Tooltip helpers + ensureTooltip() { + if (this.tooltipEl) return; + const el = document.createElement('div'); + el.className = 'topology-tooltip'; + document.body.appendChild(el); + this.tooltipEl = el; + } + + showTooltip(nodeData, pageX, pageY) { + this.ensureTooltip(); + const labels = (nodeData && nodeData.labels) ? nodeData.labels : ((nodeData && nodeData.resources) ? nodeData.resources : null); + if (!labels || Object.keys(labels).length === 0) { + this.hideTooltip(); + return; + } + const chips = Object.entries(labels) + .map(([k, v]) => `${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}`) + .join(''); + this.tooltipEl.innerHTML = `
${chips}
`; + this.positionTooltip(pageX, pageY); + this.tooltipEl.classList.add('visible'); + } + + positionTooltip(pageX, pageY) { + if (!this.tooltipEl) return; + const offset = 12; + let left = pageX + offset; + let top = pageY + offset; + const { innerWidth, innerHeight } = window; + const rect = this.tooltipEl.getBoundingClientRect(); + if (left + rect.width > innerWidth - 8) left = pageX - rect.width - offset; + if (top + rect.height > innerHeight - 8) top = pageY - rect.height - offset; + this.tooltipEl.style.left = `${Math.max(8, left)}px`; + this.tooltipEl.style.top = `${Math.max(8, top)}px`; + } + + moveTooltip(pageX, pageY) { + if (!this.tooltipEl || !this.tooltipEl.classList.contains('visible')) return; + this.positionTooltip(pageX, pageY); + } + + hideTooltip() { + if (this.tooltipEl) this.tooltipEl.classList.remove('visible'); } updateDimensions(container) { @@ -287,15 +387,25 @@ class TopologyGraphComponent extends Component { node.append('text') .text(d => d.ip) .attr('x', 15) - .attr('y', 20) + .attr('y', 22) .attr('font-size', '11px') .attr('fill', 'var(--text-secondary)'); + // App label (between IP and Status) + node.append('text') + .text(d => (d.labels && d.labels.app) ? String(d.labels.app) : '') + .attr('x', 15) + .attr('y', 38) + .attr('font-size', '11px') + .attr('fill', 'var(--text-secondary)') + .attr('font-weight', '500') + .attr('display', d => (d.labels && d.labels.app) ? null : 'none'); + // Status text node.append('text') .text(d => d.status) .attr('x', 15) - .attr('y', 35) + .attr('y', 56) .attr('font-size', '11px') .attr('fill', d => this.getNodeColor(d.status)) .attr('font-weight', '600'); @@ -345,19 +455,31 @@ class TopologyGraphComponent extends Component { node.on('click', (event, d) => { this.viewModel.selectNode(d.id); this.updateSelection(d.id); - this.showMemberCardOverlay(d); + if (this.isDesktop()) { + // Desktop: open slide-in drawer, reuse NodeDetailsComponent + this.openDrawerForNode(d); + } else { + // Mobile/low-res: keep existing overlay + this.showMemberCardOverlay(d); + } }); node.on('mouseover', (event, d) => { d3.select(event.currentTarget).select('circle') .attr('r', d => this.getNodeRadius(d.status) + 4) .attr('stroke-width', 3); + this.showTooltip(d, event.pageX, event.pageY); }); node.on('mouseout', (event, d) => { d3.select(event.currentTarget).select('circle') .attr('r', d => this.getNodeRadius(d.status)) .attr('stroke-width', 2); + this.hideTooltip(); + }); + + node.on('mousemove', (event, d) => { + this.moveTooltip(event.pageX, event.pageY); }); link.on('mouseover', (event, d) => { @@ -600,7 +722,7 @@ class TopologyGraphComponent extends Component { hostname: nodeData.hostname, status: this.normalizeStatus(nodeData.status), latency: nodeData.latency, - labels: nodeData.resources || {} + labels: (nodeData.labels && typeof nodeData.labels === 'object') ? nodeData.labels : (nodeData.resources || {}) }; this.memberOverlayComponent.show(memberData); @@ -709,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 44904b8..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 @@ -220,7 +220,8 @@ class NodeDetailsViewModel extends ViewModel { activeTab: 'status', nodeIp: null, endpoints: null, - tasksSummary: null + tasksSummary: null, + monitoringResources: null }); } @@ -250,6 +251,9 @@ class NodeDetailsViewModel extends ViewModel { // Load endpoints data await this.loadEndpointsData(); + // Load monitoring resources data + await this.loadMonitoringResources(); + } catch (error) { console.error('Failed to load node details:', error); this.set('error', error.message); @@ -277,13 +281,29 @@ 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); } } + // Load monitoring resources data with state preservation + async loadMonitoringResources() { + try { + const ip = this.get('nodeIp'); + const response = await window.apiClient.getMonitoringResources(ip); + // 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); + this.set('monitoringResources', null); + } + } + // Invoke an endpoint against this node async callEndpoint(method, uri, params) { const ip = this.get('nodeIp'); @@ -536,6 +556,8 @@ class TopologyViewModel extends ViewModel { ip: member.ip, status: member.status || 'UNKNOWN', latency: member.latency || 0, + // Preserve both legacy 'resources' and preferred 'labels' + labels: (member.labels && typeof member.labels === 'object') ? member.labels : (member.resources || {}), resources: member.resources || {}, x: Math.random() * 1200 + 100, // Better spacing for 1400px width y: Math.random() * 800 + 100 // Better spacing for 1000px height diff --git a/public/styles/main.css b/public/styles/main.css index 42835eb..8e1d7b8 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -391,6 +391,173 @@ p { opacity: 1; } +/* Monitoring Section Styles */ +.monitoring-section { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.monitoring-header { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Resource Gauges Styles */ +.resource-gauges { + display: flex; + justify-content: space-around; + align-items: center; + margin-bottom: 2rem; + padding: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.gauge-container { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + max-width: 110px; +} + +.gauge { + position: relative; + width: 120px; + height: 120px; + margin-bottom: 0.3rem; +} + +.gauge-circle { + position: relative; + width: 100%; + height: 100%; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; + position: relative; + transition: all 0.3s ease; + padding: 6px; +} + +.gauge-circle::before { + content: ''; + position: absolute; + top: 6px; + left: 6px; + width: calc(100% - 12px); + height: calc(100% - 12px); + border-radius: 50%; + background: var(--bg-primary); + z-index: 1; +} + +.gauge-circle::after { + content: ''; + position: absolute; + top: 6px; + left: 6px; + width: calc(100% - 12px); + height: calc(100% - 12px); + border-radius: 50%; + background: conic-gradient( + from -90deg, + transparent 0deg, + transparent calc(var(--percentage) * 3.6deg), + var(--bg-primary) calc(var(--percentage) * 3.6deg), + var(--bg-primary) 360deg + ); + z-index: 2; +} + +.gauge-text { + position: relative; + z-index: 3; + text-align: center; + color: var(--text-primary); +} + +.gauge-value { + font-size: 1.2rem; + font-weight: 600; + line-height: 1; + margin-bottom: 0.1rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.gauge-label { + font-size: 0.75rem; + font-weight: 500; + opacity: 0.7; + text-transform: uppercase; + letter-spacing: 0.3px; + margin-bottom: 0.1rem; +} + +.gauge-detail { + font-size: 0.65rem; + opacity: 0.5; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-weight: 400; +} + +/* Dynamic gauge colors based on percentage */ +.gauge-empty .gauge-circle { + background: rgba(255, 255, 255, 0.1); +} + +.gauge-green .gauge-circle { + background: conic-gradient( + from -90deg, + var(--accent-success) 0deg, + var(--accent-success) calc(var(--percentage) * 3.6deg), + rgba(255, 255, 255, 0.1) calc(var(--percentage) * 3.6deg), + rgba(255, 255, 255, 0.1) 360deg + ); +} + +.gauge-yellow .gauge-circle { + background: conic-gradient( + from -90deg, + var(--accent-warning) 0deg, + var(--accent-warning) calc(var(--percentage) * 3.6deg), + rgba(255, 255, 255, 0.1) calc(var(--percentage) * 3.6deg), + rgba(255, 255, 255, 0.1) 360deg + ); +} + +.gauge-red .gauge-circle { + background: conic-gradient( + from -90deg, + var(--accent-error) 0deg, + var(--accent-error) calc(var(--percentage) * 3.6deg), + rgba(255, 255, 255, 0.1) calc(var(--percentage) * 3.6deg), + rgba(255, 255, 255, 0.1) 360deg + ); +} + +/* Gauge value color based on usage level */ +.gauge[data-percentage] .gauge-value { + color: var(--text-primary); +} + +/* Hover effects */ +.gauge:hover .gauge-circle { + transform: scale(1.05); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); +} + +.gauge:hover .gauge-value { + color: var(--accent-primary); +} + .api-endpoints { margin-top: 1rem; } @@ -413,7 +580,6 @@ p { } .endpoint-item:hover { - background: rgba(0, 0, 0, 0.25); border-color: rgba(255, 255, 255, 0.15); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } @@ -2185,6 +2351,29 @@ p { border-bottom: none; } +/* Refresh button aligned to the right of tabs, blends with tab header */ +.tabs-header .tab-refresh-btn { + margin-left: auto; + background: transparent; + border: 1px solid transparent; + color: var(--text-secondary); + padding: 0.4rem; + border-radius: 8px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.tabs-header .tab-refresh-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.12); + color: var(--text-primary); +} +.tabs-header .tab-refresh-btn:disabled { + opacity: 0.6; + cursor: default; +} + .tab-button { background: transparent; border: 1px solid transparent; @@ -2822,6 +3011,13 @@ p { display: none; } +/* Hide expand icon on desktop screens */ +@media (min-width: 1025px) { + .expand-icon { + display: none; + } +} + /* Ensure expanded state is visually clear */ .member-overlay-body .member-card.expanded .member-details { display: block; @@ -2860,6 +3056,100 @@ p { display: inline-flex; } +/* Desktop slide-in details drawer */ +.details-drawer-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.3); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 999; +} +.details-drawer-backdrop.visible { opacity: 1; pointer-events: auto; } + +.details-drawer { + position: fixed; + top: 0; + right: 0; + height: 100vh; + width: clamp(33.333vw, 650px, 90vw); + background: var(--bg-primary); + color: var(--text-primary); + border-left: 1px solid var(--border-primary); + box-shadow: var(--shadow-primary); + transform: translateX(100%); + transition: transform 0.25s ease; + z-index: 1000; + display: flex; + flex-direction: column; +} +.details-drawer.open { transform: translateX(0); } + +.details-drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-secondary); + background: var(--bg-secondary); +} +.details-drawer-header .drawer-title { + font-weight: 600; +} +.drawer-close { + background: transparent; + border: 1px solid var(--border-secondary); + color: var(--text-secondary); + border-radius: 8px; + padding: 0.35rem; + cursor: pointer; +} +.drawer-close:hover { + background: var(--bg-hover); + color: var(--text-primary); +} +.drawer-close svg { width: 18px; height: 18px; } + +.details-drawer-content { + padding: 1rem; + overflow: auto; +} + +/* Only enable drawer on wider screens; on small keep inline cards */ +@media (max-width: 1023px) { + .details-drawer, + .details-drawer-backdrop { display: none; } +} + +/* Topology hover tooltip for labels */ +.topology-tooltip { + position: fixed; + z-index: 1001; + pointer-events: none; + background: var(--bg-primary); + border: 1px solid var(--border-primary); + box-shadow: var(--shadow-secondary); + border-radius: 10px; + padding: 0.5rem 0.6rem; + opacity: 0; + transform: translateY(4px); + transition: opacity 0.12s ease, transform 0.12s ease; +} +.topology-tooltip.visible { + opacity: 1; + transform: translateY(0); +} +.topology-tooltip .member-labels { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +.topology-tooltip .label-chip { + font-size: 0.72rem; + padding: 0.2rem 0.5rem; +} + /* Labels section styling in node details */ .detail-section { margin-top: 20px; @@ -2917,6 +3207,37 @@ p { animation: highlight-pulse 2s ease-in-out; } +/* Selected member card styling */ +.member-card.selected { + border-color: #3b82f6 !important; + border-width: 2px !important; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2), 0 8px 25px rgba(0, 0, 0, 0.2) !important; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, var(--bg-tertiary) 100%) !important; + z-index: 10 !important; +} + +.member-card.selected::before { + opacity: 0.8 !important; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%) !important; +} + +.member-card.selected .member-header { + background: rgba(59, 130, 246, 0.05) !important; +} + +.member-card.selected .member-status { + filter: brightness(1.2) !important; +} + +.member-card.selected .member-hostname { + color: #3b82f6 !important; + font-weight: 600 !important; +} + +.member-card.selected .member-ip { + color: #60a5fa !important; +} + @keyframes highlight-pulse { 0%, 100% { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); diff --git a/public/styles/theme.css b/public/styles/theme.css index 084e05e..6e7bd76 100644 --- a/public/styles/theme.css +++ b/public/styles/theme.css @@ -236,6 +236,33 @@ display: none; } +/* Selected member card styling for light theme */ +[data-theme="light"] .member-card.selected { + border-color: #3b82f6 !important; + border-width: 2px !important; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2), 0 8px 25px rgba(148, 163, 184, 0.15) !important; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(255, 255, 255, 0.15) 100%) !important; +} + +[data-theme="light"] .member-card.selected::before { + display: block !important; + opacity: 0.6 !important; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.03) 100%) !important; +} + +[data-theme="light"] .member-card.selected .member-header { + background: rgba(59, 130, 246, 0.03) !important; +} + +[data-theme="light"] .member-card.selected .member-hostname { + color: #1d4ed8 !important; + font-weight: 600 !important; +} + +[data-theme="light"] .member-card.selected .member-ip { + color: #3b82f6 !important; +} + [data-theme="light"] .tabs-container { background: rgba(255, 255, 255, 0.08); backdrop-filter: blur(20px); 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