Merge pull request 'feature/desktop-view' (#5) from feature/desktop-view into main

Reviewed-on: #5
This commit is contained in:
2025-09-19 21:06:22 +02:00
27 changed files with 2966 additions and 702 deletions

View File

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

View File

@@ -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 `<html>` 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 394 KiB

View File

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

View File

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

View File

@@ -146,6 +146,7 @@
<script src="./scripts/api-client.js"></script>
<script src="./scripts/view-models.js"></script>
<!-- Base/leaf components first -->
<script src="./scripts/components/DrawerComponent.js"></script>
<script src="./scripts/components/PrimaryNodeComponent.js"></script>
<script src="./scripts/components/NodeDetailsComponent.js"></script>
<script src="./scripts/components/ClusterMembersComponent.js"></script>

View File

@@ -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: []
}
});
}
}

View File

@@ -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 = `
<div class="error">
<strong>Error loading node details:</strong><br>
${this.escapeHtml(error.message)}
</div>
`;
});
}, 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;

View File

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

View File

@@ -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 = `
<div class="drawer-title">Node Details</div>
<button class="drawer-close" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
`;
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 = '<div class="loading-details">Loading detailed information...</div>';
// 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 = `
<div class="error">
<strong>Error loading content:</strong><br>
${this.escapeHtml ? this.escapeHtml(error.message) : error.message}
</div>
`;
}
}
// 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;

View File

@@ -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('<div class="loading-details">Loading detailed information...</div>');
}
}
@@ -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('<div class="loading-details">Loading detailed information...</div>');
@@ -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)
? `<div class="member-labels" style="margin: 0 0 12px 0;">${Object.entries(labelsObj)
.map(([k, v]) => `<span class=\"label-chip\">${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}</span>`)
.join('')}</div>`
: '';
const html = `
${labelsBar}
<div class="tabs-container">
<div class="tabs-header">
<button class="tab-button ${activeTab === 'status' ? 'active' : ''}" data-tab="status">Status</button>
<button class="tab-button ${activeTab === 'endpoints' ? 'active' : ''}" data-tab="endpoints">Endpoints</button>
<button class="tab-button ${activeTab === 'tasks' ? 'active' : ''}" data-tab="tasks">Tasks</button>
<button class="tab-button ${activeTab === 'firmware' ? 'active' : ''}" data-tab="firmware">Firmware</button>
<button class="tab-refresh-btn" title="Refresh current tab" aria-label="Refresh">
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M1 4v6h6" />
<path d="M23 20v-6h-6" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
</svg>
</button>
</div>
<div class="tab-content ${activeTab === 'status' ? 'active' : ''}" id="status-tab">
<div class="detail-row">
<span class="detail-label">Free Heap:</span>
<span class="detail-value">${Math.round(nodeStatus.freeHeap / 1024)}KB</span>
</div>
<div class="detail-row">
<span class="detail-label">Chip ID:</span>
<span class="detail-value">${nodeStatus.chipId}</span>
</div>
<div class="detail-row">
<span class="detail-label">SDK Version:</span>
<span class="detail-value">${nodeStatus.sdkVersion}</span>
</div>
<div class="detail-row">
<span class="detail-label">CPU Frequency:</span>
<span class="detail-value">${nodeStatus.cpuFreqMHz}MHz</span>
</div>
<div class="detail-row">
<span class="detail-label">Flash Size:</span>
<span class="detail-value">${Math.round(nodeStatus.flashChipSize / 1024)}KB</span>
</div>
${this.renderStatusTab(nodeStatus, monitoringResources)}
</div>
<div class="tab-content ${activeTab === 'endpoints' ? 'active' : ''}" id="endpoints-tab">
@@ -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 = `
<svg class="refresh-icon spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M1 4v6h6" />
<path d="M23 20v-6h-6" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
</svg>
`;
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 += `
<div class="detail-row">
<span class="detail-label">Chip ID:</span>
<span class="detail-value">${nodeStatus.chipId}</span>
</div>
<div class="detail-row">
<span class="detail-label">SDK Version:</span>
<span class="detail-value">${nodeStatus.sdkVersion}</span>
</div>
<div class="detail-row">
<span class="detail-label">CPU Frequency:</span>
<span class="detail-value">${nodeStatus.cpuFreqMHz}MHz</span>
</div>
<div class="detail-row">
<span class="detail-label">Flash Size:</span>
<span class="detail-value">${Math.round(nodeStatus.flashChipSize / 1024)}KB</span>
</div>
`;
// Add monitoring resources if available
if (monitoringResources) {
html += `
<div class="monitoring-section">
<div class="monitoring-header">Resources</div>
`;
// CPU Usage
if (monitoringResources.cpu) {
html += `
<div class="detail-row">
<span class="detail-label">CPU Usage (Avg):</span>
<span class="detail-value">${monitoringResources.cpu.average_usage ? monitoringResources.cpu.average_usage.toFixed(1) + '%' : 'N/A'}</span>
</div>
`;
}
// 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 += `
<div class="detail-row">
<span class="detail-label">Heap Usage:</span>
<span class="detail-value">${heapUsagePercent.toFixed(1)}% (${usedHeapKB}KB / ${totalHeapKB}KB)</span>
</div>
`;
}
// 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 += `
<div class="detail-row">
<span class="detail-label">Filesystem:</span>
<span class="detail-value">${usagePercent}% (${usedKB}KB / ${totalKB}KB)</span>
</div>
`;
}
// System Information
if (monitoringResources.system) {
html += `
<div class="detail-row">
<span class="detail-label">Uptime:</span>
<span class="detail-value">${monitoringResources.system.uptime_formatted || 'N/A'}</span>
</div>
`;
}
// Network Information
if (monitoringResources.network) {
const uptimeSeconds = monitoringResources.network.uptime_seconds || 0;
const uptimeHours = Math.floor(uptimeSeconds / 3600);
const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
const uptimeFormatted = `${uptimeHours}h ${uptimeMinutes}m`;
html += `
<div class="detail-row">
<span class="detail-label">WiFi RSSI:</span>
<span class="detail-value">${monitoringResources.network.wifi_rssi || 'N/A'} dBm</span>
</div>
<div class="detail-row">
<span class="detail-label">Network Uptime:</span>
<span class="detail-value">${uptimeFormatted}</span>
</div>
`;
}
html += `</div>`;
}
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 `
<div class="resource-gauges">
<div class="gauge-container">
<div class="gauge ${getColorClass(cpuUsage)}" data-percentage="${cpuUsage}" style="--percentage: ${cpuUsage}">
<div class="gauge-circle">
<div class="gauge-text">
<div class="gauge-value">${cpuUsage.toFixed(1)}%</div>
<div class="gauge-label">CPU</div>
</div>
</div>
</div>
</div>
<div class="gauge-container">
<div class="gauge ${getColorClass(heapUsage)}" data-percentage="${heapUsage}" style="--percentage: ${heapUsage}">
<div class="gauge-circle">
<div class="gauge-text">
<div class="gauge-value">${heapUsage.toFixed(1)}%</div>
<div class="gauge-label">Heap</div>
</div>
</div>
</div>
</div>
<div class="gauge-container">
<div class="gauge ${getColorClass(filesystemUsage)}" data-percentage="${filesystemUsage}" style="--percentage: ${filesystemUsage}">
<div class="gauge-circle">
<div class="gauge-text">
<div class="gauge-value">${filesystemUsage.toFixed(1)}%</div>
<div class="gauge-label">Storage</div>
<!-- <div class="gauge-detail">${filesystemUsedKB}KB / ${filesystemTotalKB}KB</div> -->
</div>
</div>
</div>
</div>
</div>
`;
}
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');

View File

@@ -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 = `
<div class="error">
<strong>Error loading node details:</strong><br>
${this.escapeHtml(error.message)}
</div>
`;
});
});
}
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]) => `<span class=\"label-chip\">${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}</span>`)
.join('');
this.tooltipEl.innerHTML = `<div class=\"member-labels\">${chips}</div>`;
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);
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 `
<div class="member-overlay-content">

View File

@@ -42,7 +42,7 @@ class ClusterViewModel extends ViewModel {
const members = response.members || [];
const onlineNodes = Array.isArray(members)
? members.filter(m => m && m.status === 'active').length
? members.filter(m => m && m.status && m.status.toUpperCase() === 'ACTIVE').length
: 0;
// Use batch update to preserve UI state
@@ -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

View File

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

View File

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

View File

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

View File

@@ -1,102 +0,0 @@
#!/usr/bin/env node
/**
* Demo script for Frontend Discovery Integration
* Shows how the frontend displays primary node information
*/
const http = require('http');
const BASE_URL = 'http://localhost:3001';
function makeRequest(path, method = 'GET') {
return new Promise((resolve, reject) => {
const options = {
hostname: 'localhost',
port: 3001,
path: path,
method: method,
headers: {
'Content-Type': 'application/json'
}
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const jsonData = JSON.parse(data);
resolve({ status: res.statusCode, data: jsonData });
} catch (error) {
resolve({ status: res.statusCode, data: data });
}
});
});
req.on('error', (error) => {
reject(error);
});
req.end();
});
}
async function showFrontendIntegration() {
console.log('🚀 Frontend Discovery Integration Demo');
console.log('=====================================');
console.log('This demo shows how the frontend displays primary node information.');
console.log('Open http://localhost:3001 in your browser to see the UI.');
console.log('');
try {
// Check if backend is running
const healthResponse = await makeRequest('/api/health');
console.log('✅ Backend is running');
// Get discovery information
const discoveryResponse = await makeRequest('/api/discovery/nodes');
console.log('\n📡 Discovery Status:');
console.log(` Primary Node: ${discoveryResponse.data.primaryNode || 'None'}`);
console.log(` Total Nodes: ${discoveryResponse.data.totalNodes}`);
console.log(` Client Initialized: ${discoveryResponse.data.clientInitialized}`);
if (discoveryResponse.data.nodes.length > 0) {
console.log('\n🌐 Discovered Nodes:');
discoveryResponse.data.nodes.forEach((node, index) => {
console.log(` ${index + 1}. ${node.ip}:${node.port} (${node.isPrimary ? 'PRIMARY' : 'secondary'})`);
console.log(` Last Seen: ${node.lastSeen}`);
});
}
console.log('\n🎯 Frontend Display:');
console.log(' The frontend will show:');
if (discoveryResponse.data.primaryNode) {
const status = discoveryResponse.data.clientInitialized ? '✅' : '⚠️';
const nodeCount = discoveryResponse.data.totalNodes > 1 ? ` (${discoveryResponse.data.totalNodes} nodes)` : '';
console.log(` ${status} ${discoveryResponse.data.primaryNode}${nodeCount}`);
} else if (discoveryResponse.data.totalNodes > 0) {
const firstNode = discoveryResponse.data.nodes[0];
console.log(` ⚠️ ${firstNode.ip} (No Primary)`);
} else {
console.log(' 🔍 No Nodes Found');
}
console.log('\n💡 To test the frontend:');
console.log(' 1. Open http://localhost:3001 in your browser');
console.log(' 2. Look at the cluster header for primary node info');
console.log(' 3. Send discovery messages: npm run test-discovery broadcast');
console.log(' 4. Watch the primary node display update in real-time');
} catch (error) {
console.error('❌ Error:', error.message);
console.log('\n💡 Make sure the backend is running: npm start');
}
}
// Run the demo
showFrontendIntegration().catch(console.error);

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -1,137 +0,0 @@
#!/usr/bin/env node
/**
* Test script for Random Primary Node Selection
* Demonstrates how the random selection works
*/
const http = require('http');
const BASE_URL = 'http://localhost:3001';
function makeRequest(path, method = 'POST', body = null) {
return new Promise((resolve, reject) => {
const options = {
hostname: 'localhost',
port: 3001,
path: path,
method: method,
headers: {
'Content-Type': 'application/json'
}
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const jsonData = JSON.parse(data);
resolve({ status: res.statusCode, data: jsonData });
} catch (error) {
resolve({ status: res.statusCode, data: data });
}
});
});
req.on('error', (error) => {
reject(error);
});
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
async function testRandomSelection() {
console.log('🎲 Testing Random Primary Node Selection');
console.log('======================================');
console.log('');
try {
// First, check current discovery status
console.log('1. Checking current discovery status...');
const discoveryResponse = await makeRequest('/api/discovery/nodes', 'GET');
if (discoveryResponse.status !== 200) {
console.log('❌ Failed to get discovery status');
return;
}
const discovery = discoveryResponse.data;
console.log(` Current Primary: ${discovery.primaryNode || 'None'}`);
console.log(` Total Nodes: ${discovery.totalNodes}`);
console.log(` Client Initialized: ${discovery.clientInitialized}`);
if (discovery.nodes.length === 0) {
console.log('\n💡 No nodes discovered yet. Send some discovery messages first:');
console.log(' npm run test-discovery broadcast');
return;
}
console.log('\n2. Testing random primary node selection...');
// Store current primary for comparison
const currentPrimary = discovery.primaryNode;
const availableNodes = discovery.nodes.map(n => n.ip);
console.log(` Available nodes: ${availableNodes.join(', ')}`);
console.log(` Current primary: ${currentPrimary}`);
// Perform random selection
const randomResponse = await makeRequest('/api/discovery/random-primary', 'POST', {
timestamp: new Date().toISOString()
});
if (randomResponse.status === 200) {
const result = randomResponse.data;
console.log('\n✅ Random selection successful!');
console.log(` New Primary: ${result.primaryNode}`);
console.log(` Previous Primary: ${currentPrimary}`);
console.log(` Message: ${result.message}`);
console.log(` Total Nodes: ${result.totalNodes}`);
console.log(` Client Initialized: ${result.clientInitialized}`);
// Verify the change
if (result.primaryNode !== currentPrimary) {
console.log('\n🎯 Primary node successfully changed!');
} else {
console.log('\n⚠ Primary node remained the same (only one node available)');
}
} else {
console.log('\n❌ Random selection failed:');
console.log(` Status: ${randomResponse.status}`);
console.log(` Error: ${randomResponse.data.error || 'Unknown error'}`);
}
// Show updated status
console.log('\n3. Checking updated discovery status...');
const updatedResponse = await makeRequest('/api/discovery/nodes', 'GET');
if (updatedResponse.status === 200) {
const updated = updatedResponse.data;
console.log(` Current Primary: ${updated.primaryNode}`);
console.log(` Client Base URL: ${updated.clientBaseUrl}`);
}
console.log('\n💡 To test in the frontend:');
console.log(' 1. Open http://localhost:3001 in your browser');
console.log(' 2. Look at the cluster header for primary node info');
console.log(' 3. Click the 🎲 button to randomly select a new primary node');
console.log(' 4. Watch the display change in real-time');
} catch (error) {
console.error('\n❌ Test failed:', error.message);
console.log('\n💡 Make sure the backend is running: npm start');
}
}
// Run the test
testRandomSelection().catch(console.error);