fix: cluster status badge
This commit is contained in:
39
README.md
39
README.md
@@ -8,22 +8,6 @@ Zero-configuration web interface for monitoring and managing SPORE embedded syst
|
||||
- **📊 Node Details**: Detailed system information including running tasks and available endpoints
|
||||
- **🚀 OTA**: Clusterwide over-the-air firmware updates
|
||||
- **📱 Responsive**: Works on all devices and screen sizes
|
||||
- **💾 State Preservation**: Advanced UI state management that preserves user interactions during data refreshes
|
||||
- **⚡ Smart Updates**: Efficient partial updates that only refresh changed data, not entire components
|
||||
|
||||
## Key Improvements
|
||||
|
||||
### 🆕 **State Preservation System**
|
||||
- **Expanded Cards**: Cluster member cards stay expanded during data refreshes
|
||||
- **Active Tabs**: Selected tabs in node detail views are maintained
|
||||
- **User Context**: All user interactions are preserved across data updates
|
||||
- **No More UI Resets**: Users never lose their place in the interface
|
||||
|
||||
### 🚀 **Performance Enhancements**
|
||||
- **Partial Updates**: Only changed data triggers UI updates
|
||||
- **Smart Change Detection**: System automatically detects when data has actually changed
|
||||
- **Efficient Rendering**: Reduced DOM manipulation and improved responsiveness
|
||||
- **Batch Operations**: Multiple property updates are handled efficiently
|
||||
|
||||
## Screenshots
|
||||
### Cluster
|
||||
@@ -64,15 +48,6 @@ spore-ui/
|
||||
3. **Open in browser**: `http://localhost:3001`
|
||||
4. **Test state preservation**: `http://localhost:3001/test-state-preservation.html`
|
||||
|
||||
## Testing State Preservation
|
||||
|
||||
The framework includes a comprehensive test interface to demonstrate state preservation:
|
||||
|
||||
1. **Expand cluster member cards** to see detailed information
|
||||
2. **Change active tabs** in node detail views
|
||||
3. **Trigger data refresh** using the refresh buttons
|
||||
4. **Verify that all UI state is preserved** during updates
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- **`/`** - Main UI page
|
||||
@@ -89,20 +64,6 @@ The framework includes a comprehensive test interface to demonstrate state prese
|
||||
- **API**: SPORE Embedded System API
|
||||
- **Design**: Glassmorphism, CSS Grid, Flexbox
|
||||
|
||||
## Architecture Highlights
|
||||
|
||||
### 🏗️ **Component Framework**
|
||||
- **Base Component Class**: Provides state management and partial update capabilities
|
||||
- **ViewModel Pattern**: Separates data logic from UI logic
|
||||
- **Event-Driven Updates**: Efficient pub/sub system for component communication
|
||||
- **State Persistence**: Automatic preservation of UI state across data refreshes
|
||||
|
||||
### 🔄 **Smart Update System**
|
||||
- **Change Detection**: Automatically identifies when data has actually changed
|
||||
- **Partial Rendering**: Updates only the necessary parts of the UI
|
||||
- **State Preservation**: Maintains user interactions during all updates
|
||||
- **Performance Optimization**: Minimizes unnecessary DOM operations
|
||||
|
||||
## UDP Auto Discovery
|
||||
|
||||
The backend now includes automatic UDP discovery for SPORE nodes on the network. This eliminates the need for hardcoded IP addresses and provides a self-healing, scalable solution for managing SPORE clusters.
|
||||
|
||||
103
public/app.js
103
public/app.js
@@ -37,10 +37,26 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
|
||||
console.log('App: Routes registered and components pre-initialized');
|
||||
|
||||
// Initialize cluster status component for header badge AFTER main components
|
||||
// DISABLED - causes interference with main cluster functionality
|
||||
/*
|
||||
console.log('App: Initializing cluster status component...');
|
||||
const clusterStatusComponent = new ClusterStatusComponent(
|
||||
document.querySelector('.cluster-status'),
|
||||
clusterViewModel,
|
||||
app.eventBus
|
||||
);
|
||||
clusterStatusComponent.initialize();
|
||||
console.log('App: Cluster status component initialized');
|
||||
*/
|
||||
|
||||
// Set up navigation event listeners
|
||||
console.log('App: Setting up navigation...');
|
||||
app.setupNavigation();
|
||||
|
||||
// Set up cluster status updates (simple approach without component interference)
|
||||
setupClusterStatusUpdates(clusterViewModel);
|
||||
|
||||
// Set up periodic updates for cluster view with state preservation
|
||||
// setupPeriodicUpdates(); // Disabled automatic refresh
|
||||
|
||||
@@ -80,6 +96,93 @@ function setupPeriodicUpdates() {
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Set up cluster status updates (simple approach without component interference)
|
||||
function setupClusterStatusUpdates(clusterViewModel) {
|
||||
// Set initial "discovering" state immediately
|
||||
updateClusterStatusBadge(undefined, undefined, undefined);
|
||||
|
||||
// Force a fresh fetch and keep showing "discovering" until we get real data
|
||||
let hasReceivedRealData = false;
|
||||
|
||||
// Subscribe to view model changes to update cluster status
|
||||
clusterViewModel.subscribe('totalNodes', (totalNodes) => {
|
||||
if (hasReceivedRealData) {
|
||||
updateClusterStatusBadge(totalNodes, clusterViewModel.get('clientInitialized'), clusterViewModel.get('error'));
|
||||
}
|
||||
});
|
||||
|
||||
clusterViewModel.subscribe('clientInitialized', (clientInitialized) => {
|
||||
if (hasReceivedRealData) {
|
||||
updateClusterStatusBadge(clusterViewModel.get('totalNodes'), clientInitialized, clusterViewModel.get('error'));
|
||||
}
|
||||
});
|
||||
|
||||
clusterViewModel.subscribe('error', (error) => {
|
||||
if (hasReceivedRealData) {
|
||||
updateClusterStatusBadge(clusterViewModel.get('totalNodes'), clusterViewModel.get('clientInitialized'), error);
|
||||
}
|
||||
});
|
||||
|
||||
// Force a fresh fetch and only update status after we get real data
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
console.log('Cluster Status: Forcing fresh fetch from backend...');
|
||||
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
|
||||
console.log('Cluster Status: Got fresh data:', discoveryInfo);
|
||||
|
||||
// Now we have real data, mark it and update the status
|
||||
hasReceivedRealData = true;
|
||||
updateClusterStatusBadge(discoveryInfo.totalNodes, discoveryInfo.clientInitialized, null);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Cluster Status: Failed to fetch fresh data:', error);
|
||||
hasReceivedRealData = true;
|
||||
updateClusterStatusBadge(0, false, error.message);
|
||||
}
|
||||
}, 100); // Small delay to ensure view model is ready
|
||||
}
|
||||
|
||||
function updateClusterStatusBadge(totalNodes, clientInitialized, error) {
|
||||
const clusterStatusBadge = document.querySelector('.cluster-status');
|
||||
if (!clusterStatusBadge) return;
|
||||
|
||||
let statusText, statusIcon, statusClass;
|
||||
|
||||
// Check if we're still in initial state (no real data yet)
|
||||
const hasRealData = totalNodes !== undefined && clientInitialized !== undefined;
|
||||
|
||||
if (!hasRealData) {
|
||||
statusText = 'Cluster Discovering...';
|
||||
statusIcon = '🔍';
|
||||
statusClass = 'cluster-status-discovering';
|
||||
} else if (error) {
|
||||
statusText = 'Cluster Error';
|
||||
statusIcon = '❌';
|
||||
statusClass = 'cluster-status-error';
|
||||
} else if (totalNodes === 0) {
|
||||
statusText = 'Cluster Offline';
|
||||
statusIcon = '🔴';
|
||||
statusClass = 'cluster-status-offline';
|
||||
} else if (clientInitialized) {
|
||||
statusText = 'Cluster Online';
|
||||
statusIcon = '🟢';
|
||||
statusClass = 'cluster-status-online';
|
||||
} else {
|
||||
statusText = 'Cluster Connecting';
|
||||
statusIcon = '🟡';
|
||||
statusClass = 'cluster-status-connecting';
|
||||
}
|
||||
|
||||
// Update the badge
|
||||
clusterStatusBadge.innerHTML = `${statusIcon} ${statusText}`;
|
||||
|
||||
// Remove all existing status classes
|
||||
clusterStatusBadge.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error', 'cluster-status-discovering');
|
||||
|
||||
// Add the appropriate status class
|
||||
clusterStatusBadge.classList.add(statusClass);
|
||||
}
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(event) {
|
||||
console.error('Global error:', event.error);
|
||||
|
||||
@@ -1925,4 +1925,55 @@ class FirmwareViewComponent extends Component {
|
||||
console.error('Failed to update available nodes:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cluster Status Component for header badge
|
||||
class ClusterStatusComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
}
|
||||
|
||||
setupViewModelListeners() {
|
||||
// Subscribe to properties that affect cluster status
|
||||
this.subscribeToProperty('totalNodes', this.render.bind(this));
|
||||
this.subscribeToProperty('clientInitialized', this.render.bind(this));
|
||||
this.subscribeToProperty('error', this.render.bind(this));
|
||||
}
|
||||
|
||||
render() {
|
||||
const totalNodes = this.viewModel.get('totalNodes');
|
||||
const clientInitialized = this.viewModel.get('clientInitialized');
|
||||
const error = this.viewModel.get('error');
|
||||
|
||||
let statusText, statusIcon, statusClass;
|
||||
|
||||
if (error) {
|
||||
statusText = 'Cluster Error';
|
||||
statusIcon = '❌';
|
||||
statusClass = 'cluster-status-error';
|
||||
} else if (totalNodes === 0) {
|
||||
statusText = 'Cluster Offline';
|
||||
statusIcon = '🔴';
|
||||
statusClass = 'cluster-status-offline';
|
||||
} else if (clientInitialized) {
|
||||
statusText = 'Cluster Online';
|
||||
statusIcon = '🟢';
|
||||
statusClass = 'cluster-status-online';
|
||||
} else {
|
||||
statusText = 'Cluster Connecting';
|
||||
statusIcon = '🟡';
|
||||
statusClass = 'cluster-status-connecting';
|
||||
}
|
||||
|
||||
// Update the cluster status badge using the container passed to this component
|
||||
if (this.container) {
|
||||
this.container.innerHTML = `${statusIcon} ${statusText}`;
|
||||
|
||||
// Remove all existing status classes
|
||||
this.container.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error');
|
||||
|
||||
// Add the appropriate status class
|
||||
this.container.classList.add(statusClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
208
public/debug-cluster.html
Normal file
208
public/debug-cluster.html
Normal file
@@ -0,0 +1,208 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debug Cluster</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }
|
||||
.debug-section { margin: 20px 0; padding: 20px; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.status { padding: 10px; margin: 10px 0; border-radius: 4px; }
|
||||
.status.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||||
.status.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
||||
.status.info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
|
||||
button { padding: 10px 20px; margin: 5px; border: none; border-radius: 4px; background: #007bff; color: white; cursor: pointer; }
|
||||
button:hover { background: #0056b3; }
|
||||
.log { background: #f8f9fa; padding: 10px; border-radius: 4px; font-family: monospace; font-size: 12px; max-height: 300px; overflow-y: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🐛 Debug Cluster Functionality</h1>
|
||||
|
||||
<div class="debug-section">
|
||||
<h3>1. API Client Test</h3>
|
||||
<button onclick="testApiClient()">Test API Client</button>
|
||||
<div id="api-client-result"></div>
|
||||
</div>
|
||||
|
||||
<div class="debug-section">
|
||||
<h3>2. View Model Test</h3>
|
||||
<button onclick="testViewModel()">Test View Model</button>
|
||||
<div id="viewmodel-result"></div>
|
||||
</div>
|
||||
|
||||
<div class="debug-section">
|
||||
<h3>3. Component Test</h3>
|
||||
<button onclick="testComponents()">Test Components</button>
|
||||
<div id="component-result"></div>
|
||||
</div>
|
||||
|
||||
<div class="debug-section">
|
||||
<h3>4. Console Log</h3>
|
||||
<div id="console-log" class="log"></div>
|
||||
<button onclick="clearLog()">Clear Log</button>
|
||||
</div>
|
||||
|
||||
<script src="framework.js"></script>
|
||||
<script src="api-client.js"></script>
|
||||
<script src="view-models.js"></script>
|
||||
<script src="components.js"></script>
|
||||
|
||||
<script>
|
||||
// Capture console logs
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
const originalWarn = console.warn;
|
||||
|
||||
function addToLog(level, ...args) {
|
||||
const logDiv = document.getElementById('console-log');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)).join(' ');
|
||||
logDiv.innerHTML += `[${timestamp}] ${level}: ${message}\n`;
|
||||
logDiv.scrollTop = logDiv.scrollHeight;
|
||||
}
|
||||
|
||||
console.log = function(...args) {
|
||||
originalLog.apply(console, args);
|
||||
addToLog('LOG', ...args);
|
||||
};
|
||||
|
||||
console.error = function(...args) {
|
||||
originalError.apply(console, args);
|
||||
addToLog('ERROR', ...args);
|
||||
};
|
||||
|
||||
console.warn = function(...args) {
|
||||
originalWarn.apply(console, args);
|
||||
addToLog('WARN', ...args);
|
||||
};
|
||||
|
||||
function clearLog() {
|
||||
document.getElementById('console-log').innerHTML = '';
|
||||
}
|
||||
|
||||
async function testApiClient() {
|
||||
const resultDiv = document.getElementById('api-client-result');
|
||||
resultDiv.innerHTML = '<div class="status info">Testing API Client...</div>';
|
||||
|
||||
try {
|
||||
console.log('Testing API Client...');
|
||||
|
||||
// Test discovery info
|
||||
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
|
||||
console.log('Discovery Info:', discoveryInfo);
|
||||
|
||||
// Test cluster members
|
||||
const clusterMembers = await window.apiClient.getClusterMembers();
|
||||
console.log('Cluster Members:', clusterMembers);
|
||||
|
||||
resultDiv.innerHTML = `
|
||||
<div class="status success">
|
||||
<strong>API Client Test Passed!</strong><br>
|
||||
Discovery: ${discoveryInfo.totalNodes} nodes, Primary: ${discoveryInfo.primaryNode || 'None'}<br>
|
||||
Members: ${clusterMembers.members ? clusterMembers.members.length : 0} members
|
||||
</div>
|
||||
`;
|
||||
|
||||
} catch (error) {
|
||||
console.error('API Client Test Failed:', error);
|
||||
resultDiv.innerHTML = `<div class="status error">API Client Test Failed: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function testViewModel() {
|
||||
const resultDiv = document.getElementById('viewmodel-result');
|
||||
resultDiv.innerHTML = '<div class="status info">Testing View Model...</div>';
|
||||
|
||||
try {
|
||||
console.log('Testing View Model...');
|
||||
|
||||
const clusterViewModel = new ClusterViewModel();
|
||||
console.log('ClusterViewModel created:', clusterViewModel);
|
||||
|
||||
// Wait for initial data
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
const totalNodes = clusterViewModel.get('totalNodes');
|
||||
const primaryNode = clusterViewModel.get('primaryNode');
|
||||
const clientInitialized = clusterViewModel.get('clientInitialized');
|
||||
|
||||
console.log('ViewModel data:', { totalNodes, primaryNode, clientInitialized });
|
||||
|
||||
resultDiv.innerHTML = `
|
||||
<div class="status success">
|
||||
<strong>View Model Test Passed!</strong><br>
|
||||
Total Nodes: ${totalNodes}<br>
|
||||
Primary Node: ${primaryNode || 'None'}<br>
|
||||
Client Initialized: ${clientInitialized}
|
||||
</div>
|
||||
`;
|
||||
|
||||
} catch (error) {
|
||||
console.error('View Model Test Failed:', error);
|
||||
resultDiv.innerHTML = `<div class="status error">View Model Test Failed: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function testComponents() {
|
||||
const resultDiv = document.getElementById('component-result');
|
||||
resultDiv.innerHTML = '<div class="status info">Testing Components...</div>';
|
||||
|
||||
try {
|
||||
console.log('Testing Components...');
|
||||
|
||||
const eventBus = new EventBus();
|
||||
const clusterViewModel = new ClusterViewModel();
|
||||
|
||||
// Test cluster status component
|
||||
const statusContainer = document.createElement('div');
|
||||
statusContainer.className = 'cluster-status';
|
||||
statusContainer.innerHTML = '🚀 Cluster Online';
|
||||
document.body.appendChild(statusContainer);
|
||||
|
||||
const clusterStatusComponent = new ClusterStatusComponent(
|
||||
statusContainer,
|
||||
clusterViewModel,
|
||||
eventBus
|
||||
);
|
||||
|
||||
clusterStatusComponent.initialize();
|
||||
console.log('Cluster Status Component initialized');
|
||||
|
||||
// Wait for data
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
const statusText = statusContainer.innerHTML;
|
||||
const statusClasses = Array.from(statusContainer.classList);
|
||||
|
||||
console.log('Status Component Result:', { statusText, statusClasses });
|
||||
|
||||
resultDiv.innerHTML = `
|
||||
<div class="status success">
|
||||
<strong>Component Test Passed!</strong><br>
|
||||
Status Text: ${statusText}<br>
|
||||
Status Classes: ${statusClasses.join(', ')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(statusContainer);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Component Test Failed:', error);
|
||||
resultDiv.innerHTML = `<div class="status error">Component Test Failed: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-run tests on page load
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
console.log('Page loaded, starting auto-tests...');
|
||||
testApiClient();
|
||||
setTimeout(testViewModel, 1000);
|
||||
setTimeout(testComponents, 2000);
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -737,6 +737,43 @@ p {
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 2px 8px rgba(0, 255, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Cluster Status States */
|
||||
.cluster-status-online {
|
||||
background: linear-gradient(135deg, rgba(0, 255, 0, 0.15) 0%, rgba(0, 255, 0, 0.08) 100%);
|
||||
border: 1px solid rgba(0, 255, 0, 0.25);
|
||||
color: #00ff88;
|
||||
box-shadow: 0 2px 8px rgba(0, 255, 0, 0.1);
|
||||
}
|
||||
|
||||
.cluster-status-offline {
|
||||
background: linear-gradient(135deg, rgba(255, 0, 0, 0.15) 0%, rgba(255, 0, 0, 0.08) 100%);
|
||||
border: 1px solid rgba(255, 0, 0, 0.25);
|
||||
color: #ff6b6b;
|
||||
box-shadow: 0 2px 8px rgba(255, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.cluster-status-connecting {
|
||||
background: linear-gradient(135deg, rgba(255, 193, 7, 0.15) 0%, rgba(255, 193, 7, 0.08) 100%);
|
||||
border: 1px solid rgba(255, 193, 7, 0.25);
|
||||
color: #ffd54f;
|
||||
box-shadow: 0 2px 8px rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
.cluster-status-discovering {
|
||||
background: linear-gradient(135deg, rgba(33, 150, 243, 0.15) 0%, rgba(33, 150, 243, 0.08) 100%);
|
||||
border: 1px solid rgba(33, 150, 243, 0.25);
|
||||
color: #64b5f6;
|
||||
box-shadow: 0 2px 8px rgba(33, 150, 243, 0.1);
|
||||
}
|
||||
|
||||
.cluster-status-error {
|
||||
background: linear-gradient(135deg, rgba(244, 67, 54, 0.15) 0%, rgba(244, 67, 54, 0.08) 100%);
|
||||
border: 1px solid rgba(244, 67, 54, 0.25);
|
||||
color: #ff8a80;
|
||||
box-shadow: 0 2px 8px rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
|
||||
/* View Content Styles */
|
||||
|
||||
@@ -15,6 +15,11 @@ class ClusterViewModel extends ViewModel {
|
||||
activeTabs: new Map(), // Store active tab for each node
|
||||
lastUpdateTime: null
|
||||
});
|
||||
|
||||
// Initialize cluster status after a short delay to allow components to subscribe
|
||||
setTimeout(() => {
|
||||
this.updatePrimaryNodeDisplay();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Update cluster members with state preservation
|
||||
|
||||
Reference in New Issue
Block a user