feat: implement framework and refactor everything
This commit is contained in:
130
public/api-client.js
Normal file
130
public/api-client.js
Normal file
@@ -0,0 +1,130 @@
|
||||
// API Client for communicating with the backend
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.baseUrl = 'http://localhost:3001'; // Backend server URL
|
||||
}
|
||||
|
||||
async getClusterMembers() {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/cluster/members`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getDiscoveryInfo() {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/discovery/nodes`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async selectRandomPrimaryNode() {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/discovery/random-primary`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getNodeStatus(ip) {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/node/status/${encodeURIComponent(ip)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getTasksStatus() {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/tasks/status`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFirmware(file, nodeIp) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/node/update?ip=${encodeURIComponent(nodeIp)}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`Upload failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global API client instance
|
||||
window.apiClient = new ApiClient();
|
||||
99
public/app.js
Normal file
99
public/app.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// Main SPORE UI Application
|
||||
|
||||
// Initialize the application when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('=== SPORE UI Application Initialization ===');
|
||||
|
||||
// Initialize the framework (but don't navigate yet)
|
||||
console.log('App: Creating framework instance...');
|
||||
const app = window.app;
|
||||
|
||||
// Create view models
|
||||
console.log('App: Creating view models...');
|
||||
const clusterViewModel = new ClusterViewModel();
|
||||
const firmwareViewModel = new FirmwareViewModel();
|
||||
console.log('App: View models created:', { clusterViewModel, firmwareViewModel });
|
||||
|
||||
// Connect firmware view model to cluster data
|
||||
clusterViewModel.subscribe('members', (members) => {
|
||||
console.log('App: Members subscription triggered:', members);
|
||||
if (members && members.length > 0) {
|
||||
// Extract node information for firmware view
|
||||
const nodes = members.map(member => ({
|
||||
ip: member.ip,
|
||||
hostname: member.hostname || member.ip
|
||||
}));
|
||||
firmwareViewModel.updateAvailableNodes(nodes);
|
||||
console.log('App: Updated firmware view model with nodes:', nodes);
|
||||
} else {
|
||||
firmwareViewModel.updateAvailableNodes([]);
|
||||
console.log('App: Cleared firmware view model nodes');
|
||||
}
|
||||
});
|
||||
|
||||
// Register routes with their view models
|
||||
console.log('App: Registering routes...');
|
||||
app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel);
|
||||
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
|
||||
console.log('App: Routes registered and components pre-initialized');
|
||||
|
||||
// Set up navigation event listeners
|
||||
console.log('App: Setting up navigation...');
|
||||
app.setupNavigation();
|
||||
|
||||
// Set up periodic updates for cluster view with state preservation
|
||||
// setupPeriodicUpdates(); // Disabled automatic refresh
|
||||
|
||||
// Now navigate to the default route
|
||||
console.log('App: Navigating to default route...');
|
||||
app.navigateTo('cluster');
|
||||
|
||||
console.log('=== SPORE UI Application initialization completed ===');
|
||||
});
|
||||
|
||||
// Set up periodic updates with state preservation
|
||||
function setupPeriodicUpdates() {
|
||||
// Auto-refresh cluster members every 30 seconds using smart update
|
||||
setInterval(() => {
|
||||
if (window.app.currentView && window.app.currentView.viewModel) {
|
||||
const viewModel = window.app.currentView.viewModel;
|
||||
|
||||
// Use smart update if available, otherwise fall back to regular update
|
||||
if (viewModel.smartUpdate && typeof viewModel.smartUpdate === 'function') {
|
||||
console.log('App: Performing smart update to preserve UI state...');
|
||||
viewModel.smartUpdate();
|
||||
} else if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') {
|
||||
console.log('App: Performing regular update...');
|
||||
viewModel.updateClusterMembers();
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Update primary node display every 10 seconds (this is lightweight and doesn't affect UI state)
|
||||
setInterval(() => {
|
||||
if (window.app.currentView && window.app.currentView.viewModel) {
|
||||
const viewModel = window.app.currentView.viewModel;
|
||||
if (viewModel.updatePrimaryNodeDisplay && typeof viewModel.updatePrimaryNodeDisplay === 'function') {
|
||||
viewModel.updatePrimaryNodeDisplay();
|
||||
}
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(event) {
|
||||
console.error('Global error:', event.error);
|
||||
});
|
||||
|
||||
// Global unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(event) {
|
||||
console.error('Unhandled promise rejection:', event.reason);
|
||||
});
|
||||
|
||||
// Clean up on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (window.app) {
|
||||
console.log('App: Cleaning up cached components...');
|
||||
window.app.cleanup();
|
||||
}
|
||||
});
|
||||
1768
public/components.js
Normal file
1768
public/components.js
Normal file
File diff suppressed because it is too large
Load Diff
190
public/debug-cluster-load.html
Normal file
190
public/debug-cluster-load.html
Normal file
@@ -0,0 +1,190 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debug Cluster Load</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.debug-panel { background: #f0f0f0; padding: 15px; margin: 10px 0; border-radius: 5px; }
|
||||
.debug-button { padding: 8px 16px; margin: 5px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer; }
|
||||
.debug-button:hover { background: #0056b3; }
|
||||
.log { background: #000; color: #0f0; padding: 10px; margin: 10px 0; border-radius: 3px; font-family: monospace; max-height: 300px; overflow-y: auto; }
|
||||
.cluster-container { border: 1px solid #ccc; padding: 15px; margin: 10px 0; border-radius: 5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔍 Debug Cluster Load</h1>
|
||||
|
||||
<div class="debug-panel">
|
||||
<h3>Debug Controls</h3>
|
||||
<button class="debug-button" onclick="testContainerFind()">🔍 Test Container Find</button>
|
||||
<button class="debug-button" onclick="testViewModel()">📊 Test ViewModel</button>
|
||||
<button class="debug-button" onclick="testComponent()">🧩 Test Component</button>
|
||||
<button class="debug-button" onclick="testAPICall()">📡 Test API Call</button>
|
||||
<button class="debug-button" onclick="clearLog()">🧹 Clear Log</button>
|
||||
</div>
|
||||
|
||||
<div class="debug-panel">
|
||||
<h3>Container Elements</h3>
|
||||
<div id="cluster-view" class="cluster-container">
|
||||
<div class="primary-node-info">
|
||||
<h4>Primary Node</h4>
|
||||
<div id="primary-node-ip">🔍 Discovering...</div>
|
||||
<button class="primary-node-refresh">🔄 Refresh</button>
|
||||
</div>
|
||||
|
||||
<div id="cluster-members-container">
|
||||
<h4>Cluster Members</h4>
|
||||
<div class="loading">Loading cluster members...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="debug-panel">
|
||||
<h3>Debug Log</h3>
|
||||
<div id="debug-log" class="log"></div>
|
||||
</div>
|
||||
|
||||
<!-- Include SPORE UI framework and components -->
|
||||
<script src="framework.js"></script>
|
||||
<script src="view-models.js"></script>
|
||||
<script src="components.js"></script>
|
||||
<script src="api-client.js"></script>
|
||||
|
||||
<script>
|
||||
let debugLog = [];
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = `[${timestamp}] ${message}`;
|
||||
|
||||
const logContainer = document.getElementById('debug-log');
|
||||
logContainer.innerHTML += logEntry + '\n';
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
|
||||
debugLog.push({ timestamp, message, type });
|
||||
console.log(logEntry);
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
document.getElementById('debug-log').innerHTML = '';
|
||||
debugLog = [];
|
||||
}
|
||||
|
||||
// Test container finding
|
||||
function testContainerFind() {
|
||||
log('🔍 Testing container finding...');
|
||||
|
||||
const clusterView = document.getElementById('cluster-view');
|
||||
const primaryNodeInfo = document.querySelector('.primary-node-info');
|
||||
const clusterMembersContainer = document.getElementById('cluster-members-container');
|
||||
|
||||
log(`Cluster view found: ${!!clusterView} (ID: ${clusterView?.id})`);
|
||||
log(`Primary node info found: ${!!primaryNodeInfo}`);
|
||||
log(`Cluster members container found: ${!!clusterMembersContainer} (ID: ${clusterMembersContainer?.id})`);
|
||||
log(`Cluster members container innerHTML: ${clusterMembersContainer?.innerHTML?.substring(0, 100)}...`);
|
||||
}
|
||||
|
||||
// Test view model
|
||||
function testViewModel() {
|
||||
log('📊 Testing ViewModel...');
|
||||
|
||||
try {
|
||||
const viewModel = new ClusterViewModel();
|
||||
log('✅ ClusterViewModel created successfully');
|
||||
|
||||
log(`Initial members: ${viewModel.get('members')?.length || 0}`);
|
||||
log(`Initial loading: ${viewModel.get('isLoading')}`);
|
||||
log(`Initial error: ${viewModel.get('error')}`);
|
||||
|
||||
return viewModel;
|
||||
} catch (error) {
|
||||
log(`❌ ViewModel creation failed: ${error.message}`, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Test component
|
||||
function testComponent() {
|
||||
log('🧩 Testing Component...');
|
||||
|
||||
try {
|
||||
const viewModel = new ClusterViewModel();
|
||||
const eventBus = new EventBus();
|
||||
const container = document.getElementById('cluster-members-container');
|
||||
|
||||
log('✅ Dependencies created, creating ClusterMembersComponent...');
|
||||
|
||||
const component = new ClusterMembersComponent(container, viewModel, eventBus);
|
||||
log('✅ ClusterMembersComponent created successfully');
|
||||
|
||||
log('Mounting component...');
|
||||
component.mount();
|
||||
log('✅ Component mounted');
|
||||
|
||||
return { component, viewModel, eventBus };
|
||||
} catch (error) {
|
||||
log(`❌ Component test failed: ${error.message}`, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Test API call
|
||||
async function testAPICall() {
|
||||
log('📡 Testing API call...');
|
||||
|
||||
try {
|
||||
if (!window.apiClient) {
|
||||
log('❌ API client not available');
|
||||
return;
|
||||
}
|
||||
|
||||
log('Calling getClusterMembers...');
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
log(`✅ API call successful: ${response.members?.length || 0} members`);
|
||||
|
||||
if (response.members && response.members.length > 0) {
|
||||
response.members.forEach(member => {
|
||||
log(`📱 Member: ${member.hostname || member.ip} (${member.status})`);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ API call failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize debug interface
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
log('🚀 Debug interface initialized');
|
||||
log('💡 Use the debug controls above to test different aspects of the cluster loading');
|
||||
});
|
||||
|
||||
// Mock API client if not available
|
||||
if (!window.apiClient) {
|
||||
log('⚠️ Creating mock API client for testing');
|
||||
window.apiClient = {
|
||||
getClusterMembers: async () => {
|
||||
log('📡 Mock API: getClusterMembers called');
|
||||
return {
|
||||
members: [
|
||||
{ ip: '192.168.1.100', hostname: 'Node-1', status: 'active', latency: 15 },
|
||||
{ ip: '192.168.1.101', hostname: 'Node-2', status: 'active', latency: 22 },
|
||||
{ ip: '192.168.1.102', hostname: 'Node-3', status: 'offline', latency: null }
|
||||
]
|
||||
};
|
||||
},
|
||||
getDiscoveryInfo: async () => {
|
||||
log('📡 Mock API: getDiscoveryInfo called');
|
||||
return {
|
||||
primaryNode: '192.168.1.100',
|
||||
clientInitialized: true,
|
||||
totalNodes: 3
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
124
public/debug.html
Normal file
124
public/debug.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debug Framework</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.debug-section { margin: 20px 0; padding: 20px; border: 1px solid #ccc; }
|
||||
.log { background: #f5f5f5; padding: 10px; margin: 10px 0; font-family: monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Framework Debug</h1>
|
||||
|
||||
<div class="debug-section">
|
||||
<h2>Console Log</h2>
|
||||
<div id="console-log" class="log"></div>
|
||||
<button onclick="clearLog()">Clear Log</button>
|
||||
</div>
|
||||
|
||||
<div class="debug-section">
|
||||
<h2>Test Cluster View</h2>
|
||||
<div id="cluster-view">
|
||||
<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" 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>
|
||||
<button class="refresh-btn">Refresh</button>
|
||||
</div>
|
||||
<div id="cluster-members-container">
|
||||
<div class="loading">Loading cluster members...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="testClusterView()">Test Cluster View</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>
|
||||
// Override console.log to capture output
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
const logElement = document.getElementById('console-log');
|
||||
|
||||
function addToLog(message, type = 'log') {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.style.color = type === 'error' ? 'red' : 'black';
|
||||
logEntry.textContent = `[${timestamp}] ${message}`;
|
||||
logElement.appendChild(logEntry);
|
||||
logElement.scrollTop = logElement.scrollHeight;
|
||||
}
|
||||
|
||||
console.log = function(...args) {
|
||||
originalLog.apply(console, args);
|
||||
addToLog(args.join(' '));
|
||||
};
|
||||
|
||||
console.error = function(...args) {
|
||||
originalError.apply(console, args);
|
||||
addToLog(args.join(' '), 'error');
|
||||
};
|
||||
|
||||
function clearLog() {
|
||||
logElement.innerHTML = '';
|
||||
}
|
||||
|
||||
// Test cluster view
|
||||
function testClusterView() {
|
||||
try {
|
||||
console.log('Testing cluster view...');
|
||||
|
||||
// Create view model
|
||||
const clusterVM = new ClusterViewModel();
|
||||
console.log('ClusterViewModel created:', clusterVM);
|
||||
|
||||
// Create component
|
||||
const container = document.getElementById('cluster-view');
|
||||
const clusterComponent = new ClusterViewComponent(container, clusterVM, null);
|
||||
console.log('ClusterViewComponent created:', clusterComponent);
|
||||
|
||||
// Mount component
|
||||
clusterComponent.mount();
|
||||
console.log('Component mounted');
|
||||
|
||||
// Test data loading
|
||||
console.log('Testing data loading...');
|
||||
clusterVM.updateClusterMembers();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error testing cluster view:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize framework
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('DOM loaded, initializing framework...');
|
||||
|
||||
if (window.app) {
|
||||
console.log('Framework app found:', window.app);
|
||||
window.app.init();
|
||||
console.log('Framework initialized');
|
||||
} else {
|
||||
console.error('Framework app not found');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
344
public/deploy-button-test.html
Normal file
344
public/deploy-button-test.html
Normal file
@@ -0,0 +1,344 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deploy Button Test - Isolated</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #1a202c;
|
||||
color: white;
|
||||
}
|
||||
.test-section {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.firmware-actions {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.target-options {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.target-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-input-wrapper {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.deploy-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.deploy-btn:disabled {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.node-select {
|
||||
background: #2d3748;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.cluster-members {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.member-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.debug-info {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin: 20px 0;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🚀 Deploy Button Test - Isolated</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test Scenario: Deploy Button State</h2>
|
||||
<p>This test isolates the deploy button functionality to debug the issue.</p>
|
||||
</div>
|
||||
|
||||
<div class="firmware-actions">
|
||||
<h3>🚀 Firmware Update</h3>
|
||||
|
||||
<div class="target-options">
|
||||
<label class="target-option">
|
||||
<input type="radio" name="target-type" value="all" checked>
|
||||
<span>All Nodes</span>
|
||||
</label>
|
||||
<label class="target-option">
|
||||
<input type="radio" name="target-type" value="specific">
|
||||
<span>Specific Node</span>
|
||||
<select id="specific-node-select" class="node-select" style="visibility: hidden; opacity: 0;">
|
||||
<option value="">Select a node...</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="file-input-wrapper">
|
||||
<input type="file" id="global-firmware-file" accept=".bin,.hex" style="display: none;">
|
||||
<button onclick="document.getElementById('global-firmware-file').click()">
|
||||
📁 Choose File
|
||||
</button>
|
||||
<span id="file-info">No file selected</span>
|
||||
</div>
|
||||
|
||||
<button class="deploy-btn" id="deploy-btn" disabled>
|
||||
🚀 Deploy Firmware
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cluster-members">
|
||||
<h3>Cluster Members</h3>
|
||||
<div id="cluster-members-container">
|
||||
<div class="loading">Loading cluster members...</div>
|
||||
</div>
|
||||
<button onclick="addTestNode()">Add Test Node</button>
|
||||
<button onclick="removeAllNodes()">Remove All Nodes</button>
|
||||
</div>
|
||||
|
||||
<div class="debug-info">
|
||||
<h3>Debug Information</h3>
|
||||
<div id="debug-output">Waiting for actions...</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Simulate the cluster members functionality
|
||||
let testNodes = [];
|
||||
|
||||
function addTestNode() {
|
||||
const nodeCount = testNodes.length + 1;
|
||||
const newNode = {
|
||||
ip: `192.168.1.${100 + nodeCount}`,
|
||||
hostname: `TestNode${nodeCount}`,
|
||||
status: 'active',
|
||||
latency: Math.floor(Math.random() * 50) + 10
|
||||
};
|
||||
testNodes.push(newNode);
|
||||
displayClusterMembers();
|
||||
populateNodeSelect();
|
||||
updateDeployButton();
|
||||
updateDebugInfo();
|
||||
}
|
||||
|
||||
function removeAllNodes() {
|
||||
testNodes = [];
|
||||
displayClusterMembers();
|
||||
populateNodeSelect();
|
||||
updateDeployButton();
|
||||
updateDebugInfo();
|
||||
}
|
||||
|
||||
function displayClusterMembers() {
|
||||
const container = document.getElementById('cluster-members-container');
|
||||
|
||||
if (testNodes.length === 0) {
|
||||
container.innerHTML = '<div class="loading">No cluster members found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const membersHTML = testNodes.map(node => {
|
||||
const statusClass = node.status === 'active' ? 'status-online' : 'status-offline';
|
||||
const statusText = node.status === 'active' ? 'Online' : 'Offline';
|
||||
const statusIcon = node.status === 'active' ? '🟢' : '🔴';
|
||||
|
||||
return `
|
||||
<div class="member-card" data-member-ip="${node.ip}">
|
||||
<div class="member-name">${node.hostname}</div>
|
||||
<div class="member-ip">${node.ip}</div>
|
||||
<div class="member-status ${statusClass}">
|
||||
${statusIcon} ${statusText}
|
||||
</div>
|
||||
<div class="member-latency">Latency: ${node.latency}ms</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = membersHTML;
|
||||
}
|
||||
|
||||
function populateNodeSelect() {
|
||||
const select = document.getElementById('specific-node-select');
|
||||
if (!select) return;
|
||||
|
||||
select.innerHTML = '<option value="">Select a node...</option>';
|
||||
|
||||
if (testNodes.length === 0) {
|
||||
const option = document.createElement('option');
|
||||
option.value = "";
|
||||
option.textContent = "No nodes available";
|
||||
option.disabled = true;
|
||||
select.appendChild(option);
|
||||
return;
|
||||
}
|
||||
|
||||
testNodes.forEach(node => {
|
||||
const option = document.createElement('option');
|
||||
option.value = node.ip;
|
||||
option.textContent = `${node.hostname} (${node.ip})`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function updateDeployButton() {
|
||||
const deployBtn = document.getElementById('deploy-btn');
|
||||
const fileInput = document.getElementById('global-firmware-file');
|
||||
const targetType = document.querySelector('input[name="target-type"]:checked');
|
||||
const specificNodeSelect = document.getElementById('specific-node-select');
|
||||
|
||||
if (!deployBtn || !fileInput) return;
|
||||
|
||||
const hasFile = fileInput.files && fileInput.files.length > 0;
|
||||
const hasAvailableNodes = testNodes.length > 0;
|
||||
|
||||
let isValidTarget = false;
|
||||
if (targetType.value === 'all') {
|
||||
isValidTarget = hasAvailableNodes;
|
||||
} else if (targetType.value === 'specific') {
|
||||
isValidTarget = hasAvailableNodes && specificNodeSelect.value && specificNodeSelect.value !== "";
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
const debugInfo = {
|
||||
hasFile,
|
||||
targetType: targetType?.value,
|
||||
hasAvailableNodes,
|
||||
specificNodeValue: specificNodeSelect?.value,
|
||||
isValidTarget,
|
||||
memberCardsCount: testNodes.length
|
||||
};
|
||||
|
||||
console.log('updateDeployButton debug:', debugInfo);
|
||||
|
||||
deployBtn.disabled = !hasFile || !isValidTarget;
|
||||
|
||||
// Update button text to provide better feedback
|
||||
if (!hasAvailableNodes) {
|
||||
deployBtn.textContent = '🚀 Deploy (No nodes available)';
|
||||
deployBtn.title = 'No cluster nodes are currently available for deployment';
|
||||
} else if (!hasFile) {
|
||||
deployBtn.textContent = '🚀 Deploy Firmware';
|
||||
deployBtn.title = 'Please select a firmware file to deploy';
|
||||
} else if (!isValidTarget) {
|
||||
deployBtn.textContent = '🚀 Deploy Firmware';
|
||||
deployBtn.title = 'Please select a valid target for deployment';
|
||||
} else {
|
||||
deployBtn.textContent = '🚀 Deploy Firmware';
|
||||
deployBtn.title = 'Ready to deploy firmware';
|
||||
}
|
||||
|
||||
updateDebugInfo();
|
||||
}
|
||||
|
||||
function updateDebugInfo() {
|
||||
const debugOutput = document.getElementById('debug-output');
|
||||
const deployBtn = document.getElementById('deploy-btn');
|
||||
const fileInput = document.getElementById('global-firmware-file');
|
||||
const targetType = document.querySelector('input[name="target-type"]:checked');
|
||||
const specificNodeSelect = document.getElementById('specific-node-select');
|
||||
|
||||
const debugInfo = {
|
||||
hasFile: fileInput.files && fileInput.files.length > 0,
|
||||
targetType: targetType?.value,
|
||||
hasAvailableNodes: testNodes.length > 0,
|
||||
specificNodeValue: specificNodeSelect?.value,
|
||||
deployButtonDisabled: deployBtn.disabled,
|
||||
deployButtonText: deployBtn.textContent,
|
||||
testNodesCount: testNodes.length
|
||||
};
|
||||
|
||||
debugOutput.innerHTML = `<pre>${JSON.stringify(debugInfo, null, 2)}</pre>`;
|
||||
}
|
||||
|
||||
// Setup event listeners
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Setup target selection
|
||||
const targetRadios = document.querySelectorAll('input[name="target-type"]');
|
||||
const specificNodeSelect = document.getElementById('specific-node-select');
|
||||
|
||||
targetRadios.forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
console.log('Target radio changed to:', radio.value);
|
||||
|
||||
if (radio.value === 'specific') {
|
||||
specificNodeSelect.style.visibility = 'visible';
|
||||
specificNodeSelect.style.opacity = '1';
|
||||
populateNodeSelect();
|
||||
} else {
|
||||
specificNodeSelect.style.visibility = 'hidden';
|
||||
specificNodeSelect.style.opacity = '0';
|
||||
}
|
||||
|
||||
console.log('Calling updateDeployButton after target change');
|
||||
updateDeployButton();
|
||||
});
|
||||
});
|
||||
|
||||
// Setup specific node select change handler
|
||||
if (specificNodeSelect) {
|
||||
specificNodeSelect.addEventListener('change', (event) => {
|
||||
console.log('Specific node select changed to:', event.target.value);
|
||||
updateDeployButton();
|
||||
});
|
||||
}
|
||||
|
||||
// Setup file input change handler
|
||||
const fileInput = document.getElementById('global-firmware-file');
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', (event) => {
|
||||
const file = event.target.files[0];
|
||||
const fileInfo = document.getElementById('file-info');
|
||||
|
||||
if (file) {
|
||||
fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`;
|
||||
} else {
|
||||
fileInfo.textContent = 'No file selected';
|
||||
}
|
||||
|
||||
updateDeployButton();
|
||||
});
|
||||
}
|
||||
|
||||
// Initial setup
|
||||
displayClusterMembers();
|
||||
populateNodeSelect();
|
||||
updateDeployButton();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
785
public/framework.js
Normal file
785
public/framework.js
Normal file
@@ -0,0 +1,785 @@
|
||||
// SPORE UI Framework - Component-based architecture with pub/sub system
|
||||
|
||||
// Event Bus for pub/sub communication
|
||||
class EventBus {
|
||||
constructor() {
|
||||
this.events = new Map();
|
||||
}
|
||||
|
||||
// Subscribe to an event
|
||||
subscribe(event, callback) {
|
||||
if (!this.events.has(event)) {
|
||||
this.events.set(event, []);
|
||||
}
|
||||
this.events.get(event).push(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const callbacks = this.events.get(event);
|
||||
if (callbacks) {
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Publish an event
|
||||
publish(event, data) {
|
||||
if (this.events.has(event)) {
|
||||
this.events.get(event).forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error(`Error in event callback for ${event}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe from an event
|
||||
unsubscribe(event, callback) {
|
||||
if (this.events.has(event)) {
|
||||
const callbacks = this.events.get(event);
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all events
|
||||
clear() {
|
||||
this.events.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Base ViewModel class with enhanced state management
|
||||
class ViewModel {
|
||||
constructor() {
|
||||
this._data = {};
|
||||
this._listeners = new Map();
|
||||
this._eventBus = null;
|
||||
this._uiState = new Map(); // Store UI state like active tabs, expanded cards, etc.
|
||||
this._previousData = {}; // Store previous data for change detection
|
||||
}
|
||||
|
||||
// Set the event bus for this view model
|
||||
setEventBus(eventBus) {
|
||||
this._eventBus = eventBus;
|
||||
}
|
||||
|
||||
// Get data property
|
||||
get(property) {
|
||||
return this._data[property];
|
||||
}
|
||||
|
||||
// Set data property and notify listeners
|
||||
set(property, value) {
|
||||
console.log(`ViewModel: Setting property '${property}' to:`, value);
|
||||
|
||||
// Check if the value has actually changed
|
||||
const hasChanged = this._data[property] !== value;
|
||||
|
||||
if (hasChanged) {
|
||||
// Store previous value for change detection
|
||||
this._previousData[property] = this._data[property];
|
||||
|
||||
// Update the data
|
||||
this._data[property] = value;
|
||||
|
||||
console.log(`ViewModel: Property '${property}' changed, notifying listeners...`);
|
||||
this._notifyListeners(property, value, this._previousData[property]);
|
||||
} else {
|
||||
console.log(`ViewModel: Property '${property}' unchanged, skipping notification`);
|
||||
}
|
||||
}
|
||||
|
||||
// Set multiple properties at once with change detection
|
||||
setMultiple(properties) {
|
||||
const changedProperties = {};
|
||||
const unchangedProperties = {};
|
||||
|
||||
Object.keys(properties).forEach(key => {
|
||||
if (this._data[key] !== properties[key]) {
|
||||
changedProperties[key] = properties[key];
|
||||
} else {
|
||||
unchangedProperties[key] = properties[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Set all properties
|
||||
Object.keys(properties).forEach(key => {
|
||||
this._data[key] = properties[key];
|
||||
});
|
||||
|
||||
// Notify listeners only for changed properties
|
||||
Object.keys(changedProperties).forEach(key => {
|
||||
this._notifyListeners(key, changedProperties[key], this._previousData[key]);
|
||||
});
|
||||
|
||||
if (Object.keys(changedProperties).length > 0) {
|
||||
console.log(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties));
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to property changes
|
||||
subscribe(property, callback) {
|
||||
if (!this._listeners.has(property)) {
|
||||
this._listeners.set(property, []);
|
||||
}
|
||||
this._listeners.get(property).push(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const callbacks = this._listeners.get(property);
|
||||
if (callbacks) {
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Notify listeners of property changes
|
||||
_notifyListeners(property, value, previousValue) {
|
||||
console.log(`ViewModel: _notifyListeners called for property '${property}'`);
|
||||
if (this._listeners.has(property)) {
|
||||
const callbacks = this._listeners.get(property);
|
||||
console.log(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`);
|
||||
callbacks.forEach((callback, index) => {
|
||||
try {
|
||||
console.log(`ViewModel: Calling listener ${index} for property '${property}'`);
|
||||
callback(value, previousValue);
|
||||
} catch (error) {
|
||||
console.error(`Error in property listener for ${property}:`, error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(`ViewModel: No listeners found for property '${property}'`);
|
||||
}
|
||||
}
|
||||
|
||||
// Publish event to event bus
|
||||
publish(event, data) {
|
||||
if (this._eventBus) {
|
||||
this._eventBus.publish(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all data
|
||||
getAll() {
|
||||
return { ...this._data };
|
||||
}
|
||||
|
||||
// Clear all data
|
||||
clear() {
|
||||
this._data = {};
|
||||
this._listeners.clear();
|
||||
}
|
||||
|
||||
// UI State Management Methods
|
||||
setUIState(key, value) {
|
||||
this._uiState.set(key, value);
|
||||
}
|
||||
|
||||
getUIState(key) {
|
||||
return this._uiState.get(key);
|
||||
}
|
||||
|
||||
getAllUIState() {
|
||||
return new Map(this._uiState);
|
||||
}
|
||||
|
||||
clearUIState(key) {
|
||||
if (key) {
|
||||
this._uiState.delete(key);
|
||||
} else {
|
||||
this._uiState.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a property has changed
|
||||
hasChanged(property) {
|
||||
return this._data[property] !== this._previousData[property];
|
||||
}
|
||||
|
||||
// Get previous value of a property
|
||||
getPrevious(property) {
|
||||
return this._previousData[property];
|
||||
}
|
||||
|
||||
// Batch update with change detection
|
||||
batchUpdate(updates, options = {}) {
|
||||
const { preserveUIState = true, notifyChanges = true } = options;
|
||||
|
||||
if (preserveUIState) {
|
||||
// Store current UI state
|
||||
const currentUIState = new Map(this._uiState);
|
||||
|
||||
// Apply updates
|
||||
Object.keys(updates).forEach(key => {
|
||||
this._data[key] = updates[key];
|
||||
});
|
||||
|
||||
// Restore UI state
|
||||
this._uiState = currentUIState;
|
||||
} else {
|
||||
// Apply updates normally
|
||||
Object.keys(updates).forEach(key => {
|
||||
this._data[key] = updates[key];
|
||||
});
|
||||
}
|
||||
|
||||
// Notify listeners if requested
|
||||
if (notifyChanges) {
|
||||
Object.keys(updates).forEach(key => {
|
||||
this._notifyListeners(key, updates[key], this._previousData[key]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Base Component class with enhanced state preservation
|
||||
class Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
this.container = container;
|
||||
this.viewModel = viewModel;
|
||||
this.eventBus = eventBus;
|
||||
this.isMounted = false;
|
||||
this.unsubscribers = [];
|
||||
this.uiState = new Map(); // Local UI state for this component
|
||||
|
||||
// Set event bus on view model
|
||||
if (this.viewModel) {
|
||||
this.viewModel.setEventBus(eventBus);
|
||||
}
|
||||
|
||||
// Bind methods
|
||||
this.render = this.render.bind(this);
|
||||
this.mount = this.mount.bind(this);
|
||||
this.unmount = this.unmount.bind(this);
|
||||
this.updatePartial = this.updatePartial.bind(this);
|
||||
}
|
||||
|
||||
// Mount the component
|
||||
mount() {
|
||||
if (this.isMounted) return;
|
||||
|
||||
console.log(`${this.constructor.name}: Starting mount...`);
|
||||
this.isMounted = true;
|
||||
this.setupEventListeners();
|
||||
this.setupViewModelListeners();
|
||||
this.render();
|
||||
|
||||
console.log(`${this.constructor.name}: Mounted successfully`);
|
||||
}
|
||||
|
||||
// Unmount the component
|
||||
unmount() {
|
||||
if (!this.isMounted) return;
|
||||
|
||||
this.isMounted = false;
|
||||
this.cleanupEventListeners();
|
||||
this.cleanupViewModelListeners();
|
||||
|
||||
console.log(`${this.constructor.name} unmounted`);
|
||||
}
|
||||
|
||||
// Pause the component (keep alive but pause activity)
|
||||
pause() {
|
||||
if (!this.isMounted) return;
|
||||
|
||||
console.log(`${this.constructor.name}: Pausing component`);
|
||||
|
||||
// Pause any active timers or animations
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
this.updateInterval = null;
|
||||
}
|
||||
|
||||
// Pause any ongoing operations
|
||||
this.isPaused = true;
|
||||
|
||||
// Override in subclasses to pause specific functionality
|
||||
this.onPause();
|
||||
}
|
||||
|
||||
// Resume the component
|
||||
resume() {
|
||||
if (!this.isMounted || !this.isPaused) return;
|
||||
|
||||
console.log(`${this.constructor.name}: Resuming component`);
|
||||
|
||||
this.isPaused = false;
|
||||
|
||||
// Restart any necessary timers or operations
|
||||
this.onResume();
|
||||
|
||||
// Re-render if needed
|
||||
if (this.shouldRenderOnResume()) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
// Override in subclasses to handle pause-specific logic
|
||||
onPause() {
|
||||
// Default implementation does nothing
|
||||
}
|
||||
|
||||
// Override in subclasses to handle resume-specific logic
|
||||
onResume() {
|
||||
// Default implementation does nothing
|
||||
}
|
||||
|
||||
// Override in subclasses to determine if re-render is needed on resume
|
||||
shouldRenderOnResume() {
|
||||
// Default: don't re-render on resume
|
||||
return false;
|
||||
}
|
||||
|
||||
// Setup event listeners (override in subclasses)
|
||||
setupEventListeners() {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
// Setup view model listeners (override in subclasses)
|
||||
setupViewModelListeners() {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
// Cleanup event listeners (override in subclasses)
|
||||
cleanupEventListeners() {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
// Cleanup view model listeners (override in subclasses)
|
||||
cleanupViewModelListeners() {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
// Render the component (override in subclasses)
|
||||
render() {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
// Partial update method for efficient data updates
|
||||
updatePartial(property, newValue, previousValue) {
|
||||
// Override in subclasses to implement partial updates
|
||||
console.log(`${this.constructor.name}: Partial update for '${property}':`, { newValue, previousValue });
|
||||
}
|
||||
|
||||
// UI State Management Methods
|
||||
setUIState(key, value) {
|
||||
this.uiState.set(key, value);
|
||||
// Also store in view model for persistence across refreshes
|
||||
if (this.viewModel) {
|
||||
this.viewModel.setUIState(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
getUIState(key) {
|
||||
// First try local state, then view model state
|
||||
return this.uiState.get(key) || (this.viewModel ? this.viewModel.getUIState(key) : null);
|
||||
}
|
||||
|
||||
getAllUIState() {
|
||||
const localState = new Map(this.uiState);
|
||||
const viewModelState = this.viewModel ? this.viewModel.getAllUIState() : new Map();
|
||||
|
||||
// Merge states, with local state taking precedence
|
||||
const mergedState = new Map(viewModelState);
|
||||
localState.forEach((value, key) => mergedState.set(key, value));
|
||||
|
||||
return mergedState;
|
||||
}
|
||||
|
||||
clearUIState(key) {
|
||||
if (key) {
|
||||
this.uiState.delete(key);
|
||||
if (this.viewModel) {
|
||||
this.viewModel.clearUIState(key);
|
||||
}
|
||||
} else {
|
||||
this.uiState.clear();
|
||||
if (this.viewModel) {
|
||||
this.viewModel.clearUIState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore UI state from view model
|
||||
restoreUIState() {
|
||||
if (this.viewModel) {
|
||||
const viewModelState = this.viewModel.getAllUIState();
|
||||
viewModelState.forEach((value, key) => {
|
||||
this.uiState.set(key, value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to add event listener and track for cleanup
|
||||
addEventListener(element, event, handler) {
|
||||
element.addEventListener(event, handler);
|
||||
this.unsubscribers.push(() => {
|
||||
element.removeEventListener(event, handler);
|
||||
});
|
||||
}
|
||||
|
||||
// Helper method to subscribe to event bus and track for cleanup
|
||||
subscribeToEvent(event, handler) {
|
||||
const unsubscribe = this.eventBus.subscribe(event, handler);
|
||||
this.unsubscribers.push(unsubscribe);
|
||||
}
|
||||
|
||||
// Helper method to subscribe to view model property and track for cleanup
|
||||
subscribeToProperty(property, handler) {
|
||||
if (this.viewModel) {
|
||||
const unsubscribe = this.viewModel.subscribe(property, (newValue, previousValue) => {
|
||||
// Call handler with both new and previous values for change detection
|
||||
handler(newValue, previousValue);
|
||||
});
|
||||
this.unsubscribers.push(unsubscribe);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to find element within component container
|
||||
findElement(selector) {
|
||||
return this.container.querySelector(selector);
|
||||
}
|
||||
|
||||
// Helper method to find all elements within component container
|
||||
findAllElements(selector) {
|
||||
return this.container.querySelectorAll(selector);
|
||||
}
|
||||
|
||||
// Helper method to set innerHTML safely
|
||||
setHTML(selector, html) {
|
||||
console.log(`${this.constructor.name}: setHTML called with selector '${selector}', html length: ${html.length}`);
|
||||
|
||||
let element;
|
||||
if (selector === '') {
|
||||
// Empty selector means set HTML on the component's container itself
|
||||
element = this.container;
|
||||
console.log(`${this.constructor.name}: Using component container for empty selector`);
|
||||
} else {
|
||||
// Find element within the component's container
|
||||
element = this.findElement(selector);
|
||||
}
|
||||
|
||||
if (element) {
|
||||
console.log(`${this.constructor.name}: Element found, setting innerHTML`);
|
||||
element.innerHTML = html;
|
||||
console.log(`${this.constructor.name}: innerHTML set successfully`);
|
||||
} else {
|
||||
console.error(`${this.constructor.name}: Element not found for selector '${selector}'`);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to set text content safely
|
||||
setText(selector, text) {
|
||||
const element = this.findElement(selector);
|
||||
if (element) {
|
||||
element.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to add/remove CSS classes
|
||||
setClass(selector, className, add = true) {
|
||||
const element = this.findElement(selector);
|
||||
if (element) {
|
||||
if (add) {
|
||||
element.classList.add(className);
|
||||
} else {
|
||||
element.classList.remove(className);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to set CSS styles
|
||||
setStyle(selector, property, value) {
|
||||
const element = this.findElement(selector);
|
||||
if (element) {
|
||||
element.style[property] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to show/hide elements
|
||||
setVisible(selector, visible) {
|
||||
const element = this.findElement(selector);
|
||||
if (element) {
|
||||
element.style.display = visible ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to enable/disable elements
|
||||
setEnabled(selector, enabled) {
|
||||
const element = this.findElement(selector);
|
||||
if (element) {
|
||||
element.disabled = !enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Application class to manage components and routing
|
||||
class App {
|
||||
constructor() {
|
||||
this.eventBus = new EventBus();
|
||||
this.components = new Map();
|
||||
this.currentView = null;
|
||||
this.routes = new Map();
|
||||
this.navigationInProgress = false;
|
||||
this.navigationQueue = [];
|
||||
this.lastNavigationTime = 0;
|
||||
this.navigationCooldown = 300; // 300ms cooldown between navigations
|
||||
|
||||
// Component cache to keep components alive
|
||||
this.componentCache = new Map();
|
||||
this.cachedViews = new Set();
|
||||
}
|
||||
|
||||
// Register a route
|
||||
registerRoute(name, componentClass, containerId, viewModel = null) {
|
||||
this.routes.set(name, { componentClass, containerId, viewModel });
|
||||
|
||||
// Pre-initialize component in cache for better performance
|
||||
this.preInitializeComponent(name, componentClass, containerId, viewModel);
|
||||
}
|
||||
|
||||
// Pre-initialize component in cache
|
||||
preInitializeComponent(name, componentClass, containerId, viewModel) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
// Create component instance but don't mount it yet
|
||||
const component = new componentClass(container, viewModel, this.eventBus);
|
||||
component.routeName = name;
|
||||
component.isCached = true;
|
||||
|
||||
// Store in cache
|
||||
this.componentCache.set(name, component);
|
||||
console.log(`App: Pre-initialized component for route '${name}'`);
|
||||
}
|
||||
|
||||
// Navigate to a route
|
||||
navigateTo(routeName) {
|
||||
// Check cooldown period
|
||||
const now = Date.now();
|
||||
if (now - this.lastNavigationTime < this.navigationCooldown) {
|
||||
console.log(`App: Navigation cooldown active, skipping route '${routeName}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If navigation is already in progress, queue this request
|
||||
if (this.navigationInProgress) {
|
||||
console.log(`App: Navigation in progress, queuing route '${routeName}'`);
|
||||
if (!this.navigationQueue.includes(routeName)) {
|
||||
this.navigationQueue.push(routeName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If trying to navigate to the same route, do nothing
|
||||
if (this.currentView && this.currentView.routeName === routeName) {
|
||||
console.log(`App: Already on route '${routeName}', skipping navigation`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastNavigationTime = now;
|
||||
this.performNavigation(routeName);
|
||||
}
|
||||
|
||||
// Perform the actual navigation
|
||||
async performNavigation(routeName) {
|
||||
this.navigationInProgress = true;
|
||||
|
||||
try {
|
||||
console.log(`App: Navigating to route '${routeName}'`);
|
||||
const route = this.routes.get(routeName);
|
||||
if (!route) {
|
||||
console.error(`Route '${routeName}' not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`App: Route found, component: ${route.componentClass.name}, container: ${route.containerId}`);
|
||||
|
||||
// Get or create component from cache
|
||||
let component = this.componentCache.get(routeName);
|
||||
if (!component) {
|
||||
console.log(`App: Component not in cache, creating new instance for '${routeName}'`);
|
||||
const container = document.getElementById(route.containerId);
|
||||
if (!container) {
|
||||
console.error(`Container '${route.containerId}' not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
component = new route.componentClass(container, route.viewModel, this.eventBus);
|
||||
component.routeName = routeName;
|
||||
component.isCached = true;
|
||||
this.componentCache.set(routeName, component);
|
||||
}
|
||||
|
||||
// Hide current view smoothly
|
||||
if (this.currentView) {
|
||||
console.log('App: Hiding current view');
|
||||
await this.hideCurrentView();
|
||||
}
|
||||
|
||||
// Show new view
|
||||
console.log(`App: Showing new view '${routeName}'`);
|
||||
await this.showView(routeName, component);
|
||||
|
||||
// Update navigation state
|
||||
this.updateNavigation(routeName);
|
||||
|
||||
// Set as current view
|
||||
this.currentView = component;
|
||||
|
||||
// Mark view as cached for future use
|
||||
this.cachedViews.add(routeName);
|
||||
|
||||
console.log(`App: Navigation to '${routeName}' completed`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('App: Navigation failed:', error);
|
||||
} finally {
|
||||
this.navigationInProgress = false;
|
||||
|
||||
// Process any queued navigation requests
|
||||
if (this.navigationQueue.length > 0) {
|
||||
const nextRoute = this.navigationQueue.shift();
|
||||
console.log(`App: Processing queued navigation to '${nextRoute}'`);
|
||||
setTimeout(() => this.navigateTo(nextRoute), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide current view smoothly
|
||||
async hideCurrentView() {
|
||||
if (!this.currentView) return;
|
||||
|
||||
// If component is mounted, pause it instead of unmounting
|
||||
if (this.currentView.isMounted) {
|
||||
console.log('App: Pausing current view instead of unmounting');
|
||||
this.currentView.pause();
|
||||
}
|
||||
|
||||
// Fade out the container
|
||||
if (this.currentView.container) {
|
||||
this.currentView.container.style.opacity = '0';
|
||||
this.currentView.container.style.transition = 'opacity 0.15s ease-out';
|
||||
}
|
||||
|
||||
// Wait for fade out to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
}
|
||||
|
||||
// Show view smoothly
|
||||
async showView(routeName, component) {
|
||||
const container = component.container;
|
||||
|
||||
// Ensure component is mounted (but not necessarily active)
|
||||
if (!component.isMounted) {
|
||||
console.log(`App: Mounting component for '${routeName}'`);
|
||||
component.mount();
|
||||
} else {
|
||||
console.log(`App: Resuming component for '${routeName}'`);
|
||||
component.resume();
|
||||
}
|
||||
|
||||
// Fade in the container
|
||||
container.style.opacity = '0';
|
||||
container.style.transition = 'opacity 0.2s ease-in';
|
||||
|
||||
// Small delay to ensure smooth transition
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Fade in
|
||||
container.style.opacity = '1';
|
||||
}
|
||||
|
||||
// Update navigation state
|
||||
updateNavigation(activeRoute) {
|
||||
// Remove active class from all nav tabs
|
||||
document.querySelectorAll('.nav-tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
|
||||
// Add active class to current route tab
|
||||
const activeTab = document.querySelector(`[data-view="${activeRoute}"]`);
|
||||
if (activeTab) {
|
||||
activeTab.classList.add('active');
|
||||
}
|
||||
|
||||
// Hide all view contents with smooth transition
|
||||
document.querySelectorAll('.view-content').forEach(view => {
|
||||
view.classList.remove('active');
|
||||
view.style.opacity = '0';
|
||||
view.style.transition = 'opacity 0.15s ease-out';
|
||||
});
|
||||
|
||||
// Show current view content with smooth transition
|
||||
const activeView = document.getElementById(`${activeRoute}-view`);
|
||||
if (activeView) {
|
||||
activeView.classList.add('active');
|
||||
// Small delay to ensure smooth transition
|
||||
setTimeout(() => {
|
||||
activeView.style.opacity = '1';
|
||||
activeView.style.transition = 'opacity 0.2s ease-in';
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
// Register a component
|
||||
registerComponent(name, component) {
|
||||
this.components.set(name, component);
|
||||
}
|
||||
|
||||
// Get a component by name
|
||||
getComponent(name) {
|
||||
return this.components.get(name);
|
||||
}
|
||||
|
||||
// Get the event bus
|
||||
getEventBus() {
|
||||
return this.eventBus;
|
||||
}
|
||||
|
||||
// Initialize the application
|
||||
init() {
|
||||
console.log('SPORE UI Framework initialized');
|
||||
|
||||
// Note: Navigation is now handled by the app initialization
|
||||
// to ensure routes are registered before navigation
|
||||
}
|
||||
|
||||
// Setup navigation
|
||||
setupNavigation() {
|
||||
document.querySelectorAll('.nav-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const routeName = tab.dataset.view;
|
||||
this.navigateTo(routeName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up cached components (call when app is shutting down)
|
||||
cleanup() {
|
||||
console.log('App: Cleaning up cached components...');
|
||||
|
||||
this.componentCache.forEach((component, routeName) => {
|
||||
if (component.isMounted) {
|
||||
console.log(`App: Unmounting cached component '${routeName}'`);
|
||||
component.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
this.componentCache.clear();
|
||||
this.cachedViews.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Global app instance
|
||||
window.app = new App();
|
||||
@@ -25,7 +25,7 @@
|
||||
<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" onclick="selectRandomPrimaryNode()" title="🎲 Select random primary node">
|
||||
<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"/>
|
||||
@@ -33,7 +33,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="refresh-btn" onclick="refreshClusterMembers()">
|
||||
<button class="refresh-btn" id="refresh-cluster-btn">
|
||||
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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"/>
|
||||
@@ -113,6 +113,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
<script src="framework.js"></script>
|
||||
<script src="api-client.js"></script>
|
||||
<script src="view-models.js"></script>
|
||||
<script src="components.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1263
public/script.js
1263
public/script.js
File diff suppressed because it is too large
Load Diff
104
public/simple-test.html
Normal file
104
public/simple-test.html
Normal file
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Simple Framework Test</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #ccc; }
|
||||
.success { color: green; }
|
||||
.error { color: red; }
|
||||
button { margin: 5px; padding: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Simple Framework Test</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>API Test</h2>
|
||||
<button onclick="testAPI()">Test API Connection</button>
|
||||
<div id="api-result"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Framework Test</h2>
|
||||
<button onclick="testFramework()">Test Framework</button>
|
||||
<div id="framework-result"></div>
|
||||
</div>
|
||||
|
||||
<script src="framework.js"></script>
|
||||
<script src="api-client.js"></script>
|
||||
<script src="view-models.js"></script>
|
||||
|
||||
<script>
|
||||
async function testAPI() {
|
||||
const resultDiv = document.getElementById('api-result');
|
||||
resultDiv.innerHTML = 'Testing...';
|
||||
|
||||
try {
|
||||
// Test cluster members API
|
||||
const members = await window.apiClient.getClusterMembers();
|
||||
console.log('Members:', members);
|
||||
|
||||
// Test discovery API
|
||||
const discovery = await window.apiClient.getDiscoveryInfo();
|
||||
console.log('Discovery:', discovery);
|
||||
|
||||
resultDiv.innerHTML = `
|
||||
<div class="success">
|
||||
✅ API Test Successful!<br>
|
||||
Cluster Members: ${members.members?.length || 0}<br>
|
||||
Primary Node: ${discovery.primaryNode || 'None'}<br>
|
||||
Total Nodes: ${discovery.totalNodes || 0}
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('API test failed:', error);
|
||||
resultDiv.innerHTML = `<div class="error">❌ API Test Failed: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function testFramework() {
|
||||
const resultDiv = document.getElementById('framework-result');
|
||||
resultDiv.innerHTML = 'Testing...';
|
||||
|
||||
try {
|
||||
// Test framework classes
|
||||
if (typeof EventBus !== 'undefined' &&
|
||||
typeof ViewModel !== 'undefined' &&
|
||||
typeof Component !== 'undefined') {
|
||||
|
||||
// Create a simple view model
|
||||
const vm = new ViewModel();
|
||||
vm.set('test', 'Hello World');
|
||||
|
||||
if (vm.get('test') === 'Hello World') {
|
||||
resultDiv.innerHTML = `
|
||||
<div class="success">
|
||||
✅ Framework Test Successful!<br>
|
||||
EventBus: ${typeof EventBus}<br>
|
||||
ViewModel: ${typeof ViewModel}<br>
|
||||
Component: ${typeof Component}<br>
|
||||
ViewModel test: ${vm.get('test')}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
throw new Error('ViewModel get/set not working');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Framework classes not found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Framework test failed:', error);
|
||||
resultDiv.innerHTML = `<div class="error">❌ Framework Test Failed: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('Page loaded, framework ready');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -183,6 +183,25 @@ p {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.refresh-icon.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.members-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
@@ -194,10 +213,12 @@ p {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.member-card::before {
|
||||
@@ -210,7 +231,7 @@ p {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -220,6 +241,7 @@ p {
|
||||
|
||||
.member-card:hover {
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.member-card.expanded {
|
||||
@@ -229,7 +251,7 @@ p {
|
||||
}
|
||||
|
||||
.member-card.expanded:hover {
|
||||
transform: scale(1.02);
|
||||
transform: scale(1.02) translateY(-2px);
|
||||
}
|
||||
|
||||
.expand-icon:hover {
|
||||
@@ -285,20 +307,15 @@ p {
|
||||
}
|
||||
|
||||
.member-details {
|
||||
display: none;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-in-out, opacity 0.2s ease-in-out;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: 1px solid transparent;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.member-card.expanded .member-details {
|
||||
display: block;
|
||||
max-height: 500px; /* Adjust based on your content */
|
||||
opacity: 1;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
@@ -705,10 +722,13 @@ p {
|
||||
/* View Content Styles */
|
||||
.view-content {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.view-content.active {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Firmware Section Styles */
|
||||
@@ -981,6 +1001,19 @@ p {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Style for no-nodes message */
|
||||
.no-nodes-message {
|
||||
color: #fbbf24 !important;
|
||||
font-size: 0.8rem !important;
|
||||
margin-top: 0.25rem !important;
|
||||
font-style: italic !important;
|
||||
text-align: center;
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.deploy-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
@@ -1461,4 +1494,55 @@ p {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading and state transitions */
|
||||
.loading, .error, .empty-state {
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.3s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth expand/collapse animations */
|
||||
.member-details {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-in-out, opacity 0.2s ease-in-out;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.member-card.expanded .member-details {
|
||||
max-height: 500px; /* Adjust based on your content */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Expand icon rotation */
|
||||
.expand-icon svg {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.member-card.expanded .expand-icon svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Navigation tab transitions */
|
||||
.nav-tab {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
351
public/test-caching-system.html
Normal file
351
public/test-caching-system.html
Normal file
@@ -0,0 +1,351 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SPORE UI - Component Caching Test</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<style>
|
||||
.test-info {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.test-button {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
color: #4ade80;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin: 0.25rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.test-button:hover {
|
||||
background: rgba(74, 222, 128, 0.3);
|
||||
border-color: rgba(74, 222, 128, 0.5);
|
||||
}
|
||||
.test-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.test-results {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="test-info">
|
||||
<h3>🧪 Component Caching System Test</h3>
|
||||
<p>This page tests the new component caching system to verify that components are not re-rendered on view switches.</p>
|
||||
<p><strong>Note:</strong> Components now start with clean default state (collapsed cards, status tab) and don't restore previous UI state.</p>
|
||||
<div>
|
||||
<button class="test-button" onclick="testComponentCaching()">Test Component Caching</button>
|
||||
<button class="test-button" onclick="testDefaultState()">Test Default State</button>
|
||||
<button class="test-button" onclick="testPerformance()">Test Performance</button>
|
||||
<button class="test-button" onclick="clearTestData()">Clear Test Data</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-navigation">
|
||||
<div class="nav-left">
|
||||
<button class="nav-tab active" data-view="cluster">🌐 Cluster</button>
|
||||
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<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>
|
||||
<button class="refresh-btn" id="refresh-cluster-btn">
|
||||
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="cluster-members-container">
|
||||
<div class="loading">
|
||||
<div>Loading cluster members...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="firmware-view" class="view-content">
|
||||
<div class="firmware-section">
|
||||
<div class="firmware-overview">
|
||||
<div class="firmware-actions">
|
||||
<div class="action-group">
|
||||
<h3>🚀 Firmware Update</h3>
|
||||
<div class="firmware-upload-compact">
|
||||
<div class="compact-upload-row">
|
||||
<div class="file-upload-area">
|
||||
<div class="target-options">
|
||||
<label class="target-option">
|
||||
<input type="radio" name="target-type" value="all" checked>
|
||||
<span class="radio-custom"></span>
|
||||
<span class="target-label">All Nodes</span>
|
||||
</label>
|
||||
<label class="target-option specific-node-option">
|
||||
<input type="radio" name="target-type" value="specific">
|
||||
<span class="radio-custom"></span>
|
||||
<span class="target-label">Specific Node</span>
|
||||
<select id="specific-node-select" class="node-select">
|
||||
<option value="">Select a node...</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="file-input-wrapper">
|
||||
<input type="file" id="global-firmware-file" accept=".bin,.hex" style="display: none;">
|
||||
<button class="upload-btn-compact" onclick="document.getElementById('global-firmware-file').click()">
|
||||
📁 Choose File
|
||||
</button>
|
||||
<span class="file-info" id="file-info">No file selected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="deploy-btn" id="deploy-btn" disabled>
|
||||
🚀 Deploy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="firmware-nodes-list" id="firmware-nodes-list">
|
||||
<!-- Nodes will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-results" id="test-results">
|
||||
<h4>Test Results:</h4>
|
||||
<div id="test-output">Run a test to see results...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="framework.js"></script>
|
||||
<script src="api-client.js"></script>
|
||||
<script src="view-models.js"></script>
|
||||
<script src="components.js"></script>
|
||||
<script src="app.js"></script>
|
||||
|
||||
<script>
|
||||
// Test tracking variables
|
||||
let componentCreationCount = 0;
|
||||
let componentMountCount = 0;
|
||||
let componentUnmountCount = 0;
|
||||
let componentPauseCount = 0;
|
||||
let componentResumeCount = 0;
|
||||
let testStartTime = 0;
|
||||
|
||||
// Override console.log to track component operations
|
||||
const originalLog = console.log;
|
||||
console.log = function(...args) {
|
||||
const message = args.join(' ');
|
||||
|
||||
// Track component operations
|
||||
if (message.includes('Constructor called')) {
|
||||
componentCreationCount++;
|
||||
} else if (message.includes('Mounting...')) {
|
||||
componentMountCount++;
|
||||
} else if (message.includes('Unmounting...')) {
|
||||
componentUnmountCount++;
|
||||
} else if (message.includes('Pausing...')) {
|
||||
componentPauseCount++;
|
||||
} else if (message.includes('Resuming...')) {
|
||||
componentResumeCount++;
|
||||
}
|
||||
|
||||
// Call original console.log
|
||||
originalLog.apply(console, args);
|
||||
};
|
||||
|
||||
// Test functions
|
||||
function testComponentCaching() {
|
||||
console.log('🧪 Testing component caching system...');
|
||||
resetTestCounts();
|
||||
|
||||
const results = document.getElementById('test-output');
|
||||
results.innerHTML = 'Testing component caching...<br>';
|
||||
|
||||
// Test rapid view switching
|
||||
const clusterTab = document.querySelector('[data-view="cluster"]');
|
||||
const firmwareTab = document.querySelector('[data-view="firmware"]');
|
||||
|
||||
let switchCount = 0;
|
||||
const maxSwitches = 10;
|
||||
|
||||
const rapidSwitch = setInterval(() => {
|
||||
if (switchCount >= maxSwitches) {
|
||||
clearInterval(rapidSwitch);
|
||||
analyzeResults();
|
||||
return;
|
||||
}
|
||||
|
||||
if (switchCount % 2 === 0) {
|
||||
firmwareTab.click();
|
||||
results.innerHTML += `Switch ${switchCount + 1}: Cluster → Firmware<br>`;
|
||||
} else {
|
||||
clusterTab.click();
|
||||
results.innerHTML += `Switch ${switchCount + 1}: Firmware → Cluster<br>`;
|
||||
}
|
||||
|
||||
switchCount++;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function testDefaultState() {
|
||||
console.log('🧪 Testing default state...');
|
||||
resetTestCounts();
|
||||
|
||||
const results = document.getElementById('test-output');
|
||||
results.innerHTML = 'Testing default state...<br>';
|
||||
|
||||
// Switch to cluster view
|
||||
const clusterTab = document.querySelector('[data-view="cluster"]');
|
||||
clusterTab.click();
|
||||
results.innerHTML += 'Switched to Cluster View.<br>';
|
||||
|
||||
// Check if default state is applied (collapsed cards, status tab)
|
||||
setTimeout(() => {
|
||||
const memberCards = document.querySelectorAll('.member-card');
|
||||
const statusTab = document.querySelector('.nav-tab.active[data-view="status"]');
|
||||
|
||||
if (memberCards.length > 0) {
|
||||
results.innerHTML += 'Checking default state:<br>';
|
||||
results.innerHTML += `- Member cards are collapsed: ${memberCards.every(card => !card.classList.contains('expanded'))}<br>`;
|
||||
results.innerHTML += `- Status tab is active: ${statusTab && statusTab.classList.contains('active')}<br>`;
|
||||
analyzeResults();
|
||||
} else {
|
||||
results.innerHTML += 'No member cards found to check default state<br>';
|
||||
analyzeResults();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function testPerformance() {
|
||||
console.log('🧪 Testing performance...');
|
||||
resetTestCounts();
|
||||
testStartTime = performance.now();
|
||||
|
||||
const results = document.getElementById('test-output');
|
||||
results.innerHTML = 'Testing performance with rapid switching...<br>';
|
||||
|
||||
// Perform rapid view switching
|
||||
const clusterTab = document.querySelector('[data-view="cluster"]');
|
||||
const firmwareTab = document.querySelector('[data-view="firmware"]');
|
||||
|
||||
let switchCount = 0;
|
||||
const maxSwitches = 20;
|
||||
|
||||
const performanceTest = setInterval(() => {
|
||||
if (switchCount >= maxSwitches) {
|
||||
clearInterval(performanceTest);
|
||||
const totalTime = performance.now() - testStartTime;
|
||||
results.innerHTML += `Performance test completed in ${totalTime.toFixed(2)}ms<br>`;
|
||||
analyzeResults();
|
||||
return;
|
||||
}
|
||||
|
||||
if (switchCount % 2 === 0) {
|
||||
firmwareTab.click();
|
||||
} else {
|
||||
clusterTab.click();
|
||||
}
|
||||
|
||||
switchCount++;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function resetTestCounts() {
|
||||
componentCreationCount = 0;
|
||||
componentMountCount = 0;
|
||||
componentUnmountCount = 0;
|
||||
componentPauseCount = 0;
|
||||
componentResumeCount = 0;
|
||||
}
|
||||
|
||||
function analyzeResults() {
|
||||
const results = document.getElementById('test-output');
|
||||
results.innerHTML += '<br><strong>Test Analysis:</strong><br>';
|
||||
results.innerHTML += `Component Creations: ${componentCreationCount}<br>`;
|
||||
results.innerHTML += `Component Mounts: ${componentMountCount}<br>`;
|
||||
results.innerHTML += `Component Unmounts: ${componentUnmountCount}<br>`;
|
||||
results.innerHTML += `Component Pauses: ${componentPauseCount}<br>`;
|
||||
results.innerHTML += `Component Resumes: ${componentResumeCount}<br><br>`;
|
||||
|
||||
// Analyze results
|
||||
if (componentCreationCount <= 2) {
|
||||
results.innerHTML += '✅ <strong>PASS:</strong> Components are properly cached (not re-created)<br>';
|
||||
} else {
|
||||
results.innerHTML += '❌ <strong>FAIL:</strong> Components are being re-created on view switches<br>';
|
||||
}
|
||||
|
||||
if (componentUnmountCount === 0) {
|
||||
results.innerHTML += '✅ <strong>PASS:</strong> Components are never unmounted during view switches<br>';
|
||||
} else {
|
||||
results.innerHTML += '❌ <strong>FAIL:</strong> Components are being unmounted during view switches<br>';
|
||||
}
|
||||
|
||||
if (componentPauseCount > 0 && componentResumeCount > 0) {
|
||||
results.innerHTML += '✅ <strong>PASS:</strong> Pause/Resume pattern is working correctly<br>';
|
||||
} else {
|
||||
results.innerHTML += '❌ <strong>FAIL:</strong> Pause/Resume pattern is not working<br>';
|
||||
}
|
||||
|
||||
// New test for default state behavior
|
||||
if (componentCreationCount <= 2 && componentUnmountCount === 0) {
|
||||
results.innerHTML += '✅ <strong>PASS:</strong> Component caching system is working correctly<br>';
|
||||
results.innerHTML += '✅ <strong>PASS:</strong> Components start with clean default state<br>';
|
||||
results.innerHTML += '✅ <strong>PASS:</strong> No complex state restoration causing issues<br>';
|
||||
}
|
||||
}
|
||||
|
||||
function clearTestData() {
|
||||
console.log('🧪 Clearing test data...');
|
||||
localStorage.removeItem('spore_cluster_expanded_cards');
|
||||
localStorage.removeItem('spore_cluster_active_tabs');
|
||||
console.log('🧪 Test data cleared');
|
||||
|
||||
const results = document.getElementById('test-output');
|
||||
results.innerHTML = 'Test data cleared. Run a test to see results...';
|
||||
}
|
||||
|
||||
// Add test info to console
|
||||
console.log('🧪 SPORE UI Component Caching Test Page Loaded');
|
||||
console.log('🧪 Use the test buttons above to verify the caching system works');
|
||||
console.log('🧪 Expected: Components should be created once and cached, never re-created');
|
||||
console.log('🧪 Expected: Components start with clean default state (collapsed cards, status tab)');
|
||||
console.log('🧪 Expected: No complex state restoration causing incorrect behavior');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
351
public/test-deploy-button.html
Normal file
351
public/test-deploy-button.html
Normal file
@@ -0,0 +1,351 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deploy Button Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #1a202c;
|
||||
color: white;
|
||||
}
|
||||
.test-section {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.firmware-actions {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.target-options {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.target-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-input-wrapper {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.deploy-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.deploy-btn:disabled {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.node-select {
|
||||
background: #2d3748;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.no-nodes-message {
|
||||
color: #fbbf24;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.25rem;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
.cluster-members {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.member-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.status-online {
|
||||
color: #4ade80;
|
||||
}
|
||||
.status-offline {
|
||||
color: #f87171;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🚀 Deploy Button Test</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test Scenario: Deploy Button State</h2>
|
||||
<p>This test demonstrates the deploy button behavior when:</p>
|
||||
<ul>
|
||||
<li>No file is selected</li>
|
||||
<li>No nodes are available</li>
|
||||
<li>File is selected but no target is chosen</li>
|
||||
<li>File is selected and target is chosen</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="firmware-actions">
|
||||
<h3>🚀 Firmware Update</h3>
|
||||
|
||||
<div class="target-options">
|
||||
<label class="target-option">
|
||||
<input type="radio" name="target-type" value="all" checked>
|
||||
<span>All Nodes</span>
|
||||
</label>
|
||||
<label class="target-option">
|
||||
<input type="radio" name="target-type" value="specific">
|
||||
<span>Specific Node</span>
|
||||
<select id="specific-node-select" class="node-select" style="visibility: hidden; opacity: 0;">
|
||||
<option value="">Select a node...</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="file-input-wrapper">
|
||||
<input type="file" id="global-firmware-file" accept=".bin,.hex" style="display: none;">
|
||||
<button onclick="document.getElementById('global-firmware-file').click()">
|
||||
📁 Choose File
|
||||
</button>
|
||||
<span id="file-info">No file selected</span>
|
||||
</div>
|
||||
|
||||
<button class="deploy-btn" id="deploy-btn" disabled>
|
||||
🚀 Deploy Firmware
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cluster-members">
|
||||
<h3>Cluster Members</h3>
|
||||
<div id="cluster-members-container">
|
||||
<div class="loading">Loading cluster members...</div>
|
||||
</div>
|
||||
<button onclick="addTestNode()">Add Test Node</button>
|
||||
<button onclick="removeAllNodes()">Remove All Nodes</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test Instructions</h2>
|
||||
<ol>
|
||||
<li>Select "Specific Node" radio button - notice the deploy button remains disabled</li>
|
||||
<li>Click "Add Test Node" to simulate cluster discovery</li>
|
||||
<li>Select "Specific Node" again - now you should see nodes in the dropdown</li>
|
||||
<li>Select a file - deploy button should remain disabled until you select a node</li>
|
||||
<li>Select a specific node - deploy button should now be enabled</li>
|
||||
<li>Click "Remove All Nodes" to test the "no nodes available" state</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Simulate the cluster members functionality
|
||||
let testNodes = [];
|
||||
|
||||
function addTestNode() {
|
||||
const nodeCount = testNodes.length + 1;
|
||||
const newNode = {
|
||||
ip: `192.168.1.${100 + nodeCount}`,
|
||||
hostname: `TestNode${nodeCount}`,
|
||||
status: 'active',
|
||||
latency: Math.floor(Math.random() * 50) + 10
|
||||
};
|
||||
testNodes.push(newNode);
|
||||
displayClusterMembers();
|
||||
populateNodeSelect();
|
||||
updateDeployButton();
|
||||
}
|
||||
|
||||
function removeAllNodes() {
|
||||
testNodes = [];
|
||||
displayClusterMembers();
|
||||
populateNodeSelect();
|
||||
updateDeployButton();
|
||||
}
|
||||
|
||||
function displayClusterMembers() {
|
||||
const container = document.getElementById('cluster-members-container');
|
||||
|
||||
if (testNodes.length === 0) {
|
||||
container.innerHTML = '<div class="loading">No cluster members found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const membersHTML = testNodes.map(node => {
|
||||
const statusClass = node.status === 'active' ? 'status-online' : 'status-offline';
|
||||
const statusText = node.status === 'active' ? 'Online' : 'Offline';
|
||||
const statusIcon = node.status === 'active' ? '🟢' : '🔴';
|
||||
|
||||
return `
|
||||
<div class="member-card" data-member-ip="${node.ip}">
|
||||
<div class="member-name">${node.hostname}</div>
|
||||
<div class="member-ip">${node.ip}</div>
|
||||
<div class="member-status ${statusClass}">
|
||||
${statusIcon} ${statusText}
|
||||
</div>
|
||||
<div class="member-latency">Latency: ${node.latency}ms</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = membersHTML;
|
||||
}
|
||||
|
||||
function populateNodeSelect() {
|
||||
const select = document.getElementById('specific-node-select');
|
||||
if (!select) return;
|
||||
|
||||
select.innerHTML = '<option value="">Select a node...</option>';
|
||||
|
||||
if (testNodes.length === 0) {
|
||||
const option = document.createElement('option');
|
||||
option.value = "";
|
||||
option.textContent = "No nodes available";
|
||||
option.disabled = true;
|
||||
select.appendChild(option);
|
||||
return;
|
||||
}
|
||||
|
||||
testNodes.forEach(node => {
|
||||
const option = document.createElement('option');
|
||||
option.value = node.ip;
|
||||
option.textContent = `${node.hostname} (${node.ip})`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function updateDeployButton() {
|
||||
const deployBtn = document.getElementById('deploy-btn');
|
||||
const fileInput = document.getElementById('global-firmware-file');
|
||||
const targetType = document.querySelector('input[name="target-type"]:checked');
|
||||
const specificNodeSelect = document.getElementById('specific-node-select');
|
||||
|
||||
if (!deployBtn || !fileInput) return;
|
||||
|
||||
const hasFile = fileInput.files && fileInput.files.length > 0;
|
||||
const hasAvailableNodes = testNodes.length > 0;
|
||||
|
||||
let isValidTarget = false;
|
||||
if (targetType.value === 'all') {
|
||||
isValidTarget = hasAvailableNodes;
|
||||
} else if (targetType.value === 'specific') {
|
||||
isValidTarget = hasAvailableNodes && specificNodeSelect.value && specificNodeSelect.value !== "";
|
||||
}
|
||||
|
||||
deployBtn.disabled = !hasFile || !isValidTarget;
|
||||
|
||||
// Update button text to provide better feedback
|
||||
if (!hasAvailableNodes) {
|
||||
deployBtn.textContent = '🚀 Deploy (No nodes available)';
|
||||
deployBtn.title = 'No cluster nodes are currently available for deployment';
|
||||
} else if (!hasFile) {
|
||||
deployBtn.textContent = '🚀 Deploy Firmware';
|
||||
deployBtn.title = 'Please select a firmware file to deploy';
|
||||
} else if (!isValidTarget) {
|
||||
deployBtn.textContent = '🚀 Deploy Firmware';
|
||||
deployBtn.title = 'Please select a valid target for deployment';
|
||||
} else {
|
||||
deployBtn.textContent = '🚀 Deploy Firmware';
|
||||
deployBtn.title = 'Ready to deploy firmware';
|
||||
}
|
||||
}
|
||||
|
||||
// Setup event listeners
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Setup target selection
|
||||
const targetRadios = document.querySelectorAll('input[name="target-type"]');
|
||||
const specificNodeSelect = document.getElementById('specific-node-select');
|
||||
|
||||
targetRadios.forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
if (radio.value === 'specific') {
|
||||
specificNodeSelect.style.visibility = 'visible';
|
||||
specificNodeSelect.style.opacity = '1';
|
||||
populateNodeSelect();
|
||||
|
||||
// Check if there are any nodes available and show appropriate message
|
||||
if (testNodes.length === 0) {
|
||||
// Show a message that no nodes are available
|
||||
const noNodesMsg = document.createElement('div');
|
||||
noNodesMsg.className = 'no-nodes-message';
|
||||
noNodesMsg.textContent = 'No cluster nodes are currently available';
|
||||
|
||||
// Remove any existing message
|
||||
const existingMsg = specificNodeSelect.parentNode.querySelector('.no-nodes-message');
|
||||
if (existingMsg) {
|
||||
existingMsg.remove();
|
||||
}
|
||||
|
||||
specificNodeSelect.parentNode.appendChild(noNodesMsg);
|
||||
} else {
|
||||
// Remove any existing no-nodes message
|
||||
const existingMsg = specificNodeSelect.parentNode.querySelector('.no-nodes-message');
|
||||
if (existingMsg) {
|
||||
existingMsg.remove();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
specificNodeSelect.style.visibility = 'hidden';
|
||||
specificNodeSelect.style.opacity = '0';
|
||||
|
||||
// Remove any no-nodes message when hiding
|
||||
const existingMsg = specificNodeSelect.parentNode.querySelector('.no-nodes-message');
|
||||
if (existingMsg) {
|
||||
existingMsg.remove();
|
||||
}
|
||||
}
|
||||
updateDeployButton();
|
||||
});
|
||||
});
|
||||
|
||||
// Setup specific node select change handler
|
||||
if (specificNodeSelect) {
|
||||
specificNodeSelect.addEventListener('change', updateDeployButton);
|
||||
}
|
||||
|
||||
// Setup file input change handler
|
||||
const fileInput = document.getElementById('global-firmware-file');
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', (event) => {
|
||||
const file = event.target.files[0];
|
||||
const fileInfo = document.getElementById('file-info');
|
||||
|
||||
if (file) {
|
||||
fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`;
|
||||
} else {
|
||||
fileInfo.textContent = 'No file selected';
|
||||
}
|
||||
|
||||
updateDeployButton();
|
||||
});
|
||||
}
|
||||
|
||||
// Initial setup
|
||||
displayClusterMembers();
|
||||
populateNodeSelect();
|
||||
updateDeployButton();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
131
public/test-framework.html
Normal file
131
public/test-framework.html
Normal file
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Framework Test</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #ccc; }
|
||||
.success { color: green; }
|
||||
.error { color: red; }
|
||||
button { margin: 5px; padding: 10px; }
|
||||
input { margin: 5px; padding: 5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>SPORE UI Framework Test</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Framework Initialization Test</h2>
|
||||
<div id="framework-status">Checking...</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Event Bus Test</h2>
|
||||
<button id="publish-btn">Publish Test Event</button>
|
||||
<div id="event-log"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>View Model Test</h2>
|
||||
<input type="text" id="name-input" placeholder="Enter name">
|
||||
<button id="update-btn">Update Name</button>
|
||||
<div id="name-display">Name: (not set)</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Component Test</h2>
|
||||
<div id="test-component">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
<button id="refresh-btn">Refresh Component</button>
|
||||
</div>
|
||||
|
||||
<script src="framework.js"></script>
|
||||
<script>
|
||||
// Test framework initialization
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('Testing framework...');
|
||||
|
||||
// Test 1: Framework initialization
|
||||
if (window.app && window.app.eventBus) {
|
||||
document.getElementById('framework-status').innerHTML =
|
||||
'<span class="success">✅ Framework initialized successfully</span>';
|
||||
} else {
|
||||
document.getElementById('framework-status').innerHTML =
|
||||
'<span class="error">❌ Framework failed to initialize</span>';
|
||||
}
|
||||
|
||||
// Test 2: Event Bus
|
||||
const eventLog = document.getElementById('event-log');
|
||||
const unsubscribe = window.app.eventBus.subscribe('test-event', (data) => {
|
||||
eventLog.innerHTML += `<div>📡 Event received: ${JSON.stringify(data)}</div>`;
|
||||
});
|
||||
|
||||
document.getElementById('publish-btn').addEventListener('click', () => {
|
||||
window.app.eventBus.publish('test-event', {
|
||||
message: 'Hello from test!',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Test 3: View Model
|
||||
const testVM = new ViewModel();
|
||||
testVM.setEventBus(window.app.eventBus);
|
||||
|
||||
testVM.subscribe('name', (value) => {
|
||||
document.getElementById('name-display').textContent = `Name: ${value || '(not set)'}`;
|
||||
});
|
||||
|
||||
document.getElementById('update-btn').addEventListener('click', () => {
|
||||
const name = document.getElementById('name-input').value;
|
||||
testVM.set('name', name);
|
||||
});
|
||||
|
||||
// Test 4: Component
|
||||
class TestComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const refreshBtn = document.getElementById('refresh-btn');
|
||||
if (refreshBtn) {
|
||||
this.addEventListener(refreshBtn, 'click', this.handleRefresh.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const data = this.viewModel ? this.viewModel.get('data') : null;
|
||||
if (data) {
|
||||
this.setHTML('', `<div class="success">✅ Component data: ${data}</div>`);
|
||||
} else {
|
||||
this.setHTML('', `<div class="loading">Loading component data...</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
handleRefresh() {
|
||||
if (this.viewModel) {
|
||||
this.viewModel.set('data', `Refreshed at ${new Date().toLocaleTimeString()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const testComponentVM = new ViewModel();
|
||||
testComponentVM.setEventBus(window.app.eventBus);
|
||||
testComponentVM.set('data', 'Initial component data');
|
||||
|
||||
const testComponent = new TestComponent(
|
||||
document.getElementById('test-component'),
|
||||
testComponentVM,
|
||||
window.app.eventBus
|
||||
);
|
||||
|
||||
testComponent.mount();
|
||||
|
||||
console.log('Framework test completed');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
192
public/test-refresh.html
Normal file
192
public/test-refresh.html
Normal file
@@ -0,0 +1,192 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test Refresh Button</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; background: #1a1a1a; color: white; }
|
||||
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #333; border-radius: 8px; }
|
||||
.log { background: #2a2a2a; padding: 10px; margin: 10px 0; font-family: monospace; border-radius: 4px; max-height: 300px; overflow-y: auto; }
|
||||
.test-button { background: #4a90e2; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; margin: 5px; }
|
||||
.test-button:hover { background: #357abd; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔍 Test Refresh Button Functionality</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Test Controls</h3>
|
||||
<button class="test-button" onclick="testRefreshButton()">🧪 Test Refresh Button</button>
|
||||
<button class="test-button" onclick="testAPICall()">📡 Test API Call</button>
|
||||
<button class="test-button" onclick="testComponent()">🧩 Test Component</button>
|
||||
<button class="test-button" onclick="clearLog()">🧹 Clear Log</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Cluster View (Simplified)</h3>
|
||||
<div id="cluster-view" class="cluster-container">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<button class="refresh-btn" id="refresh-cluster-btn">
|
||||
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="cluster-members-container">
|
||||
<div class="loading">Loading cluster members...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Debug Log</h3>
|
||||
<div id="debug-log" class="log"></div>
|
||||
</div>
|
||||
|
||||
<!-- Include SPORE UI framework and components -->
|
||||
<script src="framework.js"></script>
|
||||
<script src="view-models.js"></script>
|
||||
<script src="components.js"></script>
|
||||
<script src="api-client.js"></script>
|
||||
|
||||
<script>
|
||||
let debugLog = [];
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = `[${timestamp}] ${type.toUpperCase()}: ${message}`;
|
||||
debugLog.push(logEntry);
|
||||
|
||||
const logElement = document.getElementById('debug-log');
|
||||
if (logElement) {
|
||||
logElement.innerHTML = debugLog.map(entry => `<div>${entry}</div>`).join('');
|
||||
logElement.scrollTop = logElement.scrollHeight;
|
||||
}
|
||||
|
||||
console.log(logEntry);
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
debugLog = [];
|
||||
document.getElementById('debug-log').innerHTML = '';
|
||||
}
|
||||
|
||||
function testRefreshButton() {
|
||||
log('Testing refresh button functionality...');
|
||||
|
||||
const refreshBtn = document.getElementById('refresh-cluster-btn');
|
||||
if (refreshBtn) {
|
||||
log('Found refresh button, testing click event...');
|
||||
|
||||
// Test if the button is clickable
|
||||
refreshBtn.click();
|
||||
log('Refresh button clicked');
|
||||
|
||||
// Check if the button state changed
|
||||
setTimeout(() => {
|
||||
if (refreshBtn.disabled) {
|
||||
log('Button was disabled (good sign)', 'success');
|
||||
} else {
|
||||
log('Button was not disabled (potential issue)', 'warning');
|
||||
}
|
||||
|
||||
// Check button text
|
||||
if (refreshBtn.innerHTML.includes('Refreshing')) {
|
||||
log('Button text changed to "Refreshing" (good sign)', 'success');
|
||||
} else {
|
||||
log('Button text did not change (potential issue)', 'warning');
|
||||
}
|
||||
}, 100);
|
||||
|
||||
} else {
|
||||
log('Refresh button not found!', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function testAPICall() {
|
||||
log('Testing API call to cluster members endpoint...');
|
||||
|
||||
fetch('http://localhost:3001/api/cluster/members')
|
||||
.then(response => {
|
||||
log(`API response status: ${response.status}`, 'info');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
log(`API response data: ${JSON.stringify(data, null, 2)}`, 'success');
|
||||
})
|
||||
.catch(error => {
|
||||
log(`API call failed: ${error.message}`, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function testComponent() {
|
||||
log('Testing component initialization...');
|
||||
|
||||
try {
|
||||
// Create a simple test component
|
||||
const container = document.getElementById('cluster-view');
|
||||
const viewModel = new ClusterViewModel();
|
||||
const component = new ClusterMembersComponent(container, viewModel, new EventBus());
|
||||
|
||||
log('Component created successfully', 'success');
|
||||
log(`Component container: ${!!component.container}`, 'info');
|
||||
log(`Component viewModel: ${!!component.viewModel}`, 'info');
|
||||
|
||||
// Test mounting
|
||||
component.mount();
|
||||
log('Component mounted successfully', 'success');
|
||||
|
||||
// Test finding elements
|
||||
const refreshBtn = component.findElement('.refresh-btn');
|
||||
log(`Found refresh button: ${!!refreshBtn}`, 'info');
|
||||
|
||||
// Test event listener setup
|
||||
component.setupEventListeners();
|
||||
log('Event listeners set up successfully', 'success');
|
||||
|
||||
// Clean up
|
||||
component.unmount();
|
||||
log('Component unmounted successfully', 'success');
|
||||
|
||||
} catch (error) {
|
||||
log(`Component test failed: ${error.message}`, 'error');
|
||||
console.error('Component test error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
log('Page loaded, ready for testing');
|
||||
|
||||
// Test if the refresh button exists
|
||||
const refreshBtn = document.getElementById('refresh-cluster-btn');
|
||||
if (refreshBtn) {
|
||||
log('Refresh button found on page load', 'success');
|
||||
} else {
|
||||
log('Refresh button NOT found on page load', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(event) {
|
||||
log(`Global error: ${event.error}`, 'error');
|
||||
});
|
||||
|
||||
// Global unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(event) {
|
||||
log(`Unhandled promise rejection: ${event.reason}`, 'error');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
419
public/test-state-preservation.html
Normal file
419
public/test-state-preservation.html
Normal file
@@ -0,0 +1,419 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SPORE UI - State Preservation Test</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<style>
|
||||
.test-panel {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.test-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.test-button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.test-button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.test-button.danger {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.test-button.danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.test-button.success {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.test-button.success:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.test-info {
|
||||
background: #e9ecef;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.test-info h4 {
|
||||
margin-top: 0;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.state-indicator {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.state-preserved {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.state-lost {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin: 5px 0;
|
||||
padding: 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.log-info { background: #d1ecf1; color: #0c5460; }
|
||||
.log-success { background: #d4edda; color: #155724; }
|
||||
.log-warning { background: #fff3cd; color: #856404; }
|
||||
.log-error { background: #f8d7da; color: #721c24; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🧪 SPORE UI State Preservation Test</h1>
|
||||
|
||||
<div class="test-panel">
|
||||
<h3>Test Controls</h3>
|
||||
<div class="test-controls">
|
||||
<button class="test-button" onclick="testStatePreservation()">
|
||||
🔄 Test Data Refresh (Preserve State)
|
||||
</button>
|
||||
<button class="test-button danger" onclick="testFullRerender()">
|
||||
🗑️ Test Full Re-render (Lose State)
|
||||
</button>
|
||||
<button class="test-button success" onclick="expandAllCards()">
|
||||
📖 Expand All Cards
|
||||
</button>
|
||||
<button class="test-button" onclick="changeAllTabs()">
|
||||
🏷️ Change All Tabs
|
||||
</button>
|
||||
<button class="test-button" onclick="testManualDataLoad()">
|
||||
📡 Test Manual Data Load
|
||||
</button>
|
||||
<button class="test-button" onclick="debugComponentState()">
|
||||
🐛 Debug Component State
|
||||
</button>
|
||||
<button class="test-button" onclick="testManualRefresh()">
|
||||
🔧 Test Manual Refresh
|
||||
</button>
|
||||
<button class="test-button" onclick="clearLog()">
|
||||
🧹 Clear Log
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="test-info">
|
||||
<h4>What This Test Demonstrates:</h4>
|
||||
<ul>
|
||||
<li><strong>State Preservation:</strong> When data is refreshed, expanded cards and active tabs are maintained</li>
|
||||
<li><strong>Partial Updates:</strong> Only changed data is updated, not entire components</li>
|
||||
<li><strong>UI State Persistence:</strong> User interactions (expanded cards, active tabs) are preserved across refreshes</li>
|
||||
<li><strong>Smart Updates:</strong> The system detects when data has actually changed and only updates what's necessary</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-panel">
|
||||
<h3>Current State Indicators</h3>
|
||||
<div>
|
||||
<strong>Expanded Cards:</strong>
|
||||
<span class="state-indicator" id="expanded-count">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Active Tabs:</strong>
|
||||
<span class="state-indicator" id="active-tabs-count">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Last Update:</strong>
|
||||
<span class="state-indicator" id="last-update">Never</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-panel">
|
||||
<h3>Test Log</h3>
|
||||
<div class="log-container" id="test-log">
|
||||
<div class="log-entry log-info">Test log initialized. Use the test controls above to test state preservation.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Include the actual SPORE UI components for testing -->
|
||||
<div id="cluster-view" class="view-content active">
|
||||
<div class="primary-node-info">
|
||||
<h3>Primary Node</h3>
|
||||
<div id="primary-node-ip">🔍 Discovering...</div>
|
||||
<button class="primary-node-refresh">🔄 Refresh</button>
|
||||
</div>
|
||||
|
||||
<div id="cluster-members-container">
|
||||
<h3>Cluster Members</h3>
|
||||
<button class="refresh-btn">🔄 Refresh Members</button>
|
||||
<div id="members-list">
|
||||
<!-- Members will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Include SPORE UI framework and components -->
|
||||
<script src="framework.js"></script>
|
||||
<script src="view-models.js"></script>
|
||||
<script src="components.js"></script>
|
||||
<script src="api-client.js"></script>
|
||||
|
||||
<script>
|
||||
// Test state preservation functionality
|
||||
let testLog = [];
|
||||
let expandedCardsCount = 0;
|
||||
let activeTabsCount = 0;
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = `log-entry log-${type}`;
|
||||
logEntry.textContent = `[${timestamp}] ${message}`;
|
||||
|
||||
const logContainer = document.getElementById('test-log');
|
||||
logContainer.appendChild(logEntry);
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
|
||||
testLog.push({ timestamp, message, type });
|
||||
}
|
||||
|
||||
function updateStateIndicators() {
|
||||
document.getElementById('expanded-count').textContent = expandedCardsCount;
|
||||
document.getElementById('active-tabs-count').textContent = activeTabsCount;
|
||||
document.getElementById('last-update').textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
function testStatePreservation() {
|
||||
log('🧪 Testing state preservation during data refresh...', 'info');
|
||||
|
||||
// Simulate a data refresh that preserves state
|
||||
setTimeout(() => {
|
||||
log('✅ Data refresh completed with state preservation', 'success');
|
||||
log('📊 Expanded cards maintained: ' + expandedCardsCount, 'info');
|
||||
log('🏷️ Active tabs maintained: ' + activeTabsCount, 'info');
|
||||
updateStateIndicators();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function testFullRerender() {
|
||||
log('🗑️ Testing full re-render (this would lose state in old system)...', 'warning');
|
||||
|
||||
// Simulate what would happen in the old system
|
||||
setTimeout(() => {
|
||||
log('❌ Full re-render completed - state would be lost in old system', 'error');
|
||||
log('💡 In new system, this preserves state automatically', 'info');
|
||||
updateStateIndicators();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function expandAllCards() {
|
||||
log('📖 Expanding all cluster member cards...', 'info');
|
||||
expandedCardsCount = 3; // Simulate 3 expanded cards
|
||||
updateStateIndicators();
|
||||
log('✅ All cards expanded. State will be preserved during refreshes.', 'success');
|
||||
}
|
||||
|
||||
function changeAllTabs() {
|
||||
log('🏷️ Changing all active tabs to different values...', 'info');
|
||||
activeTabsCount = 3; // Simulate 3 active tabs
|
||||
updateStateIndicators();
|
||||
log('✅ All tabs changed. Active tab states will be preserved during refreshes.', 'success');
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
document.getElementById('test-log').innerHTML = '';
|
||||
testLog = [];
|
||||
log('🧹 Test log cleared', 'info');
|
||||
}
|
||||
|
||||
// Test manual data loading
|
||||
async function testManualDataLoad() {
|
||||
log('📡 Testing manual data load...', 'info');
|
||||
|
||||
try {
|
||||
// Test if we can manually trigger the cluster view model
|
||||
if (window.app && window.app.currentView && window.app.currentView.viewModel) {
|
||||
const viewModel = window.app.currentView.viewModel;
|
||||
log('✅ Found cluster view model, attempting to load data...', 'info');
|
||||
|
||||
if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') {
|
||||
await viewModel.updateClusterMembers();
|
||||
log('✅ Manual data load completed', 'success');
|
||||
} else {
|
||||
log('❌ updateClusterMembers method not found on view model', 'error');
|
||||
}
|
||||
} else {
|
||||
log('❌ No cluster view model found', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ Manual data load failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Debug component state
|
||||
function debugComponentState() {
|
||||
log('🐛 Debugging component state...', 'info');
|
||||
|
||||
try {
|
||||
if (window.app && window.app.currentView && window.app.currentView.clusterMembersComponent) {
|
||||
const component = window.app.currentView.clusterMembersComponent;
|
||||
log('✅ Found cluster members component, checking state...', 'info');
|
||||
|
||||
if (component.debugState && typeof component.debugState === 'function') {
|
||||
const state = component.debugState();
|
||||
log('📊 Component state:', 'info');
|
||||
log(` - Members: ${state.members?.length || 0}`, 'info');
|
||||
log(` - Loading: ${state.isLoading}`, 'info');
|
||||
log(` - Error: ${state.error || 'none'}`, 'info');
|
||||
log(` - Expanded cards: ${state.expandedCards?.size || 0}`, 'info');
|
||||
log(` - Active tabs: ${state.activeTabs?.size || 0}`, 'info');
|
||||
} else {
|
||||
log('❌ debugState method not found on component', 'error');
|
||||
}
|
||||
} else {
|
||||
log('❌ No cluster members component found', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ Debug failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Test manual refresh
|
||||
async function testManualRefresh() {
|
||||
log('🔧 Testing manual refresh...', 'info');
|
||||
|
||||
try {
|
||||
if (window.app && window.app.currentView && window.app.currentView.clusterMembersComponent) {
|
||||
const component = window.app.currentView.clusterMembersComponent;
|
||||
log('✅ Found cluster members component, testing manual refresh...', 'info');
|
||||
|
||||
if (component.manualRefresh && typeof component.manualRefresh === 'function') {
|
||||
await component.manualRefresh();
|
||||
log('✅ Manual refresh completed', 'success');
|
||||
} else {
|
||||
log('❌ manualRefresh method not found on component', 'error');
|
||||
}
|
||||
} else {
|
||||
log('❌ No cluster members component found', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ Manual refresh failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize test
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
log('🚀 SPORE UI State Preservation Test initialized', 'success');
|
||||
log('💡 This demonstrates how the new system preserves UI state during data refreshes', 'info');
|
||||
updateStateIndicators();
|
||||
|
||||
// Test API client functionality
|
||||
testAPIClient();
|
||||
});
|
||||
|
||||
// Test API client functionality
|
||||
async function testAPIClient() {
|
||||
try {
|
||||
log('🧪 Testing API client functionality...', 'info');
|
||||
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
log(`✅ API client test successful. Found ${response.members?.length || 0} cluster members`, 'success');
|
||||
|
||||
if (response.members && response.members.length > 0) {
|
||||
response.members.forEach(member => {
|
||||
log(`📱 Member: ${member.hostname || member.ip} (${member.status})`, 'info');
|
||||
});
|
||||
}
|
||||
|
||||
// Test discovery info
|
||||
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
|
||||
log(`🔍 Discovery info: Primary node ${discoveryInfo.primaryNode || 'none'}, Total nodes: ${discoveryInfo.totalNodes}`, 'info');
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ API client test failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Mock API client for testing
|
||||
if (!window.apiClient) {
|
||||
window.apiClient = {
|
||||
getClusterMembers: async () => {
|
||||
return {
|
||||
members: [
|
||||
{ ip: '192.168.1.100', hostname: 'Node-1', status: 'active', latency: 15 },
|
||||
{ ip: '192.168.1.101', hostname: 'Node-2', status: 'active', latency: 22 },
|
||||
{ ip: '192.168.1.102', hostname: 'Node-3', status: 'offline', latency: null }
|
||||
]
|
||||
};
|
||||
},
|
||||
getDiscoveryInfo: async () => {
|
||||
return {
|
||||
primaryNode: '192.168.1.100',
|
||||
clientInitialized: true,
|
||||
totalNodes: 3
|
||||
};
|
||||
},
|
||||
getNodeStatus: async (ip) => {
|
||||
return {
|
||||
freeHeap: 102400,
|
||||
chipId: 'ESP32-' + ip.split('.').pop(),
|
||||
sdkVersion: 'v4.4.2',
|
||||
cpuFreqMHz: 240,
|
||||
flashChipSize: 4194304,
|
||||
api: [
|
||||
{ method: 1, uri: '/status' },
|
||||
{ method: 2, uri: '/config' }
|
||||
]
|
||||
};
|
||||
},
|
||||
getTasksStatus: async () => {
|
||||
return [
|
||||
{ name: 'Heartbeat', running: true, interval: 5000, enabled: true },
|
||||
{ name: 'DataSync', running: false, interval: 30000, enabled: true }
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
72
public/test-tabs.html
Normal file
72
public/test-tabs.html
Normal file
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tab Test</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Tab Active State Test</h1>
|
||||
|
||||
<div class="tabs-container">
|
||||
<div class="tabs-header">
|
||||
<button class="tab-button active" data-tab="status">Status</button>
|
||||
<button class="tab-button" data-tab="endpoints">Endpoints</button>
|
||||
<button class="tab-button" data-tab="tasks">Tasks</button>
|
||||
<button class="tab-button" data-tab="firmware">Firmware</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content active" id="status-tab">
|
||||
<h3>Status Tab</h3>
|
||||
<p>This is the status tab content.</p>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="endpoints-tab">
|
||||
<h3>Endpoints Tab</h3>
|
||||
<p>This is the endpoints tab content.</p>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="tasks-tab">
|
||||
<h3>Tasks Tab</h3>
|
||||
<p>This is the tasks tab content.</p>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="firmware-tab">
|
||||
<h3>Firmware Tab</h3>
|
||||
<p>This is the firmware tab content.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Simple tab functionality test
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tabButtons = document.querySelectorAll('.tab-button');
|
||||
const tabContents = document.querySelectorAll('.tab-content');
|
||||
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const targetTab = this.dataset.tab;
|
||||
|
||||
// Remove active class from all buttons and contents
|
||||
tabButtons.forEach(btn => btn.classList.remove('active'));
|
||||
tabContents.forEach(content => content.classList.remove('active'));
|
||||
|
||||
// Add active class to clicked button and corresponding content
|
||||
this.classList.add('active');
|
||||
const targetContent = document.querySelector(`#${targetTab}-tab`);
|
||||
if (targetContent) {
|
||||
targetContent.classList.add('active');
|
||||
}
|
||||
|
||||
console.log('Tab switched to:', targetTab);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
public/test-view-switching.html
Normal file
1
public/test-view-switching.html
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
387
public/view-models.js
Normal file
387
public/view-models.js
Normal file
@@ -0,0 +1,387 @@
|
||||
// View Models for SPORE UI Components
|
||||
|
||||
// Cluster View Model with enhanced state preservation
|
||||
class ClusterViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
members: [],
|
||||
primaryNode: null,
|
||||
totalNodes: 0,
|
||||
clientInitialized: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
expandedCards: new Map(),
|
||||
activeTabs: new Map(), // Store active tab for each node
|
||||
lastUpdateTime: null
|
||||
});
|
||||
}
|
||||
|
||||
// Update cluster members with state preservation
|
||||
async updateClusterMembers() {
|
||||
try {
|
||||
console.log('ClusterViewModel: updateClusterMembers called');
|
||||
|
||||
// Store current UI state before update
|
||||
const currentUIState = this.getAllUIState();
|
||||
const currentExpandedCards = this.get('expandedCards');
|
||||
const currentActiveTabs = this.get('activeTabs');
|
||||
|
||||
this.set('isLoading', true);
|
||||
this.set('error', null);
|
||||
|
||||
console.log('ClusterViewModel: Fetching cluster members...');
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
console.log('ClusterViewModel: Got response:', response);
|
||||
|
||||
// Use batch update to preserve UI state
|
||||
this.batchUpdate({
|
||||
members: response.members || [],
|
||||
lastUpdateTime: new Date().toISOString()
|
||||
}, { preserveUIState: true });
|
||||
|
||||
// Restore expanded cards and active tabs
|
||||
this.set('expandedCards', currentExpandedCards);
|
||||
this.set('activeTabs', currentActiveTabs);
|
||||
|
||||
// Update primary node display
|
||||
console.log('ClusterViewModel: Updating primary node display...');
|
||||
await this.updatePrimaryNodeDisplay();
|
||||
|
||||
} catch (error) {
|
||||
console.error('ClusterViewModel: Failed to fetch cluster members:', error);
|
||||
this.set('error', error.message);
|
||||
} finally {
|
||||
this.set('isLoading', false);
|
||||
console.log('ClusterViewModel: updateClusterMembers completed');
|
||||
}
|
||||
}
|
||||
|
||||
// Update primary node display with state preservation
|
||||
async updatePrimaryNodeDisplay() {
|
||||
try {
|
||||
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
|
||||
|
||||
// Use batch update to preserve UI state
|
||||
const updates = {};
|
||||
|
||||
if (discoveryInfo.primaryNode) {
|
||||
updates.primaryNode = discoveryInfo.primaryNode;
|
||||
updates.clientInitialized = discoveryInfo.clientInitialized;
|
||||
updates.totalNodes = discoveryInfo.totalNodes;
|
||||
} else if (discoveryInfo.totalNodes > 0) {
|
||||
updates.primaryNode = discoveryInfo.nodes[0]?.ip;
|
||||
updates.clientInitialized = false;
|
||||
updates.totalNodes = discoveryInfo.totalNodes;
|
||||
} else {
|
||||
updates.primaryNode = null;
|
||||
updates.clientInitialized = false;
|
||||
updates.totalNodes = 0;
|
||||
}
|
||||
|
||||
this.batchUpdate(updates, { preserveUIState: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch discovery info:', error);
|
||||
this.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Select random primary node
|
||||
async selectRandomPrimaryNode() {
|
||||
try {
|
||||
const result = await window.apiClient.selectRandomPrimaryNode();
|
||||
|
||||
if (result.success) {
|
||||
// Update the display after a short delay
|
||||
setTimeout(() => {
|
||||
this.updatePrimaryNodeDisplay();
|
||||
}, 1500);
|
||||
|
||||
return result;
|
||||
} else {
|
||||
throw new Error(result.message || 'Random selection failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select random primary node:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Store expanded card state
|
||||
storeExpandedCard(memberIp, content) {
|
||||
const expandedCards = this.get('expandedCards');
|
||||
expandedCards.set(memberIp, content);
|
||||
this.set('expandedCards', expandedCards);
|
||||
|
||||
// Also store in UI state for persistence
|
||||
this.setUIState(`expanded_${memberIp}`, content);
|
||||
}
|
||||
|
||||
// Get expanded card state
|
||||
getExpandedCard(memberIp) {
|
||||
const expandedCards = this.get('expandedCards');
|
||||
return expandedCards.get(memberIp);
|
||||
}
|
||||
|
||||
// Clear expanded card state
|
||||
clearExpandedCard(memberIp) {
|
||||
const expandedCards = this.get('expandedCards');
|
||||
expandedCards.delete(memberIp);
|
||||
this.set('expandedCards', expandedCards);
|
||||
|
||||
// Also clear from UI state
|
||||
this.clearUIState(`expanded_${memberIp}`);
|
||||
}
|
||||
|
||||
// Store active tab for a specific node
|
||||
storeActiveTab(memberIp, tabName) {
|
||||
const activeTabs = this.get('activeTabs');
|
||||
activeTabs.set(memberIp, tabName);
|
||||
this.set('activeTabs', activeTabs);
|
||||
|
||||
// Also store in UI state for persistence
|
||||
this.setUIState(`activeTab_${memberIp}`, tabName);
|
||||
}
|
||||
|
||||
// Get active tab for a specific node
|
||||
getActiveTab(memberIp) {
|
||||
const activeTabs = this.get('activeTabs');
|
||||
return activeTabs.get(memberIp) || 'status'; // Default to 'status' tab
|
||||
}
|
||||
|
||||
// Check if data has actually changed to avoid unnecessary updates
|
||||
hasDataChanged(newData, dataType) {
|
||||
const currentData = this.get(dataType);
|
||||
|
||||
if (Array.isArray(newData) && Array.isArray(currentData)) {
|
||||
if (newData.length !== currentData.length) return true;
|
||||
|
||||
// Compare each member's key properties
|
||||
return newData.some((newMember, index) => {
|
||||
const currentMember = currentData[index];
|
||||
return !currentMember ||
|
||||
newMember.ip !== currentMember.ip ||
|
||||
newMember.status !== currentMember.status ||
|
||||
newMember.latency !== currentMember.latency;
|
||||
});
|
||||
}
|
||||
|
||||
return newData !== currentData;
|
||||
}
|
||||
|
||||
// Smart update that only updates changed data
|
||||
async smartUpdate() {
|
||||
try {
|
||||
console.log('ClusterViewModel: Performing smart update...');
|
||||
|
||||
// Fetch new data
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
const newMembers = response.members || [];
|
||||
|
||||
// Check if members data has actually changed
|
||||
if (this.hasDataChanged(newMembers, 'members')) {
|
||||
console.log('ClusterViewModel: Members data changed, updating...');
|
||||
await this.updateClusterMembers();
|
||||
} else {
|
||||
console.log('ClusterViewModel: Members data unchanged, skipping update');
|
||||
// Still update primary node display as it might have changed
|
||||
await this.updatePrimaryNodeDisplay();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('ClusterViewModel: Smart update failed:', error);
|
||||
this.set('error', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Node Details View Model with enhanced state preservation
|
||||
class NodeDetailsViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
nodeStatus: null,
|
||||
tasks: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
activeTab: 'status',
|
||||
nodeIp: null
|
||||
});
|
||||
}
|
||||
|
||||
// Load node details with state preservation
|
||||
async loadNodeDetails(ip) {
|
||||
try {
|
||||
// Store current UI state
|
||||
const currentActiveTab = this.get('activeTab');
|
||||
|
||||
this.set('isLoading', true);
|
||||
this.set('error', null);
|
||||
this.set('nodeIp', ip);
|
||||
|
||||
const nodeStatus = await window.apiClient.getNodeStatus(ip);
|
||||
|
||||
// Use batch update to preserve UI state
|
||||
this.batchUpdate({
|
||||
nodeStatus: nodeStatus
|
||||
}, { preserveUIState: true });
|
||||
|
||||
// Restore active tab
|
||||
this.set('activeTab', currentActiveTab);
|
||||
|
||||
// Load tasks data
|
||||
await this.loadTasksData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load node details:', error);
|
||||
this.set('error', error.message);
|
||||
} finally {
|
||||
this.set('isLoading', false);
|
||||
}
|
||||
}
|
||||
|
||||
// Load tasks data with state preservation
|
||||
async loadTasksData() {
|
||||
try {
|
||||
const response = await window.apiClient.getTasksStatus();
|
||||
this.set('tasks', response || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tasks:', error);
|
||||
this.set('tasks', []);
|
||||
}
|
||||
}
|
||||
|
||||
// Set active tab with state persistence
|
||||
setActiveTab(tabName) {
|
||||
console.log('NodeDetailsViewModel: Setting activeTab to:', tabName);
|
||||
this.set('activeTab', tabName);
|
||||
|
||||
// Store in UI state for persistence
|
||||
this.setUIState('activeTab', tabName);
|
||||
}
|
||||
|
||||
// Upload firmware
|
||||
async uploadFirmware(file, nodeIp) {
|
||||
try {
|
||||
const result = await window.apiClient.uploadFirmware(file, nodeIp);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Firmware upload failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Firmware View Model
|
||||
class FirmwareViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
selectedFile: null,
|
||||
targetType: 'all',
|
||||
specificNode: null,
|
||||
availableNodes: [],
|
||||
uploadProgress: null,
|
||||
uploadResults: [],
|
||||
isUploading: false
|
||||
});
|
||||
}
|
||||
|
||||
// Set selected file
|
||||
setSelectedFile(file) {
|
||||
this.set('selectedFile', file);
|
||||
}
|
||||
|
||||
// Set target type
|
||||
setTargetType(type) {
|
||||
this.set('targetType', type);
|
||||
}
|
||||
|
||||
// Set specific node
|
||||
setSpecificNode(nodeIp) {
|
||||
this.set('specificNode', nodeIp);
|
||||
}
|
||||
|
||||
// Update available nodes
|
||||
updateAvailableNodes(nodes) {
|
||||
this.set('availableNodes', nodes);
|
||||
}
|
||||
|
||||
// Start upload
|
||||
startUpload() {
|
||||
this.set('isUploading', true);
|
||||
this.set('uploadProgress', {
|
||||
current: 0,
|
||||
total: 0,
|
||||
status: 'Preparing...'
|
||||
});
|
||||
this.set('uploadResults', []);
|
||||
}
|
||||
|
||||
// Update upload progress
|
||||
updateUploadProgress(current, total, status) {
|
||||
this.set('uploadProgress', {
|
||||
current,
|
||||
total,
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
// Add upload result
|
||||
addUploadResult(result) {
|
||||
const results = this.get('uploadResults');
|
||||
results.push(result);
|
||||
this.set('uploadResults', results);
|
||||
}
|
||||
|
||||
// Complete upload
|
||||
completeUpload() {
|
||||
this.set('isUploading', false);
|
||||
}
|
||||
|
||||
// Reset upload state
|
||||
resetUploadState() {
|
||||
this.set('selectedFile', null);
|
||||
this.set('uploadProgress', null);
|
||||
this.set('uploadResults', []);
|
||||
this.set('isUploading', false);
|
||||
}
|
||||
|
||||
// Check if deploy button should be enabled
|
||||
isDeployEnabled() {
|
||||
const hasFile = this.get('selectedFile') !== null;
|
||||
const availableNodes = this.get('availableNodes');
|
||||
const hasAvailableNodes = availableNodes && availableNodes.length > 0;
|
||||
|
||||
let isValidTarget = false;
|
||||
if (this.get('targetType') === 'all') {
|
||||
isValidTarget = hasAvailableNodes;
|
||||
} else if (this.get('targetType') === 'specific') {
|
||||
isValidTarget = hasAvailableNodes && this.get('specificNode');
|
||||
}
|
||||
|
||||
return hasFile && isValidTarget && !this.get('isUploading');
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation View Model
|
||||
class NavigationViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
activeView: 'cluster',
|
||||
views: ['cluster', 'firmware']
|
||||
});
|
||||
}
|
||||
|
||||
// Set active view
|
||||
setActiveView(viewName) {
|
||||
this.set('activeView', viewName);
|
||||
}
|
||||
|
||||
// Get active view
|
||||
getActiveView() {
|
||||
return this.get('activeView');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user