fix: cluster status badge

This commit is contained in:
2025-08-27 17:17:47 +02:00
parent 8818e43301
commit 6b5ff2939c
6 changed files with 404 additions and 39 deletions

View File

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

View File

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

View File

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

View File

@@ -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 */

View File

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