From c0aef5b8d514b6a33aefdfe04efa2ea7961de2e0 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 11:04:47 +0200 Subject: [PATCH 01/18] refactor(app): mount ClusterStatusComponent and remove duplicate cluster status logic from app.js --- public/scripts/app.js | 97 +------------------------------------------ 1 file changed, 2 insertions(+), 95 deletions(-) diff --git a/public/scripts/app.js b/public/scripts/app.js index 35f8dc7..8a07bc7 100644 --- a/public/scripts/app.js +++ b/public/scripts/app.js @@ -40,29 +40,20 @@ document.addEventListener('DOMContentLoaded', function() { app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel); console.log('App: Routes registered and components pre-initialized'); - // Initialize cluster status component for header badge AFTER main components - // DISABLED - causes interference with main cluster functionality - /* + // Initialize cluster status component for header badge console.log('App: Initializing cluster status component...'); const clusterStatusComponent = new ClusterStatusComponent( document.querySelector('.cluster-status'), clusterViewModel, app.eventBus ); - clusterStatusComponent.initialize(); + clusterStatusComponent.mount(); console.log('App: Cluster status component initialized'); - */ // Set up navigation event listeners console.log('App: Setting up navigation...'); app.setupNavigation(); - // Set up cluster status updates (simple approach without component interference) - setupClusterStatusUpdates(clusterViewModel); - - // Set up periodic updates for cluster view with state preservation - // setupPeriodicUpdates(); // Disabled automatic refresh - // Now navigate to the default route console.log('App: Navigating to default route...'); app.navigateTo('cluster'); @@ -126,90 +117,6 @@ function setupPeriodicUpdates() { }, 10000); } -// Set up cluster status updates (simple approach without component interference) -function setupClusterStatusUpdates(clusterViewModel) { - // Set initial "discovering" state immediately - updateClusterStatusBadge(undefined, undefined, undefined); - - // Force a fresh fetch and keep showing "discovering" until we get real data - let hasReceivedRealData = false; - - // Subscribe to view model changes to update cluster status - clusterViewModel.subscribe('totalNodes', (totalNodes) => { - if (hasReceivedRealData) { - updateClusterStatusBadge(totalNodes, clusterViewModel.get('clientInitialized'), clusterViewModel.get('error')); - } - }); - - clusterViewModel.subscribe('clientInitialized', (clientInitialized) => { - if (hasReceivedRealData) { - updateClusterStatusBadge(clusterViewModel.get('totalNodes'), clientInitialized, clusterViewModel.get('error')); - } - }); - - clusterViewModel.subscribe('error', (error) => { - if (hasReceivedRealData) { - updateClusterStatusBadge(clusterViewModel.get('totalNodes'), clusterViewModel.get('clientInitialized'), error); - } - }); - - // Force a fresh fetch and only update status after we get real data - setTimeout(async () => { - try { - console.log('Cluster Status: Forcing fresh fetch from backend...'); - const discoveryInfo = await window.apiClient.getDiscoveryInfo(); - console.log('Cluster Status: Got fresh data:', discoveryInfo); - - // Now we have real data, mark it and update the status - hasReceivedRealData = true; - updateClusterStatusBadge(discoveryInfo.totalNodes, discoveryInfo.clientInitialized, null); - - } catch (error) { - console.error('Cluster Status: Failed to fetch fresh data:', error); - hasReceivedRealData = true; - updateClusterStatusBadge(0, false, error.message); - } - }, 100); // Small delay to ensure view model is ready -} - -function updateClusterStatusBadge(totalNodes, clientInitialized, error) { - const clusterStatusBadge = document.querySelector('.cluster-status'); - if (!clusterStatusBadge) return; - - let statusText, statusIcon, statusClass; - - // Check if we're still in initial state (no real data yet) - const hasRealData = totalNodes !== undefined && clientInitialized !== undefined; - - if (!hasRealData) { - statusText = 'Cluster Discovering...'; - statusIcon = '🔍'; - statusClass = 'cluster-status-discovering'; - } else if (error || totalNodes === 0) { - // Show "Cluster Offline" for both errors and when no nodes are discovered - statusText = 'Cluster Offline'; - statusIcon = '🔴'; - statusClass = 'cluster-status-offline'; - } else if (clientInitialized) { - statusText = 'Cluster Online'; - statusIcon = '🟢'; - statusClass = 'cluster-status-online'; - } else { - statusText = 'Cluster Connecting'; - statusIcon = '🟡'; - statusClass = 'cluster-status-connecting'; - } - - // Update the badge - clusterStatusBadge.innerHTML = `${statusIcon} ${statusText}`; - - // Remove all existing status classes - clusterStatusBadge.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error', 'cluster-status-discovering'); - - // Add the appropriate status class - clusterStatusBadge.classList.add(statusClass); -} - // Global error handler window.addEventListener('error', function(event) { console.error('Global error:', event.error); From f18907d9e46a032cc8bf1675d57ae642fe524497 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 11:06:39 +0200 Subject: [PATCH 02/18] refactor(tabs): centralize tab wiring in base Component.setupTabs with onChange hook; persist and restore NodeDetails active tab; reuse base tabs in ClusterMembersComponent --- public/scripts/components.js | 56 ++++++++++-------------------------- public/scripts/framework.js | 6 +++- 2 files changed, 20 insertions(+), 42 deletions(-) diff --git a/public/scripts/components.js b/public/scripts/components.js index 7554296..b81e452 100644 --- a/public/scripts/components.js +++ b/public/scripts/components.js @@ -571,32 +571,14 @@ class ClusterMembersComponent extends Component { } setupTabs(container) { - const tabButtons = container.querySelectorAll('.tab-button'); - const tabContents = container.querySelectorAll('.tab-content'); - - tabButtons.forEach(button => { - this.addEventListener(button, 'click', (e) => { - e.stopPropagation(); - - const targetTab = button.dataset.tab; - - // Use base helper to set active tab - this.setActiveTab(targetTab, container); - - // Store active tab state + super.setupTabs(container, { + onChange: (targetTab) => { const memberCard = container.closest('.member-card'); if (memberCard) { const memberIp = memberCard.dataset.memberIp; this.viewModel.storeActiveTab(memberIp, targetTab); } - }); - }); - - // Also prevent event propagation on tab content areas - tabContents.forEach(content => { - this.addEventListener(content, 'click', (e) => { - e.stopPropagation(); - }); + } }); } @@ -884,6 +866,11 @@ class NodeDetailsComponent extends Component { this.setHTML('', html); this.setupTabs(); + // Restore last active tab from view model if available + const restored = this.viewModel && typeof this.viewModel.get === 'function' ? this.viewModel.get('activeTab') : null; + if (restored) { + this.setActiveTab(restored); + } this.setupFirmwareUpload(); } @@ -1158,26 +1145,13 @@ class NodeDetailsComponent extends Component { setupTabs() { console.log('NodeDetailsComponent: Setting up tabs'); - const tabButtons = this.findAllElements('.tab-button'); - const tabContents = this.findAllElements('.tab-content'); - - tabButtons.forEach(button => { - this.addEventListener(button, 'click', (e) => { - e.stopPropagation(); - - const targetTab = button.dataset.tab; - console.log('NodeDetailsComponent: Tab clicked:', targetTab); - - // Update tab UI locally, don't store in view model - this.setActiveTab(targetTab); - }); - }); - - // Also prevent event propagation on tab content areas - tabContents.forEach(content => { - this.addEventListener(content, 'click', (e) => { - e.stopPropagation(); - }); + super.setupTabs(this.container, { + onChange: (tab) => { + // Persist active tab in the view model for restoration + if (this.viewModel && typeof this.viewModel.setActiveTab === 'function') { + this.viewModel.setActiveTab(tab); + } + } }); } diff --git a/public/scripts/framework.js b/public/scripts/framework.js index 1196235..ada9fb0 100644 --- a/public/scripts/framework.js +++ b/public/scripts/framework.js @@ -570,7 +570,8 @@ class Component { } // Tab helpers - setupTabs(container = this.container) { + setupTabs(container = this.container, options = {}) { + const { onChange } = options; const tabButtons = container.querySelectorAll('.tab-button'); const tabContents = container.querySelectorAll('.tab-content'); tabButtons.forEach(button => { @@ -578,6 +579,9 @@ class Component { e.stopPropagation(); const targetTab = button.dataset.tab; this.setActiveTab(targetTab, container); + if (typeof onChange === 'function') { + try { onChange(targetTab); } catch (_) {} + } }); }); tabContents.forEach(content => { From b757cb68da2d27f87539df107fe98f03fa9aafe9 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 11:22:22 +0200 Subject: [PATCH 03/18] refactor(constants): introduce constants.js and wire timing/selector constants into framework transitions and navigation --- public/index.html | 1 + public/scripts/constants.js | 27 +++++++++++++++++++++++++++ public/scripts/framework.js | 16 ++++++++-------- 3 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 public/scripts/constants.js diff --git a/public/index.html b/public/index.html index 8a9eacc..0dffe3c 100644 --- a/public/index.html +++ b/public/index.html @@ -143,6 +143,7 @@ + diff --git a/public/scripts/constants.js b/public/scripts/constants.js new file mode 100644 index 0000000..0f577c9 --- /dev/null +++ b/public/scripts/constants.js @@ -0,0 +1,27 @@ +(function(){ + const TIMING = { + NAV_COOLDOWN_MS: 300, + VIEW_FADE_OUT_MS: 150, + VIEW_FADE_IN_MS: 200, + VIEW_FADE_DELAY_MS: 50, + AUTO_REFRESH_MS: 30000, + PRIMARY_NODE_REFRESH_MS: 10000, + LOAD_GUARD_MS: 10000 + }; + + const SELECTORS = { + NAV_TAB: '.nav-tab', + VIEW_CONTENT: '.view-content', + CLUSTER_STATUS: '.cluster-status' + }; + + const CLASSES = { + CLUSTER_STATUS_ONLINE: 'cluster-status-online', + CLUSTER_STATUS_OFFLINE: 'cluster-status-offline', + CLUSTER_STATUS_CONNECTING: 'cluster-status-connecting', + CLUSTER_STATUS_ERROR: 'cluster-status-error', + CLUSTER_STATUS_DISCOVERING: 'cluster-status-discovering' + }; + + window.CONSTANTS = window.CONSTANTS || { TIMING, SELECTORS, CLASSES }; +})(); \ No newline at end of file diff --git a/public/scripts/framework.js b/public/scripts/framework.js index ada9fb0..43ba9e8 100644 --- a/public/scripts/framework.js +++ b/public/scripts/framework.js @@ -614,7 +614,7 @@ class App { this.navigationInProgress = false; this.navigationQueue = []; this.lastNavigationTime = 0; - this.navigationCooldown = 300; // 300ms cooldown between navigations + this.navigationCooldown = (window.CONSTANTS && window.CONSTANTS.TIMING.NAV_COOLDOWN_MS) || 300; // cooldown between navigations // Component cache to keep components alive this.componentCache = new Map(); @@ -750,11 +750,11 @@ class App { // Fade out the container if (this.currentView.container) { this.currentView.container.style.opacity = '0'; - this.currentView.container.style.transition = 'opacity 0.15s ease-out'; + this.currentView.container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150}ms ease-out`; } // Wait for fade out to complete - await new Promise(resolve => setTimeout(resolve, 150)); + await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150)); } // Show view smoothly @@ -772,10 +772,10 @@ class App { // Fade in the container container.style.opacity = '0'; - container.style.transition = 'opacity 0.2s ease-in'; + container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_IN_MS) || 200}ms ease-in`; // Small delay to ensure smooth transition - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_DELAY_MS) || 50)); // Fade in container.style.opacity = '1'; @@ -784,7 +784,7 @@ class App { // Update navigation state updateNavigation(activeRoute) { // Remove active class from all nav tabs - document.querySelectorAll('.nav-tab').forEach(tab => { + document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => { tab.classList.remove('active'); }); @@ -795,7 +795,7 @@ class App { } // Hide all view contents with smooth transition - document.querySelectorAll('.view-content').forEach(view => { + document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.VIEW_CONTENT) || '.view-content').forEach(view => { view.classList.remove('active'); view.style.opacity = '0'; view.style.transition = 'opacity 0.15s ease-out'; @@ -838,7 +838,7 @@ class App { // Setup navigation setupNavigation() { - document.querySelectorAll('.nav-tab').forEach(tab => { + document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => { tab.addEventListener('click', () => { const routeName = tab.dataset.view; this.navigateTo(routeName); From 1bdaed9a2c75378c02ba3a018bf219f37afd155a Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 11:24:39 +0200 Subject: [PATCH 04/18] refactor(rendering): restore NodeDetails active tab; keyed partial updates by IP; add escapeHtml in base Component and use in members; simplify ApiClient methods by removing redundant try/catch --- public/scripts/api-client.js | 88 +++++++++++------------------------- public/scripts/components.js | 38 ++++++---------- public/scripts/framework.js | 12 ++++- 3 files changed, 51 insertions(+), 87 deletions(-) diff --git a/public/scripts/api-client.js b/public/scripts/api-client.js index dd90201..bcd0f91 100644 --- a/public/scripts/api-client.js +++ b/public/scripts/api-client.js @@ -51,92 +51,56 @@ class ApiClient { } async getClusterMembers() { - try { - return await this.request('/api/cluster/members', { method: 'GET' }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request('/api/cluster/members', { method: 'GET' }); } async getClusterMembersFromNode(ip) { - try { - return await this.request(`/api/cluster/members`, { - method: 'GET', - query: { ip: ip } - }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request(`/api/cluster/members`, { + method: 'GET', + query: { ip: ip } + }); } async getDiscoveryInfo() { - try { - return await this.request('/api/discovery/nodes', { method: 'GET' }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request('/api/discovery/nodes', { method: 'GET' }); } async selectRandomPrimaryNode() { - try { - return await this.request('/api/discovery/random-primary', { - method: 'POST', - body: { timestamp: new Date().toISOString() } - }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request('/api/discovery/random-primary', { + method: 'POST', + body: { timestamp: new Date().toISOString() } + }); } async getNodeStatus(ip) { - try { - return await this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' }); } async getTasksStatus(ip) { - try { - return await this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined }); } async getCapabilities(ip) { - try { - return await this.request('/api/capabilities', { method: 'GET', query: ip ? { ip } : undefined }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request('/api/capabilities', { method: 'GET', query: ip ? { ip } : undefined }); } async callCapability({ ip, method, uri, params }) { - try { - return await this.request('/api/proxy-call', { - method: 'POST', - body: { ip, method, uri, params } - }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request('/api/proxy-call', { + method: 'POST', + body: { ip, method, uri, params } + }); } async uploadFirmware(file, nodeIp) { - try { - const formData = new FormData(); - formData.append('file', file); - return await this.request(`/api/node/update`, { - method: 'POST', - query: { ip: nodeIp }, - body: formData, - isForm: true, - headers: {}, - }); - } catch (error) { - throw new Error(`Upload failed: ${error.message}`); - } + const formData = new FormData(); + formData.append('file', file); + return this.request(`/api/node/update`, { + method: 'POST', + query: { ip: nodeIp }, + body: formData, + isForm: true, + headers: {}, + }); } } diff --git a/public/scripts/components.js b/public/scripts/components.js index b81e452..11cb776 100644 --- a/public/scripts/components.js +++ b/public/scripts/components.js @@ -285,22 +285,11 @@ class ClusterMembersComponent extends Component { // Check if we should skip rendering during view switches shouldSkipRender() { - // Skip rendering if we're in the middle of a view switch - const isViewSwitching = document.querySelectorAll('.view-content.active').length === 0; - if (isViewSwitching) { - console.log('ClusterMembersComponent: View switching in progress, skipping render'); + // Rely on lifecycle flags controlled by App + if (!this.isMounted || this.isPaused) { + logger.debug('ClusterMembersComponent: Not mounted or paused, skipping render'); return true; } - - // Skip rendering if the component is not visible - const isVisible = this.container.style.display !== 'none' && - this.container.style.opacity !== '0' && - this.container.classList.contains('active'); - if (!isVisible) { - console.log('ClusterMembersComponent: Component not visible, skipping render'); - return true; - } - return false; } @@ -308,11 +297,12 @@ class ClusterMembersComponent extends Component { updateMembersPartially(newMembers, previousMembers) { console.log('ClusterMembersComponent: Performing partial update to preserve UI state'); - // Update only the data that changed, preserving expanded states and active tabs - newMembers.forEach((newMember, index) => { - const prevMember = previousMembers[index]; + // Build previous map by IP for stable diffs + const prevByIp = new Map((previousMembers || []).map(m => [m.ip, m])); + newMembers.forEach((newMember) => { + const prevMember = prevByIp.get(newMember.ip); if (prevMember && this.hasMemberChanged(newMember, prevMember)) { - this.updateMemberCard(newMember, index); + this.updateMemberCard(newMember); } }); } @@ -325,7 +315,7 @@ class ClusterMembersComponent extends Component { } // Update a specific member card without re-rendering the entire component - updateMemberCard(member, index) { + updateMemberCard(member) { const card = this.findElement(`[data-member-ip="${member.ip}"]`); if (!card) return; @@ -451,9 +441,9 @@ class ClusterMembersComponent extends Component {
${statusIcon}
-
${member.hostname || 'Unknown Device'}
+
${this.escapeHtml(member.hostname || 'Unknown Device')}
-
${member.ip || 'No IP'}
+
${this.escapeHtml(member.ip || 'No IP')}
Latency: ${member.latency ? member.latency + 'ms' : 'N/A'} @@ -462,7 +452,7 @@ class ClusterMembersComponent extends Component { ${member.labels && Object.keys(member.labels).length ? `
- ${Object.entries(member.labels).map(([key, value]) => `${key}: ${value}`).join('')} + ${Object.entries(member.labels).map(([key, value]) => `${this.escapeHtml(key)}: ${this.escapeHtml(value)}`).join('')}
` : ''} @@ -807,8 +797,8 @@ class NodeDetailsComponent extends Component { } renderNodeDetails(nodeStatus, tasks, capabilities) { - // Always start with 'status' tab, don't restore previous state - const activeTab = 'status'; + // Use persisted active tab from the view model, default to 'status' + const activeTab = (this.viewModel && typeof this.viewModel.get === 'function' && this.viewModel.get('activeTab')) || 'status'; console.log('NodeDetailsComponent: Rendering with activeTab:', activeTab); const html = ` diff --git a/public/scripts/framework.js b/public/scripts/framework.js index 43ba9e8..b76df22 100644 --- a/public/scripts/framework.js +++ b/public/scripts/framework.js @@ -550,7 +550,7 @@ class Component { } renderError(message) { - const safe = String(message || 'An error occurred'); + const safe = this.escapeHtml(String(message || 'An error occurred')); const html = `
Error:
@@ -569,6 +569,16 @@ class Component { this.setHTML('', html); } + // Basic HTML escaping for dynamic values + escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + // Tab helpers setupTabs(container = this.container, options = {}) { const { onChange } = options; From ab03cd772d70123a9af5a7904d3c4117dd43a30b Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 11:58:20 +0200 Subject: [PATCH 05/18] refactor(logging): downgrade info logs to logger.debug in ViewModel, Component lifecycle, and App navigation --- public/scripts/framework.js | 68 ++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/public/scripts/framework.js b/public/scripts/framework.js index b76df22..9245c6f 100644 --- a/public/scripts/framework.js +++ b/public/scripts/framework.js @@ -88,7 +88,7 @@ class ViewModel { // Set data property and notify listeners set(property, value) { - console.log(`ViewModel: Setting property '${property}' to:`, value); + logger.debug(`ViewModel: Setting property '${property}' to:`, value); // Check if the value has actually changed const hasChanged = this._data[property] !== value; @@ -100,10 +100,10 @@ class ViewModel { // Update the data this._data[property] = value; - console.log(`ViewModel: Property '${property}' changed, notifying listeners...`); + logger.debug(`ViewModel: Property '${property}' changed, notifying listeners...`); this._notifyListeners(property, value, this._previousData[property]); } else { - console.log(`ViewModel: Property '${property}' unchanged, skipping notification`); + logger.debug(`ViewModel: Property '${property}' unchanged, skipping notification`); } } @@ -132,7 +132,7 @@ class ViewModel { }); if (Object.keys(changedProperties).length > 0) { - console.log(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties)); + logger.debug(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties)); } } @@ -157,20 +157,20 @@ class ViewModel { // Notify listeners of property changes _notifyListeners(property, value, previousValue) { - console.log(`ViewModel: _notifyListeners called for property '${property}'`); + logger.debug(`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}'`); + logger.debug(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`); callbacks.forEach((callback, index) => { try { - console.log(`ViewModel: Calling listener ${index} for property '${property}'`); + logger.debug(`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}'`); + logger.debug(`ViewModel: No listeners found for property '${property}'`); } } @@ -285,13 +285,13 @@ class Component { mount() { if (this.isMounted) return; - console.log(`${this.constructor.name}: Starting mount...`); + logger.debug(`${this.constructor.name}: Starting mount...`); this.isMounted = true; this.setupEventListeners(); this.setupViewModelListeners(); this.render(); - console.log(`${this.constructor.name}: Mounted successfully`); + logger.debug(`${this.constructor.name}: Mounted successfully`); } // Unmount the component @@ -302,14 +302,14 @@ class Component { this.cleanupEventListeners(); this.cleanupViewModelListeners(); - console.log(`${this.constructor.name} unmounted`); + logger.debug(`${this.constructor.name} unmounted`); } // Pause the component (keep alive but pause activity) pause() { if (!this.isMounted) return; - console.log(`${this.constructor.name}: Pausing component`); + logger.debug(`${this.constructor.name}: Pausing component`); // Pause any active timers or animations if (this.updateInterval) { @@ -328,7 +328,7 @@ class Component { resume() { if (!this.isMounted || !this.isPaused) return; - console.log(`${this.constructor.name}: Resuming component`); + logger.debug(`${this.constructor.name}: Resuming component`); this.isPaused = false; @@ -385,7 +385,7 @@ class Component { // 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 }); + logger.debug(`${this.constructor.name}: Partial update for '${property}':`, { newValue, previousValue }); } // UI State Management Methods @@ -474,22 +474,22 @@ class Component { // Helper method to set innerHTML safely setHTML(selector, html) { - console.log(`${this.constructor.name}: setHTML called with selector '${selector}', html length: ${html.length}`); + logger.debug(`${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`); + logger.debug(`${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`); + logger.debug(`${this.constructor.name}: Element found, setting innerHTML`); element.innerHTML = html; - console.log(`${this.constructor.name}: innerHTML set successfully`); + logger.debug(`${this.constructor.name}: innerHTML set successfully`); } else { console.error(`${this.constructor.name}: Element not found for selector '${selector}'`); } @@ -651,7 +651,7 @@ class App { // Store in cache this.componentCache.set(name, component); - console.log(`App: Pre-initialized component for route '${name}'`); + logger.debug(`App: Pre-initialized component for route '${name}'`); } // Navigate to a route @@ -659,13 +659,13 @@ class App { // Check cooldown period const now = Date.now(); if (now - this.lastNavigationTime < this.navigationCooldown) { - console.log(`App: Navigation cooldown active, skipping route '${routeName}'`); + logger.debug(`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}'`); + logger.debug(`App: Navigation in progress, queuing route '${routeName}'`); if (!this.navigationQueue.includes(routeName)) { this.navigationQueue.push(routeName); } @@ -674,7 +674,7 @@ class App { // 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`); + logger.debug(`App: Already on route '${routeName}', skipping navigation`); return; } @@ -687,19 +687,19 @@ class App { this.navigationInProgress = true; try { - console.log(`App: Navigating to route '${routeName}'`); + logger.debug(`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}`); + logger.debug(`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}'`); + logger.debug(`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`); @@ -714,12 +714,12 @@ class App { // Hide current view smoothly if (this.currentView) { - console.log('App: Hiding current view'); + logger.debug('App: Hiding current view'); await this.hideCurrentView(); } // Show new view - console.log(`App: Showing new view '${routeName}'`); + logger.debug(`App: Showing new view '${routeName}'`); await this.showView(routeName, component); // Update navigation state @@ -731,7 +731,7 @@ class App { // Mark view as cached for future use this.cachedViews.add(routeName); - console.log(`App: Navigation to '${routeName}' completed`); + logger.debug(`App: Navigation to '${routeName}' completed`); } catch (error) { console.error('App: Navigation failed:', error); @@ -741,7 +741,7 @@ class App { // Process any queued navigation requests if (this.navigationQueue.length > 0) { const nextRoute = this.navigationQueue.shift(); - console.log(`App: Processing queued navigation to '${nextRoute}'`); + logger.debug(`App: Processing queued navigation to '${nextRoute}'`); setTimeout(() => this.navigateTo(nextRoute), 100); } } @@ -753,7 +753,7 @@ class App { // If component is mounted, pause it instead of unmounting if (this.currentView.isMounted) { - console.log('App: Pausing current view instead of unmounting'); + logger.debug('App: Pausing current view instead of unmounting'); this.currentView.pause(); } @@ -773,10 +773,10 @@ class App { // Ensure component is mounted (but not necessarily active) if (!component.isMounted) { - console.log(`App: Mounting component for '${routeName}'`); + logger.debug(`App: Mounting component for '${routeName}'`); component.mount(); } else { - console.log(`App: Resuming component for '${routeName}'`); + logger.debug(`App: Resuming component for '${routeName}'`); component.resume(); } @@ -858,11 +858,11 @@ class App { // Clean up cached components (call when app is shutting down) cleanup() { - console.log('App: Cleaning up cached components...'); + logger.debug('App: Cleaning up cached components...'); this.componentCache.forEach((component, routeName) => { if (component.isMounted) { - console.log(`App: Unmounting cached component '${routeName}'`); + logger.debug(`App: Unmounting cached component '${routeName}'`); component.unmount(); } }); From 4ee209ef78bdd19e95dd51f38692f53831cff5b6 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 12:06:06 +0200 Subject: [PATCH 06/18] refactor(logging): downgrade noisy component console.log to logger.debug across ClusterMembers, NodeDetails, Firmware, and Topology components --- public/scripts/components.js | 128 +++++++++++++++++------------------ 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/public/scripts/components.js b/public/scripts/components.js index 11cb776..449a5c1 100644 --- a/public/scripts/components.js +++ b/public/scripts/components.js @@ -94,10 +94,10 @@ class ClusterMembersComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); - console.log('ClusterMembersComponent: Constructor called'); - console.log('ClusterMembersComponent: Container:', container); - console.log('ClusterMembersComponent: Container ID:', container?.id); - console.log('ClusterMembersComponent: Container innerHTML:', container?.innerHTML); + logger.debug('ClusterMembersComponent: Constructor called'); + logger.debug('ClusterMembersComponent: Container:', container); + logger.debug('ClusterMembersComponent: Container ID:', container?.id); + logger.debug('ClusterMembersComponent: Container innerHTML:', container?.innerHTML); // Track if we're in the middle of a render operation this.renderInProgress = false; @@ -113,17 +113,17 @@ class ClusterMembersComponent extends Component { } mount() { - console.log('ClusterMembersComponent: Starting mount...'); + logger.debug('ClusterMembersComponent: Starting mount...'); super.mount(); // Show loading state immediately when mounted - console.log('ClusterMembersComponent: Showing initial loading state'); + logger.debug('ClusterMembersComponent: Showing initial loading state'); this.showLoadingState(); // Set up loading timeout safeguard this.setupLoadingTimeout(); - console.log('ClusterMembersComponent: Mounted successfully'); + logger.debug('ClusterMembersComponent: Mounted successfully'); } // Setup loading timeout safeguard to prevent getting stuck in loading state @@ -139,12 +139,12 @@ class ClusterMembersComponent extends Component { // Force a render check when loading gets stuck forceRenderCheck() { - console.log('ClusterMembersComponent: Force render check called'); + logger.debug('ClusterMembersComponent: Force render check called'); const members = this.viewModel.get('members'); const error = this.viewModel.get('error'); const isLoading = this.viewModel.get('isLoading'); - console.log('ClusterMembersComponent: Force render check state:', { members, error, isLoading }); + logger.debug('ClusterMembersComponent: Force render check state:', { members, error, isLoading }); if (error) { this.showErrorState(error); @@ -156,67 +156,67 @@ class ClusterMembersComponent extends Component { } setupEventListeners() { - console.log('ClusterMembersComponent: Setting up event listeners...'); + logger.debug('ClusterMembersComponent: Setting up event listeners...'); // Note: Refresh button is now handled by ClusterViewComponent // since it's in the cluster header, not in the members container } setupViewModelListeners() { - console.log('ClusterMembersComponent: Setting up view model listeners...'); + logger.debug('ClusterMembersComponent: Setting up view model listeners...'); // Listen to cluster members changes with change detection this.subscribeToProperty('members', this.handleMembersUpdate.bind(this)); this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this)); this.subscribeToProperty('error', this.handleErrorUpdate.bind(this)); - console.log('ClusterMembersComponent: View model listeners set up'); + logger.debug('ClusterMembersComponent: View model listeners set up'); } // Handle members update with state preservation handleMembersUpdate(newMembers, previousMembers) { - console.log('ClusterMembersComponent: Members updated:', { newMembers, previousMembers }); + logger.debug('ClusterMembersComponent: Members updated:', { newMembers, previousMembers }); // Prevent multiple simultaneous renders if (this.renderInProgress) { - console.log('ClusterMembersComponent: Render already in progress, skipping update'); + logger.debug('ClusterMembersComponent: Render already in progress, skipping update'); return; } // Check if we're currently loading - if so, let the loading handler deal with it const isLoading = this.viewModel.get('isLoading'); if (isLoading) { - console.log('ClusterMembersComponent: Currently loading, skipping members update (will be handled by loading completion)'); + logger.debug('ClusterMembersComponent: Currently loading, skipping members update (will be handled by loading completion)'); return; } // On first load (no previous members), always render if (!previousMembers || !Array.isArray(previousMembers) || previousMembers.length === 0) { - console.log('ClusterMembersComponent: First load or no previous members, performing full render'); + logger.debug('ClusterMembersComponent: First load or no previous members, performing full render'); this.render(); return; } if (this.shouldPreserveState(newMembers, previousMembers)) { // Perform partial update to preserve UI state - console.log('ClusterMembersComponent: Preserving state, performing partial update'); + logger.debug('ClusterMembersComponent: Preserving state, performing partial update'); this.updateMembersPartially(newMembers, previousMembers); } else { // Full re-render if structure changed significantly - console.log('ClusterMembersComponent: Structure changed, performing full re-render'); + logger.debug('ClusterMembersComponent: Structure changed, performing full re-render'); this.render(); } } // Handle loading state update handleLoadingUpdate(isLoading) { - console.log('ClusterMembersComponent: Loading state changed:', isLoading); + logger.debug('ClusterMembersComponent: Loading state changed:', isLoading); if (isLoading) { - console.log('ClusterMembersComponent: Showing loading state'); + logger.debug('ClusterMembersComponent: Showing loading state'); this.renderLoading(`\n
\n
Loading cluster members...
\n
\n `); // Set up a loading completion check this.checkLoadingCompletion(); } else { - console.log('ClusterMembersComponent: Loading completed, checking if we need to render'); + logger.debug('ClusterMembersComponent: Loading completed, checking if we need to render'); // When loading completes, check if we have data to render this.handleLoadingCompletion(); } @@ -228,16 +228,16 @@ class ClusterMembersComponent extends Component { const error = this.viewModel.get('error'); const isLoading = this.viewModel.get('isLoading'); - console.log('ClusterMembersComponent: Handling loading completion:', { members, error, isLoading }); + logger.debug('ClusterMembersComponent: Handling loading completion:', { members, error, isLoading }); if (error) { - console.log('ClusterMembersComponent: Loading completed with error, showing error state'); + logger.debug('ClusterMembersComponent: Loading completed with error, showing error state'); this.showErrorState(error); } else if (members && members.length > 0) { - console.log('ClusterMembersComponent: Loading completed with data, rendering members'); + logger.debug('ClusterMembersComponent: Loading completed with data, rendering members'); this.renderMembers(members); } else if (!isLoading) { - console.log('ClusterMembersComponent: Loading completed but no data, showing empty state'); + logger.debug('ClusterMembersComponent: Loading completed but no data, showing empty state'); this.showEmptyState(); } } @@ -253,7 +253,7 @@ class ClusterMembersComponent extends Component { this.loadingCompletionCheck = setTimeout(() => { const isLoading = this.viewModel.get('isLoading'); if (!isLoading) { - console.log('ClusterMembersComponent: Loading completion check triggered'); + logger.debug('ClusterMembersComponent: Loading completion check triggered'); this.handleLoadingCompletion(); } }, 1000); // Check after 1 second @@ -394,7 +394,7 @@ class ClusterMembersComponent extends Component { // Show loading state showLoadingState() { - console.log('ClusterMembersComponent: showLoadingState() called'); + logger.debug('ClusterMembersComponent: showLoadingState() called'); this.renderLoading(`
Loading cluster members...
@@ -404,13 +404,13 @@ class ClusterMembersComponent extends Component { // Show error state showErrorState(error) { - console.log('ClusterMembersComponent: showErrorState() called with error:', error); + logger.debug('ClusterMembersComponent: showErrorState() called with error:', error); this.renderError(`Error loading cluster members: ${error}`); } // Show empty state showEmptyState() { - console.log('ClusterMembersComponent: showEmptyState() called'); + logger.debug('ClusterMembersComponent: showEmptyState() called'); this.renderEmpty(`
🌐
@@ -423,14 +423,14 @@ class ClusterMembersComponent extends Component { } renderMembers(members) { - console.log('ClusterMembersComponent: renderMembers() called with', members.length, 'members'); + logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members'); const membersHTML = members.map(member => { const statusClass = member.status === 'active' ? 'status-online' : 'status-offline'; const statusText = member.status === 'active' ? 'Online' : 'Offline'; const statusIcon = member.status === 'active' ? '🟢' : '🔴'; - console.log('ClusterMembersComponent: Rendering member:', member); + logger.debug('ClusterMembersComponent: Rendering member:', member); return `
@@ -470,9 +470,9 @@ class ClusterMembersComponent extends Component { `; }).join(''); - console.log('ClusterMembersComponent: Setting HTML, length:', membersHTML.length); + logger.debug('ClusterMembersComponent: Setting HTML, length:', membersHTML.length); this.setHTML('', membersHTML); - console.log('ClusterMembersComponent: HTML set, setting up member cards...'); + logger.debug('ClusterMembersComponent: HTML set, setting up member cards...'); this.setupMemberCards(members); } @@ -1134,7 +1134,7 @@ class NodeDetailsComponent extends Component { } setupTabs() { - console.log('NodeDetailsComponent: Setting up tabs'); + logger.debug('NodeDetailsComponent: Setting up tabs'); super.setupTabs(this.container, { onChange: (tab) => { // Persist active tab in the view model for restoration @@ -1148,7 +1148,7 @@ class NodeDetailsComponent extends Component { // Update active tab without full re-render updateActiveTab(newTab, previousTab = null) { this.setActiveTab(newTab); - console.log(`NodeDetailsComponent: Active tab updated to '${newTab}'`); + logger.debug(`NodeDetailsComponent: Active tab updated to '${newTab}'`); } setupFirmwareUpload() { @@ -1245,17 +1245,17 @@ class FirmwareComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); - console.log('FirmwareComponent: Constructor called'); - console.log('FirmwareComponent: Container:', container); - console.log('FirmwareComponent: Container ID:', container?.id); + logger.debug('FirmwareComponent: Constructor called'); + logger.debug('FirmwareComponent: Container:', container); + logger.debug('FirmwareComponent: Container ID:', container?.id); // Check if the dropdown exists in the container if (container) { const dropdown = container.querySelector('#specific-node-select'); - console.log('FirmwareComponent: Dropdown found in constructor:', !!dropdown); + logger.debug('FirmwareComponent: Dropdown found in constructor:', !!dropdown); if (dropdown) { - console.log('FirmwareComponent: Dropdown tagName:', dropdown.tagName); - console.log('FirmwareComponent: Dropdown id:', dropdown.id); + logger.debug('FirmwareComponent: Dropdown tagName:', dropdown.tagName); + logger.debug('FirmwareComponent: Dropdown id:', dropdown.id); } } } @@ -1275,16 +1275,16 @@ class FirmwareComponent extends Component { // Setup specific node select change handler const specificNodeSelect = this.findElement('#specific-node-select'); - console.log('FirmwareComponent: setupEventListeners - specificNodeSelect found:', !!specificNodeSelect); + logger.debug('FirmwareComponent: setupEventListeners - specificNodeSelect found:', !!specificNodeSelect); if (specificNodeSelect) { - console.log('FirmwareComponent: specificNodeSelect element:', specificNodeSelect); - console.log('FirmwareComponent: specificNodeSelect tagName:', specificNodeSelect.tagName); - console.log('FirmwareComponent: specificNodeSelect id:', specificNodeSelect.id); + logger.debug('FirmwareComponent: specificNodeSelect element:', specificNodeSelect); + logger.debug('FirmwareComponent: specificNodeSelect tagName:', specificNodeSelect.tagName); + logger.debug('FirmwareComponent: specificNodeSelect id:', specificNodeSelect.id); // Store the bound handler as an instance property this._boundNodeSelectHandler = this.handleNodeSelect.bind(this); this.addEventListener(specificNodeSelect, 'change', this._boundNodeSelectHandler); - console.log('FirmwareComponent: Event listener added to specificNodeSelect'); + logger.debug('FirmwareComponent: Event listener added to specificNodeSelect'); } else { console.warn('FirmwareComponent: specificNodeSelect element not found during setupEventListeners'); } @@ -1345,15 +1345,15 @@ class FirmwareComponent extends Component { mount() { super.mount(); - console.log('FirmwareComponent: Mounting...'); + logger.debug('FirmwareComponent: Mounting...'); // Check if the dropdown exists when mounted const dropdown = this.findElement('#specific-node-select'); - console.log('FirmwareComponent: Mount - dropdown found:', !!dropdown); + logger.debug('FirmwareComponent: Mount - dropdown found:', !!dropdown); if (dropdown) { - console.log('FirmwareComponent: Mount - dropdown tagName:', dropdown.tagName); - console.log('FirmwareComponent: Mount - dropdown id:', dropdown.id); - console.log('FirmwareComponent: Mount - dropdown innerHTML:', dropdown.innerHTML); + logger.debug('FirmwareComponent: Mount - dropdown tagName:', dropdown.tagName); + logger.debug('FirmwareComponent: Mount - dropdown id:', dropdown.id); + logger.debug('FirmwareComponent: Mount - dropdown innerHTML:', dropdown.innerHTML); } // Initialize target visibility and label list on first mount @@ -1365,7 +1365,7 @@ class FirmwareComponent extends Component { console.warn('FirmwareComponent: Initialization after mount failed:', e); } - console.log('FirmwareComponent: Mounted successfully'); + logger.debug('FirmwareComponent: Mounted successfully'); } render() { @@ -1751,7 +1751,7 @@ class FirmwareComponent extends Component { const specificNodeSelect = this.findElement('#specific-node-select'); const labelSelect = this.findElement('#label-select'); - console.log('FirmwareComponent: updateTargetVisibility called with targetType:', targetType); + logger.debug('FirmwareComponent: updateTargetVisibility called with targetType:', targetType); if (targetType === 'specific') { if (specificNodeSelect) { specificNodeSelect.style.display = 'inline-block'; } @@ -1792,9 +1792,9 @@ class FirmwareComponent extends Component { return; } - console.log('FirmwareComponent: populateNodeSelect called'); - console.log('FirmwareComponent: Select element:', select); - console.log('FirmwareComponent: Available nodes:', this.viewModel.get('availableNodes')); + logger.debug('FirmwareComponent: populateNodeSelect called'); + logger.debug('FirmwareComponent: Select element:', select); + logger.debug('FirmwareComponent: Available nodes:', this.viewModel.get('availableNodes')); // Clear existing options select.innerHTML = ''; @@ -1822,7 +1822,7 @@ class FirmwareComponent extends Component { // Ensure event listener is still bound after repopulating this.ensureNodeSelectListener(select); - console.log('FirmwareComponent: Node select populated with', availableNodes.length, 'nodes'); + logger.debug('FirmwareComponent: Node select populated with', availableNodes.length, 'nodes'); } // Ensure the node select change listener is properly bound @@ -2284,7 +2284,7 @@ class ClusterStatusComponent extends Component { class TopologyGraphComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); - console.log('TopologyGraphComponent: Constructor called'); + logger.debug('TopologyGraphComponent: Constructor called'); this.svg = null; this.simulation = null; this.zoom = null; @@ -2303,7 +2303,7 @@ class TopologyGraphComponent extends Component { this.width = Math.max(this.width, 800); this.height = Math.max(this.height, 600); - console.log('TopologyGraphComponent: Updated dimensions to', this.width, 'x', this.height); + logger.debug('TopologyGraphComponent: Updated dimensions to', this.width, 'x', this.height); } handleResize() { @@ -2330,14 +2330,14 @@ class TopologyGraphComponent extends Component { mount() { if (this.isMounted) return; - console.log('TopologyGraphComponent: Starting mount...'); - console.log('TopologyGraphComponent: isInitialized =', this.isInitialized); + logger.debug('TopologyGraphComponent: Starting mount...'); + logger.debug('TopologyGraphComponent: isInitialized =', this.isInitialized); // Call initialize if not already done if (!this.isInitialized) { - console.log('TopologyGraphComponent: Initializing during mount...'); + logger.debug('TopologyGraphComponent: Initializing during mount...'); this.initialize().then(() => { - console.log('TopologyGraphComponent: Initialization completed, calling completeMount...'); + logger.debug('TopologyGraphComponent: Initialization completed, calling completeMount...'); // Complete mount after initialization this.completeMount(); }).catch(error => { @@ -2346,7 +2346,7 @@ class TopologyGraphComponent extends Component { this.completeMount(); }); } else { - console.log('TopologyGraphComponent: Already initialized, calling completeMount directly...'); + logger.debug('TopologyGraphComponent: Already initialized, calling completeMount directly...'); this.completeMount(); } } From 9dab498aa2a569de7d174678178ab87b9b34720a Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 12:18:15 +0200 Subject: [PATCH 07/18] refactor(logging): replace remaining console.* with logger.debug/error across app, view-models, api-client, and framework --- public/scripts/api-client.js | 2 +- public/scripts/app.js | 38 +++++++++++++++++------------------ public/scripts/framework.js | 2 +- public/scripts/view-models.js | 26 ++++++++++++------------ 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/public/scripts/api-client.js b/public/scripts/api-client.js index bcd0f91..17e6204 100644 --- a/public/scripts/api-client.js +++ b/public/scripts/api-client.js @@ -15,7 +15,7 @@ class ApiClient { this.baseUrl = `http://${currentHost}:3001`; } - console.log('API Client initialized with base URL:', this.baseUrl); + logger.debug('API Client initialized with base URL:', this.baseUrl); } async request(path, { method = 'GET', headers = {}, body = undefined, query = undefined, isForm = false } = {}) { diff --git a/public/scripts/app.js b/public/scripts/app.js index 8a07bc7..fa86415 100644 --- a/public/scripts/app.js +++ b/public/scripts/app.js @@ -2,22 +2,22 @@ // Initialize the application when DOM is loaded document.addEventListener('DOMContentLoaded', function() { - console.log('=== SPORE UI Application Initialization ==='); + logger.debug('=== SPORE UI Application Initialization ==='); // Initialize the framework (but don't navigate yet) - console.log('App: Creating framework instance...'); + logger.debug('App: Creating framework instance...'); const app = window.app; // Create view models - console.log('App: Creating view models...'); + logger.debug('App: Creating view models...'); const clusterViewModel = new ClusterViewModel(); const firmwareViewModel = new FirmwareViewModel(); const topologyViewModel = new TopologyViewModel(); - console.log('App: View models created:', { clusterViewModel, firmwareViewModel, topologyViewModel }); + logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, topologyViewModel }); // Connect firmware view model to cluster data clusterViewModel.subscribe('members', (members) => { - console.log('App: Members subscription triggered:', members); + logger.debug('App: Members subscription triggered:', members); if (members && members.length > 0) { // Extract node information for firmware view const nodes = members.map(member => ({ @@ -26,39 +26,39 @@ document.addEventListener('DOMContentLoaded', function() { labels: member.labels || {} })); firmwareViewModel.updateAvailableNodes(nodes); - console.log('App: Updated firmware view model with nodes:', nodes); + logger.debug('App: Updated firmware view model with nodes:', nodes); } else { firmwareViewModel.updateAvailableNodes([]); - console.log('App: Cleared firmware view model nodes'); + logger.debug('App: Cleared firmware view model nodes'); } }); // Register routes with their view models - console.log('App: Registering routes...'); + logger.debug('App: Registering routes...'); app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel); app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel); app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel); - console.log('App: Routes registered and components pre-initialized'); + logger.debug('App: Routes registered and components pre-initialized'); // Initialize cluster status component for header badge - console.log('App: Initializing cluster status component...'); + logger.debug('App: Initializing cluster status component...'); const clusterStatusComponent = new ClusterStatusComponent( document.querySelector('.cluster-status'), clusterViewModel, app.eventBus ); clusterStatusComponent.mount(); - console.log('App: Cluster status component initialized'); + logger.debug('App: Cluster status component initialized'); // Set up navigation event listeners - console.log('App: Setting up navigation...'); + logger.debug('App: Setting up navigation...'); app.setupNavigation(); // Now navigate to the default route - console.log('App: Navigating to default route...'); + logger.debug('App: Navigating to default route...'); app.navigateTo('cluster'); - console.log('=== SPORE UI Application initialization completed ==='); + logger.debug('=== SPORE UI Application initialization completed ==='); }); // Burger menu toggle for mobile @@ -97,10 +97,10 @@ function setupPeriodicUpdates() { // 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...'); + logger.debug('App: Performing smart update to preserve UI state...'); viewModel.smartUpdate(); } else if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') { - console.log('App: Performing regular update...'); + logger.debug('App: Performing regular update...'); viewModel.updateClusterMembers(); } } @@ -119,18 +119,18 @@ function setupPeriodicUpdates() { // Global error handler window.addEventListener('error', function(event) { - console.error('Global error:', event.error); + logger.error('Global error:', event.error); }); // Global unhandled promise rejection handler window.addEventListener('unhandledrejection', function(event) { - console.error('Unhandled promise rejection:', event.reason); + logger.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...'); + logger.debug('App: Cleaning up cached components...'); window.app.cleanup(); } }); \ No newline at end of file diff --git a/public/scripts/framework.js b/public/scripts/framework.js index 9245c6f..d1d32bd 100644 --- a/public/scripts/framework.js +++ b/public/scripts/framework.js @@ -840,7 +840,7 @@ class App { // Initialize the application init() { - console.log('SPORE UI Framework initialized'); + logger.debug('SPORE UI Framework initialized'); // Note: Navigation is now handled by the app initialization // to ensure routes are registered before navigation diff --git a/public/scripts/view-models.js b/public/scripts/view-models.js index 3ef6c4e..8e38389 100644 --- a/public/scripts/view-models.js +++ b/public/scripts/view-models.js @@ -26,7 +26,7 @@ class ClusterViewModel extends ViewModel { // Update cluster members with state preservation async updateClusterMembers() { try { - console.log('ClusterViewModel: updateClusterMembers called'); + logger.debug('ClusterViewModel: updateClusterMembers called'); // Store current UI state before update const currentUIState = this.getAllUIState(); @@ -36,9 +36,9 @@ class ClusterViewModel extends ViewModel { this.set('isLoading', true); this.set('error', null); - console.log('ClusterViewModel: Fetching cluster members...'); + logger.debug('ClusterViewModel: Fetching cluster members...'); const response = await window.apiClient.getClusterMembers(); - console.log('ClusterViewModel: Got response:', response); + logger.debug('ClusterViewModel: Got response:', response); const members = response.members || []; const onlineNodes = Array.isArray(members) @@ -57,7 +57,7 @@ class ClusterViewModel extends ViewModel { this.set('activeTabs', currentActiveTabs); // Update primary node display - console.log('ClusterViewModel: Updating primary node display...'); + logger.debug('ClusterViewModel: Updating primary node display...'); await this.updatePrimaryNodeDisplay(); } catch (error) { @@ -65,7 +65,7 @@ class ClusterViewModel extends ViewModel { this.set('error', error.message); } finally { this.set('isLoading', false); - console.log('ClusterViewModel: updateClusterMembers completed'); + logger.debug('ClusterViewModel: updateClusterMembers completed'); } } @@ -185,7 +185,7 @@ class ClusterViewModel extends ViewModel { // Smart update that only updates changed data async smartUpdate() { try { - console.log('ClusterViewModel: Performing smart update...'); + logger.debug('ClusterViewModel: Performing smart update...'); // Fetch new data const response = await window.apiClient.getClusterMembers(); @@ -193,10 +193,10 @@ class ClusterViewModel extends ViewModel { // Check if members data has actually changed if (this.hasDataChanged(newMembers, 'members')) { - console.log('ClusterViewModel: Members data changed, updating...'); + logger.debug('ClusterViewModel: Members data changed, updating...'); await this.updateClusterMembers(); } else { - console.log('ClusterViewModel: Members data unchanged, skipping update'); + logger.debug('ClusterViewModel: Members data unchanged, skipping update'); // Still update primary node display as it might have changed await this.updatePrimaryNodeDisplay(); } @@ -292,7 +292,7 @@ class NodeDetailsViewModel extends ViewModel { // Set active tab with state persistence setActiveTab(tabName) { - console.log('NodeDetailsViewModel: Setting activeTab to:', tabName); + logger.debug('NodeDetailsViewModel: Setting activeTab to:', tabName); this.set('activeTab', tabName); // Store in UI state for persistence @@ -492,14 +492,14 @@ class TopologyViewModel extends ViewModel { // Update network topology data async updateNetworkTopology() { try { - console.log('TopologyViewModel: updateNetworkTopology called'); + logger.debug('TopologyViewModel: updateNetworkTopology called'); this.set('isLoading', true); this.set('error', null); // Get cluster members from the primary node const response = await window.apiClient.getClusterMembers(); - console.log('TopologyViewModel: Got cluster members response:', response); + logger.debug('TopologyViewModel: Got cluster members response:', response); const members = response.members || []; @@ -517,7 +517,7 @@ class TopologyViewModel extends ViewModel { this.set('error', error.message); } finally { this.set('isLoading', false); - console.log('TopologyViewModel: updateNetworkTopology completed'); + logger.debug('TopologyViewModel: updateNetworkTopology completed'); } } @@ -589,7 +589,7 @@ class TopologyViewModel extends ViewModel { // If no actual connections found, create a basic mesh if (links.length === 0) { - console.log('TopologyViewModel: No actual connections found, creating basic mesh'); + logger.debug('TopologyViewModel: No actual connections found, creating basic mesh'); for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const sourceNode = nodes[i]; From 948a8a1fabda80bd082564d6eb622730571e133e Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 12:20:11 +0200 Subject: [PATCH 08/18] refactor(logging): replace remaining console.* with logger.* in components.js --- public/scripts/components.js | 238 +++++++++++++++++------------------ 1 file changed, 119 insertions(+), 119 deletions(-) diff --git a/public/scripts/components.js b/public/scripts/components.js index 449a5c1..dea26ac 100644 --- a/public/scripts/components.js +++ b/public/scripts/components.js @@ -75,7 +75,7 @@ class PrimaryNodeComponent extends Component { }, 1500); } catch (error) { - console.error('Failed to select random primary node:', error); + logger.error('Failed to select random primary node:', error); this.setText('#primary-node-ip', '❌ Selection Failed'); this.setClass('#primary-node-ip', 'error', true); this.setClass('#primary-node-ip', 'selecting', false); @@ -106,7 +106,7 @@ class ClusterMembersComponent extends Component { // Ensure initial render happens even if no data setTimeout(() => { if (this.isMounted && !this.renderInProgress) { - console.log('ClusterMembersComponent: Performing initial render check'); + logger.debug('ClusterMembersComponent: Performing initial render check'); this.render(); } }, 200); @@ -131,7 +131,7 @@ class ClusterMembersComponent extends Component { this.loadingTimeout = setTimeout(() => { const isLoading = this.viewModel.get('isLoading'); if (isLoading) { - console.warn('ClusterMembersComponent: Loading timeout reached, forcing render check'); + logger.warn('ClusterMembersComponent: Loading timeout reached, forcing render check'); this.forceRenderCheck(); } }, 10000); // 10 second timeout @@ -295,7 +295,7 @@ class ClusterMembersComponent extends Component { // Update members partially to preserve UI state updateMembersPartially(newMembers, previousMembers) { - console.log('ClusterMembersComponent: Performing partial update to preserve UI state'); + logger.debug('ClusterMembersComponent: Performing partial update to preserve UI state'); // Build previous map by IP for stable diffs const prevByIp = new Map((previousMembers || []).map(m => [m.ip, m])); @@ -344,7 +344,7 @@ class ClusterMembersComponent extends Component { render() { if (this.renderInProgress) { - console.log('ClusterMembersComponent: Render already in progress, skipping'); + logger.debug('ClusterMembersComponent: Render already in progress, skipping'); return; } @@ -356,35 +356,35 @@ class ClusterMembersComponent extends Component { this.renderInProgress = true; try { - console.log('ClusterMembersComponent: render() called'); - console.log('ClusterMembersComponent: Container element:', this.container); - console.log('ClusterMembersComponent: Is mounted:', this.isMounted); + logger.debug('ClusterMembersComponent: render() called'); + logger.debug('ClusterMembersComponent: Container element:', this.container); + logger.debug('ClusterMembersComponent: Is mounted:', this.isMounted); const members = this.viewModel.get('members'); const isLoading = this.viewModel.get('isLoading'); const error = this.viewModel.get('error'); - console.log('ClusterMembersComponent: render data:', { members, isLoading, error }); + logger.debug('ClusterMembersComponent: render data:', { members, isLoading, error }); if (isLoading) { - console.log('ClusterMembersComponent: Showing loading state'); + logger.debug('ClusterMembersComponent: Showing loading state'); this.showLoadingState(); return; } if (error) { - console.log('ClusterMembersComponent: Showing error state'); + logger.debug('ClusterMembersComponent: Showing error state'); this.showErrorState(error); return; } if (!members || members.length === 0) { - console.log('ClusterMembersComponent: Showing empty state'); + logger.debug('ClusterMembersComponent: Showing empty state'); this.showEmptyState(); return; } - console.log('ClusterMembersComponent: Rendering members:', members); + logger.debug('ClusterMembersComponent: Rendering members:', members); this.renderMembers(members); } finally { @@ -543,7 +543,7 @@ class ClusterMembersComponent extends Component { } } catch (error) { - console.error('Failed to expand card:', error); + logger.error('Failed to expand card:', error); memberDetails.innerHTML = `
Error loading node details:
@@ -600,7 +600,7 @@ class ClusterMembersComponent extends Component { const expandedCards = this.viewModel.get('expandedCards'); const activeTabs = this.viewModel.get('activeTabs'); - console.log('ClusterMembersComponent: Debug State:', { + logger.debug('ClusterMembersComponent: Debug State:', { isMounted: this.isMounted, container: this.container, members: members, @@ -617,7 +617,7 @@ class ClusterMembersComponent extends Component { // Manual refresh method that bypasses potential state conflicts async manualRefresh() { - console.log('ClusterMembersComponent: Manual refresh called'); + logger.debug('ClusterMembersComponent: Manual refresh called'); try { // Clear any existing loading state @@ -627,9 +627,9 @@ class ClusterMembersComponent extends Component { // Force a fresh data load await this.viewModel.updateClusterMembers(); - console.log('ClusterMembersComponent: Manual refresh completed'); + logger.debug('ClusterMembersComponent: Manual refresh completed'); } catch (error) { - console.error('ClusterMembersComponent: Manual refresh failed:', error); + logger.error('ClusterMembersComponent: Manual refresh failed:', error); this.showErrorState(error.message); } } @@ -656,12 +656,12 @@ class ClusterMembersComponent extends Component { this.cleanupEventListeners(); this.cleanupViewModelListeners(); - console.log(`${this.constructor.name} unmounted`); + logger.debug(`${this.constructor.name} unmounted`); } // Override pause method to handle timeouts and operations onPause() { - console.log('ClusterMembersComponent: Pausing...'); + logger.debug('ClusterMembersComponent: Pausing...'); // Clear any pending timeouts if (this.loadingTimeout) { @@ -680,7 +680,7 @@ class ClusterMembersComponent extends Component { // Override resume method to restore functionality onResume() { - console.log('ClusterMembersComponent: Resuming...'); + logger.debug('ClusterMembersComponent: Resuming...'); this.isPaused = false; @@ -700,7 +700,7 @@ class ClusterMembersComponent extends Component { // If we were loading and it completed while paused, handle the completion if (!isLoading && members && members.length > 0) { - console.log('ClusterMembersComponent: Handling pending loading completion after resume'); + logger.debug('ClusterMembersComponent: Handling pending loading completion after resume'); this.handleLoadingCompletion(); } } @@ -799,7 +799,7 @@ class NodeDetailsComponent extends Component { renderNodeDetails(nodeStatus, tasks, capabilities) { // Use persisted active tab from the view model, default to 'status' const activeTab = (this.viewModel && typeof this.viewModel.get === 'function' && this.viewModel.get('activeTab')) || 'status'; - console.log('NodeDetailsComponent: Rendering with activeTab:', activeTab); + logger.debug('NodeDetailsComponent: Rendering with activeTab:', activeTab); const html = `
@@ -1215,10 +1215,10 @@ class NodeDetailsComponent extends Component {
`; - console.log('Firmware upload successful:', result); + logger.debug('Firmware upload successful:', result); } catch (error) { - console.error('Firmware upload failed:', error); + logger.error('Firmware upload failed:', error); // Show error uploadStatus.innerHTML = ` @@ -1286,7 +1286,7 @@ class FirmwareComponent extends Component { this.addEventListener(specificNodeSelect, 'change', this._boundNodeSelectHandler); logger.debug('FirmwareComponent: Event listener added to specificNodeSelect'); } else { - console.warn('FirmwareComponent: specificNodeSelect element not found during setupEventListeners'); + logger.warn('FirmwareComponent: specificNodeSelect element not found during setupEventListeners'); } // Setup label select change handler (single-select add-to-chips) @@ -1362,7 +1362,7 @@ class FirmwareComponent extends Component { this.populateLabelSelect(); this.updateAffectedNodesPreview(); } catch (e) { - console.warn('FirmwareComponent: Initialization after mount failed:', e); + logger.warn('FirmwareComponent: Initialization after mount failed:', e); } logger.debug('FirmwareComponent: Mounted successfully'); @@ -1385,10 +1385,10 @@ class FirmwareComponent extends Component { handleNodeSelect(event) { const nodeIp = event.target.value; - console.log('FirmwareComponent: handleNodeSelect called with nodeIp:', nodeIp); - console.log('Event:', event); - console.log('Event target:', event.target); - console.log('Event target value:', event.target.value); + logger.debug('FirmwareComponent: handleNodeSelect called with nodeIp:', nodeIp); + logger.debug('Event:', event); + logger.debug('Event target:', event.target); + logger.debug('Event target value:', event.target.value); this.viewModel.setSpecificNode(nodeIp); @@ -1426,7 +1426,7 @@ class FirmwareComponent extends Component { this.viewModel.resetUploadState(); } catch (error) { - console.error('Firmware deployment failed:', error); + logger.error('Firmware deployment failed:', error); alert(`Deployment failed: ${error.message}`); } finally { this.viewModel.completeUpload(); @@ -1457,7 +1457,7 @@ class FirmwareComponent extends Component { this.displayUploadResults(results); } catch (error) { - console.error('Failed to upload firmware to all nodes:', error); + logger.error('Failed to upload firmware to all nodes:', error); throw error; } } @@ -1484,7 +1484,7 @@ class FirmwareComponent extends Component { this.displayUploadResults([result]); } catch (error) { - console.error(`Failed to upload firmware to node ${nodeIp}:`, error); + logger.error(`Failed to upload firmware to node ${nodeIp}:`, error); // Update progress to show failure this.updateNodeProgress(1, 1, nodeIp, 'Failed'); @@ -1524,7 +1524,7 @@ class FirmwareComponent extends Component { // Display results this.displayUploadResults(results); } catch (error) { - console.error('Failed to upload firmware to label-filtered nodes:', error); + logger.error('Failed to upload firmware to label-filtered nodes:', error); throw error; } } @@ -1552,7 +1552,7 @@ class FirmwareComponent extends Component { this.updateOverallProgress(successfulUploads, totalNodes); } catch (error) { - console.error(`Failed to upload to node ${nodeIp}:`, error); + logger.error(`Failed to upload to node ${nodeIp}:`, error); const errorResult = { nodeIp: nodeIp, hostname: node.hostname || nodeIp, @@ -1783,12 +1783,12 @@ class FirmwareComponent extends Component { populateNodeSelect() { const select = this.findElement('#specific-node-select'); if (!select) { - console.warn('FirmwareComponent: populateNodeSelect - select element not found'); + logger.warn('FirmwareComponent: populateNodeSelect - select element not found'); return; } if (select.tagName !== 'SELECT') { - console.warn('FirmwareComponent: populateNodeSelect - element is not a SELECT:', select.tagName); + logger.warn('FirmwareComponent: populateNodeSelect - element is not a SELECT:', select.tagName); return; } @@ -1838,7 +1838,7 @@ class FirmwareComponent extends Component { select.removeEventListener('change', this._boundNodeSelectHandler); select.addEventListener('change', this._boundNodeSelectHandler); - console.log('FirmwareComponent: Node select event listener ensured'); + logger.debug('FirmwareComponent: Node select event listener ensured'); } updateUploadProgress() { @@ -1942,18 +1942,18 @@ class ClusterViewComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); - console.log('ClusterViewComponent: Constructor called'); - console.log('ClusterViewComponent: Container:', container); - console.log('ClusterViewComponent: Container ID:', container?.id); + logger.debug('ClusterViewComponent: Constructor called'); + logger.debug('ClusterViewComponent: Container:', container); + logger.debug('ClusterViewComponent: Container ID:', container?.id); // Find elements for sub-components const primaryNodeContainer = this.findElement('.primary-node-info'); const clusterMembersContainer = this.findElement('#cluster-members-container'); - console.log('ClusterViewComponent: Primary node container:', primaryNodeContainer); - console.log('ClusterViewComponent: Cluster members container:', clusterMembersContainer); - console.log('ClusterViewComponent: Cluster members container ID:', clusterMembersContainer?.id); - console.log('ClusterViewComponent: Cluster members container innerHTML:', clusterMembersContainer?.innerHTML); + logger.debug('ClusterViewComponent: Primary node container:', primaryNodeContainer); + logger.debug('ClusterViewComponent: Cluster members container:', clusterMembersContainer); + logger.debug('ClusterViewComponent: Cluster members container ID:', clusterMembersContainer?.id); + logger.debug('ClusterViewComponent: Cluster members container innerHTML:', clusterMembersContainer?.innerHTML); // Create sub-components this.primaryNodeComponent = new PrimaryNodeComponent( @@ -1968,17 +1968,17 @@ class ClusterViewComponent extends Component { eventBus ); - console.log('ClusterViewComponent: Sub-components created'); + logger.debug('ClusterViewComponent: Sub-components created'); // Track if we've already loaded data to prevent unnecessary reloads this.dataLoaded = false; } mount() { - console.log('ClusterViewComponent: Mounting...'); + logger.debug('ClusterViewComponent: Mounting...'); super.mount(); - console.log('ClusterViewComponent: Mounting sub-components...'); + logger.debug('ClusterViewComponent: Mounting sub-components...'); // Mount sub-components this.primaryNodeComponent.mount(); this.clusterMembersComponent.mount(); @@ -1991,51 +1991,51 @@ class ClusterViewComponent extends Component { const shouldLoadData = !this.dataLoaded || !members || members.length === 0; if (shouldLoadData) { - console.log('ClusterViewComponent: Starting initial data load...'); + logger.debug('ClusterViewComponent: Starting initial data load...'); // Initial data load - ensure it happens after mounting setTimeout(() => { this.viewModel.updateClusterMembers().then(() => { this.dataLoaded = true; }).catch(error => { - console.error('ClusterViewComponent: Failed to load initial data:', error); + logger.error('ClusterViewComponent: Failed to load initial data:', error); }); }, 100); } else { - console.log('ClusterViewComponent: Data already loaded, skipping initial load'); + logger.debug('ClusterViewComponent: Data already loaded, skipping initial load'); } // Set up periodic updates // this.setupPeriodicUpdates(); // Disabled automatic refresh - console.log('ClusterViewComponent: Mounted successfully'); + logger.debug('ClusterViewComponent: Mounted successfully'); } setupRefreshButton() { - console.log('ClusterViewComponent: Setting up refresh button...'); + logger.debug('ClusterViewComponent: Setting up refresh button...'); const refreshBtn = this.findElement('.refresh-btn'); - console.log('ClusterViewComponent: Found refresh button:', !!refreshBtn, refreshBtn); + logger.debug('ClusterViewComponent: Found refresh button:', !!refreshBtn, refreshBtn); if (refreshBtn) { - console.log('ClusterViewComponent: Adding click event listener to refresh button'); + logger.debug('ClusterViewComponent: Adding click event listener to refresh button'); this.addEventListener(refreshBtn, 'click', this.handleRefresh.bind(this)); - console.log('ClusterViewComponent: Event listener added successfully'); + logger.debug('ClusterViewComponent: Event listener added successfully'); } else { - console.error('ClusterViewComponent: Refresh button not found!'); - console.log('ClusterViewComponent: Container HTML:', this.container.innerHTML); - console.log('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button')); + logger.error('ClusterViewComponent: Refresh button not found!'); + logger.debug('ClusterViewComponent: Container HTML:', this.container.innerHTML); + logger.debug('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button')); } } async handleRefresh() { - console.log('ClusterViewComponent: Refresh button clicked, performing full refresh...'); + logger.debug('ClusterViewComponent: Refresh button clicked, performing full refresh...'); // Get the refresh button and show loading state const refreshBtn = this.findElement('.refresh-btn'); - console.log('ClusterViewComponent: Found refresh button for loading state:', !!refreshBtn); + logger.debug('ClusterViewComponent: Found refresh button for loading state:', !!refreshBtn); if (refreshBtn) { const originalText = refreshBtn.innerHTML; - console.log('ClusterViewComponent: Original button text:', originalText); + logger.debug('ClusterViewComponent: Original button text:', originalText); refreshBtn.innerHTML = ` @@ -2047,29 +2047,29 @@ class ClusterViewComponent extends Component { refreshBtn.disabled = true; try { - console.log('ClusterViewComponent: Starting cluster members update...'); + logger.debug('ClusterViewComponent: Starting cluster members update...'); // Always perform a full refresh when user clicks refresh button await this.viewModel.updateClusterMembers(); - console.log('ClusterViewComponent: Cluster members update completed successfully'); + logger.debug('ClusterViewComponent: Cluster members update completed successfully'); } catch (error) { - console.error('ClusterViewComponent: Error during refresh:', error); + logger.error('ClusterViewComponent: Error during refresh:', error); // Show error state if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) { this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed'); } } finally { - console.log('ClusterViewComponent: Restoring button state...'); + logger.debug('ClusterViewComponent: Restoring button state...'); // Restore button state refreshBtn.innerHTML = originalText; refreshBtn.disabled = false; } } else { - console.warn('ClusterViewComponent: Refresh button not found, using fallback refresh'); + logger.warn('ClusterViewComponent: Refresh button not found, using fallback refresh'); // Fallback if button not found try { await this.viewModel.updateClusterMembers(); } catch (error) { - console.error('ClusterViewComponent: Fallback refresh failed:', error); + logger.error('ClusterViewComponent: Fallback refresh failed:', error); if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) { this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed'); } @@ -2078,7 +2078,7 @@ class ClusterViewComponent extends Component { } unmount() { - console.log('ClusterViewComponent: Unmounting...'); + logger.debug('ClusterViewComponent: Unmounting...'); // Unmount sub-components if (this.primaryNodeComponent) { @@ -2094,12 +2094,12 @@ class ClusterViewComponent extends Component { } super.unmount(); - console.log('ClusterViewComponent: Unmounted'); + logger.debug('ClusterViewComponent: Unmounted'); } // Override pause method to handle sub-components onPause() { - console.log('ClusterViewComponent: Pausing...'); + logger.debug('ClusterViewComponent: Pausing...'); // Pause sub-components if (this.primaryNodeComponent && this.primaryNodeComponent.isMounted) { @@ -2118,7 +2118,7 @@ class ClusterViewComponent extends Component { // Override resume method to handle sub-components onResume() { - console.log('ClusterViewComponent: Resuming...'); + logger.debug('ClusterViewComponent: Resuming...'); // Resume sub-components if (this.primaryNodeComponent && this.primaryNodeComponent.isMounted) { @@ -2151,11 +2151,11 @@ class FirmwareViewComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); - console.log('FirmwareViewComponent: Constructor called'); - console.log('FirmwareViewComponent: Container:', container); + logger.debug('FirmwareViewComponent: Constructor called'); + logger.debug('FirmwareViewComponent: Container:', container); const firmwareContainer = this.findElement('#firmware-container'); - console.log('FirmwareViewComponent: Firmware container found:', !!firmwareContainer); + logger.debug('FirmwareViewComponent: Firmware container found:', !!firmwareContainer); this.firmwareComponent = new FirmwareComponent( firmwareContainer, @@ -2163,13 +2163,13 @@ class FirmwareViewComponent extends Component { eventBus ); - console.log('FirmwareViewComponent: FirmwareComponent created'); + logger.debug('FirmwareViewComponent: FirmwareComponent created'); } mount() { super.mount(); - console.log('FirmwareViewComponent: Mounting...'); + logger.debug('FirmwareViewComponent: Mounting...'); // Mount sub-component this.firmwareComponent.mount(); @@ -2177,7 +2177,7 @@ class FirmwareViewComponent extends Component { // Update available nodes this.updateAvailableNodes(); - console.log('FirmwareViewComponent: Mounted successfully'); + logger.debug('FirmwareViewComponent: Mounted successfully'); } unmount() { @@ -2191,7 +2191,7 @@ class FirmwareViewComponent extends Component { // Override pause method to handle sub-components onPause() { - console.log('FirmwareViewComponent: Pausing...'); + logger.debug('FirmwareViewComponent: Pausing...'); // Pause sub-component if (this.firmwareComponent && this.firmwareComponent.isMounted) { @@ -2201,7 +2201,7 @@ class FirmwareViewComponent extends Component { // Override resume method to handle sub-components onResume() { - console.log('FirmwareViewComponent: Resuming...'); + logger.debug('FirmwareViewComponent: Resuming...'); // Resume sub-component if (this.firmwareComponent && this.firmwareComponent.isMounted) { @@ -2217,14 +2217,14 @@ class FirmwareViewComponent extends Component { async updateAvailableNodes() { try { - console.log('FirmwareViewComponent: updateAvailableNodes called'); + logger.debug('FirmwareViewComponent: updateAvailableNodes called'); const response = await window.apiClient.getClusterMembers(); const nodes = response.members || []; - console.log('FirmwareViewComponent: Got nodes:', nodes); + logger.debug('FirmwareViewComponent: Got nodes:', nodes); this.viewModel.updateAvailableNodes(nodes); - console.log('FirmwareViewComponent: Available nodes updated in view model'); + logger.debug('FirmwareViewComponent: Available nodes updated in view model'); } catch (error) { - console.error('Failed to update available nodes:', error); + logger.error('Failed to update available nodes:', error); } } } @@ -2341,7 +2341,7 @@ class TopologyGraphComponent extends Component { // Complete mount after initialization this.completeMount(); }).catch(error => { - console.error('TopologyGraphComponent: Initialization failed during mount:', error); + logger.error('TopologyGraphComponent: Initialization failed during mount:', error); // Still complete mount to prevent blocking this.completeMount(); }); @@ -2352,38 +2352,38 @@ class TopologyGraphComponent extends Component { } completeMount() { - console.log('TopologyGraphComponent: completeMount called'); + logger.debug('TopologyGraphComponent: completeMount called'); this.isMounted = true; - console.log('TopologyGraphComponent: Setting up event listeners...'); + logger.debug('TopologyGraphComponent: Setting up event listeners...'); this.setupEventListeners(); - console.log('TopologyGraphComponent: Setting up view model listeners...'); + logger.debug('TopologyGraphComponent: Setting up view model listeners...'); this.setupViewModelListeners(); - console.log('TopologyGraphComponent: Calling render...'); + logger.debug('TopologyGraphComponent: Calling render...'); this.render(); - console.log('TopologyGraphComponent: Mounted successfully'); + logger.debug('TopologyGraphComponent: Mounted successfully'); } setupEventListeners() { - console.log('TopologyGraphComponent: setupEventListeners called'); - console.log('TopologyGraphComponent: Container:', this.container); - console.log('TopologyGraphComponent: Container ID:', this.container?.id); + logger.debug('TopologyGraphComponent: setupEventListeners called'); + logger.debug('TopologyGraphComponent: Container:', this.container); + logger.debug('TopologyGraphComponent: Container ID:', this.container?.id); // Add resize listener to update dimensions when window is resized this.resizeHandler = this.handleResize.bind(this); window.addEventListener('resize', this.resizeHandler); // Refresh button removed from HTML, so no need to set up event listeners - console.log('TopologyGraphComponent: No event listeners needed (refresh button removed)'); + logger.debug('TopologyGraphComponent: No event listeners needed (refresh button removed)'); } setupViewModelListeners() { - console.log('TopologyGraphComponent: setupViewModelListeners called'); - console.log('TopologyGraphComponent: isInitialized =', this.isInitialized); + logger.debug('TopologyGraphComponent: setupViewModelListeners called'); + logger.debug('TopologyGraphComponent: isInitialized =', this.isInitialized); if (this.isInitialized) { // Component is already initialized, set up subscriptions immediately - console.log('TopologyGraphComponent: Setting up property subscriptions immediately'); + logger.debug('TopologyGraphComponent: Setting up property subscriptions immediately'); this.subscribeToProperty('nodes', this.renderGraph.bind(this)); this.subscribeToProperty('links', this.renderGraph.bind(this)); this.subscribeToProperty('isLoading', this.handleLoadingState.bind(this)); @@ -2391,7 +2391,7 @@ class TopologyGraphComponent extends Component { this.subscribeToProperty('selectedNode', this.updateSelection.bind(this)); } else { // Component not yet initialized, store for later - console.log('TopologyGraphComponent: Component not initialized, storing pending subscriptions'); + logger.debug('TopologyGraphComponent: Component not initialized, storing pending subscriptions'); this._pendingSubscriptions = [ ['nodes', this.renderGraph.bind(this)], ['links', this.renderGraph.bind(this)], @@ -2403,7 +2403,7 @@ class TopologyGraphComponent extends Component { } async initialize() { - console.log('TopologyGraphComponent: Initializing...'); + logger.debug('TopologyGraphComponent: Initializing...'); // Wait for DOM to be ready if (document.readyState === 'loading') { @@ -2433,7 +2433,7 @@ class TopologyGraphComponent extends Component { setupSVG() { const container = this.findElement('#topology-graph-container'); if (!container) { - console.error('TopologyGraphComponent: Graph container not found'); + logger.error('TopologyGraphComponent: Graph container not found'); return; } @@ -2468,13 +2468,13 @@ class TopologyGraphComponent extends Component { // Apply initial zoom to show the graph more zoomed in mainGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); // Better centered positioning - console.log('TopologyGraphComponent: SVG setup completed'); + logger.debug('TopologyGraphComponent: SVG setup completed'); } // Ensure component is initialized async ensureInitialized() { if (!this.isInitialized) { - console.log('TopologyGraphComponent: Ensuring initialization...'); + logger.debug('TopologyGraphComponent: Ensuring initialization...'); await this.initialize(); } return this.isInitialized; @@ -2484,12 +2484,12 @@ class TopologyGraphComponent extends Component { try { // Check if component is initialized if (!this.isInitialized) { - console.log('TopologyGraphComponent: Component not yet initialized, ensuring initialization...'); + logger.debug('TopologyGraphComponent: Component not yet initialized, ensuring initialization...'); this.ensureInitialized().then(() => { // Re-render after initialization this.renderGraph(); }).catch(error => { - console.error('TopologyGraphComponent: Failed to initialize:', error); + logger.error('TopologyGraphComponent: Failed to initialize:', error); }); return; } @@ -2499,7 +2499,7 @@ class TopologyGraphComponent extends Component { // Check if SVG is initialized if (!this.svg) { - console.log('TopologyGraphComponent: SVG not initialized yet, setting up SVG first'); + logger.debug('TopologyGraphComponent: SVG not initialized yet, setting up SVG first'); this.setupSVG(); } @@ -2508,12 +2508,12 @@ class TopologyGraphComponent extends Component { return; } - console.log('TopologyGraphComponent: Rendering graph with', nodes.length, 'nodes and', links.length, 'links'); + logger.debug('TopologyGraphComponent: Rendering graph with', nodes.length, 'nodes and', links.length, 'links'); // Get the main SVG group (the one created in setupSVG) let svgGroup = this.svg.select('g'); if (!svgGroup || svgGroup.empty()) { - console.log('TopologyGraphComponent: Creating new SVG group'); + logger.debug('TopologyGraphComponent: Creating new SVG group'); svgGroup = this.svg.append('g'); // Apply initial zoom to show the graph more zoomed in svgGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); // Better centered positioning @@ -2668,7 +2668,7 @@ class TopologyGraphComponent extends Component { // Add legend this.addLegend(svgGroup); } catch (error) { - console.error('Failed to render graph:', error); + logger.error('Failed to render graph:', error); } } @@ -2835,25 +2835,25 @@ class TopologyGraphComponent extends Component { } handleRefresh() { - console.log('TopologyGraphComponent: handleRefresh called'); + logger.debug('TopologyGraphComponent: handleRefresh called'); if (!this.isInitialized) { - console.log('TopologyGraphComponent: Component not yet initialized, ensuring initialization...'); + logger.debug('TopologyGraphComponent: Component not yet initialized, ensuring initialization...'); this.ensureInitialized().then(() => { // Refresh after initialization this.viewModel.updateNetworkTopology(); }).catch(error => { - console.error('TopologyGraphComponent: Failed to initialize for refresh:', error); + logger.error('TopologyGraphComponent: Failed to initialize for refresh:', error); }); return; } - console.log('TopologyGraphComponent: Calling updateNetworkTopology...'); + logger.debug('TopologyGraphComponent: Calling updateNetworkTopology...'); this.viewModel.updateNetworkTopology(); } handleLoadingState(isLoading) { - console.log('TopologyGraphComponent: handleLoadingState called with:', isLoading); + logger.debug('TopologyGraphComponent: handleLoadingState called with:', isLoading); const container = this.findElement('#topology-graph-container'); if (isLoading) { @@ -2922,18 +2922,18 @@ class TopologyGraphComponent extends Component { // Override render method to display the graph render() { - console.log('TopologyGraphComponent: render called'); + logger.debug('TopologyGraphComponent: render called'); if (!this.isInitialized) { - console.log('TopologyGraphComponent: Not initialized yet, skipping render'); + logger.debug('TopologyGraphComponent: Not initialized yet, skipping render'); return; } const nodes = this.viewModel.get('nodes'); const links = this.viewModel.get('links'); if (nodes && nodes.length > 0) { - console.log('TopologyGraphComponent: Rendering graph with data'); + logger.debug('TopologyGraphComponent: Rendering graph with data'); this.renderGraph(); } else { - console.log('TopologyGraphComponent: No data available, showing loading state'); + logger.debug('TopologyGraphComponent: No data available, showing loading state'); this.handleLoadingState(true); } } @@ -3113,7 +3113,7 @@ class MemberCardOverlayComponent extends Component { card.classList.add('expanded'); } catch (error) { - console.error('Failed to expand member card:', error); + logger.error('Failed to expand member card:', error); // Still show the UI even if details fail to load card.classList.add('expanded'); const details = card.querySelector('.member-details'); From 83d252f3cc80186291a5f774d3c64a503a4f8836 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 12:20:58 +0200 Subject: [PATCH 09/18] chore: remove obsolete docs --- docs/STATE_PRESERVATION.md | 266 ----------------------------------- docs/TOPOLOGY_VIEW.md | 146 ------------------- docs/VIEW_SWITCHING_FIXES.md | 223 ----------------------------- 3 files changed, 635 deletions(-) delete mode 100644 docs/STATE_PRESERVATION.md delete mode 100644 docs/TOPOLOGY_VIEW.md delete mode 100644 docs/VIEW_SWITCHING_FIXES.md diff --git a/docs/STATE_PRESERVATION.md b/docs/STATE_PRESERVATION.md deleted file mode 100644 index 18b9577..0000000 --- a/docs/STATE_PRESERVATION.md +++ /dev/null @@ -1,266 +0,0 @@ -# SPORE UI State Preservation System - -## Overview - -The SPORE UI framework now includes an advanced state preservation system that prevents UI state loss during data refreshes. This system ensures that user interactions like expanded cards, active tabs, and other UI state are maintained when data is updated from the server. - -## Key Features - -### 1. **UI State Persistence** -- **Expanded Cards**: When cluster member cards are expanded, their state is preserved across data refreshes -- **Active Tabs**: Active tab selections within node detail views are maintained -- **User Interactions**: All user-initiated UI changes are stored and restored automatically - -### 2. **Smart Data Updates** -- **Change Detection**: The system detects when data has actually changed and only updates what's necessary -- **Partial Updates**: Components can update specific data without re-rendering the entire UI -- **State Preservation**: UI state is automatically preserved during all data operations - -### 3. **Efficient Rendering** -- **No Full Re-renders**: Components avoid unnecessary full re-renders when only data changes -- **Granular Updates**: Only changed properties trigger UI updates -- **Performance Optimization**: Reduced DOM manipulation and improved user experience - -## Architecture - -### Enhanced ViewModel Class - -The base `ViewModel` class now includes: - -```javascript -class ViewModel { - // UI State Management - setUIState(key, value) // Store UI state - getUIState(key) // Retrieve UI state - getAllUIState() // Get all stored UI state - clearUIState(key) // Clear specific or all UI state - - // Change Detection - hasChanged(property) // Check if property changed - getPrevious(property) // Get previous value - - // Batch Updates - batchUpdate(updates, options) // Update multiple properties with state preservation -} -``` - -### Enhanced Component Class - -The base `Component` class now includes: - -```javascript -class Component { - // UI State Management - setUIState(key, value) // Store local UI state - getUIState(key) // Get local or view model state - getAllUIState() // Get merged state - restoreUIState() // Restore state from view model - - // Partial Updates - updatePartial(property, newValue, previousValue) // Handle partial updates -} -``` - -## Implementation Examples - -### 1. **Cluster Members Component** - -The `ClusterMembersComponent` demonstrates state preservation: - -```javascript -class ClusterMembersComponent extends Component { - setupViewModelListeners() { - // Listen with change detection - this.subscribeToProperty('members', this.handleMembersUpdate.bind(this)); - } - - handleMembersUpdate(newMembers, previousMembers) { - if (this.shouldPreserveState(newMembers, previousMembers)) { - // Partial update preserves UI state - this.updateMembersPartially(newMembers, previousMembers); - } else { - // Full re-render only when necessary - this.render(); - } - } - - shouldPreserveState(newMembers, previousMembers) { - // Check if member structure allows state preservation - if (newMembers.length !== previousMembers.length) return false; - - const newIps = new Set(newMembers.map(m => m.ip)); - const prevIps = new Set(previousMembers.map(m => m.ip)); - - return newIps.size === prevIps.size && - [...newIps].every(ip => prevIps.has(ip)); - } -} -``` - -### 2. **Node Details Component** - -The `NodeDetailsComponent` preserves active tab state: - -```javascript -class NodeDetailsComponent extends Component { - setupViewModelListeners() { - this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this)); - } - - handleActiveTabUpdate(newTab, previousTab) { - // Update tab UI without full re-render - this.updateActiveTab(newTab, previousTab); - } - - updateActiveTab(newTab) { - // Update only the tab UI, preserving other state - const tabButtons = this.findAllElements('.tab-button'); - const tabContents = this.findAllElements('.tab-content'); - - tabButtons.forEach(btn => btn.classList.remove('active')); - tabContents.forEach(content => content.classList.remove('active')); - - const activeButton = this.findElement(`[data-tab="${newTab}"]`); - const activeContent = this.findElement(`#${newTab}-tab`); - - if (activeButton) activeButton.classList.add('active'); - if (activeContent) activeContent.classList.add('active'); - } -} -``` - -## Usage Patterns - -### 1. **Storing UI State** - -```javascript -// In a component -this.setUIState('expandedCard', memberIp); -this.setUIState('activeTab', 'firmware'); - -// In a view model -this.setUIState('userPreferences', { theme: 'dark', layout: 'compact' }); -``` - -### 2. **Retrieving UI State** - -```javascript -// Get specific state -const expandedCard = this.getUIState('expandedCard'); -const activeTab = this.getUIState('activeTab'); - -// Get all state -const allState = this.getAllUIState(); -``` - -### 3. **Batch Updates with State Preservation** - -```javascript -// Update data while preserving UI state -this.viewModel.batchUpdate({ - members: newMembers, - lastUpdateTime: new Date().toISOString() -}, { preserveUIState: true }); -``` - -### 4. **Smart Updates** - -```javascript -// Use smart update to preserve state -await this.viewModel.smartUpdate(); -``` - -## Benefits - -### 1. **Improved User Experience** -- Users don't lose their place in the interface -- Expanded cards remain expanded -- Active tabs stay selected -- No jarring UI resets - -### 2. **Better Performance** -- Reduced unnecessary DOM manipulation -- Efficient partial updates -- Optimized rendering cycles - -### 3. **Maintainable Code** -- Clear separation of concerns -- Consistent state management patterns -- Easy to extend and modify - -## Testing - -Use the `test-state-preservation.html` file to test the state preservation system: - -1. **Expand cluster member cards** -2. **Change active tabs in node details** -3. **Trigger data refresh** -4. **Verify state is preserved** - -## Migration Guide - -### From Old System - -If you're upgrading from the old system: - -1. **Update ViewModel Listeners**: Change from `this.render.bind(this)` to specific update handlers -2. **Add State Management**: Use `setUIState()` and `getUIState()` for UI state -3. **Implement Partial Updates**: Override `updatePartial()` method for efficient updates -4. **Use Smart Updates**: Replace direct data updates with `smartUpdate()` calls - -### Example Migration - -**Old Code:** -```javascript -this.subscribeToProperty('members', this.render.bind(this)); - -async handleRefresh() { - await this.viewModel.updateClusterMembers(); -} -``` - -**New Code:** -```javascript -this.subscribeToProperty('members', this.handleMembersUpdate.bind(this)); - -async handleRefresh() { - await this.viewModel.smartUpdate(); -} - -handleMembersUpdate(newMembers, previousMembers) { - if (this.shouldPreserveState(newMembers, previousMembers)) { - this.updateMembersPartially(newMembers, previousMembers); - } else { - this.render(); - } -} -``` - -## Best Practices - -1. **Always Store UI State**: Use `setUIState()` for any user interaction -2. **Implement Partial Updates**: Override `updatePartial()` for efficient updates -3. **Use Change Detection**: Leverage `hasChanged()` to avoid unnecessary updates -4. **Batch Related Updates**: Use `batchUpdate()` for multiple property changes -5. **Test State Preservation**: Verify that UI state is maintained during data refreshes - -## Troubleshooting - -### Common Issues - -1. **State Not Preserved**: Ensure you're using `setUIState()` and `getUIState()` -2. **Full Re-renders**: Check if `shouldPreserveState()` logic is correct -3. **Performance Issues**: Verify you're using partial updates instead of full renders - -### Debug Tips - -1. **Enable Console Logging**: Check browser console for state preservation logs -2. **Use State Indicators**: Monitor state changes in the test interface -3. **Verify Change Detection**: Ensure `hasChanged()` is working correctly - -## Future Enhancements - -- **State Synchronization**: Real-time state sync across multiple browser tabs -- **Advanced Change Detection**: Deep object comparison for complex data structures -- **State Persistence**: Save UI state to localStorage for session persistence -- **State Rollback**: Ability to revert to previous UI states \ No newline at end of file diff --git a/docs/TOPOLOGY_VIEW.md b/docs/TOPOLOGY_VIEW.md deleted file mode 100644 index f615b90..0000000 --- a/docs/TOPOLOGY_VIEW.md +++ /dev/null @@ -1,146 +0,0 @@ -# Topology View - Network Topology Visualization - -## Overview - -The Topology view provides an interactive, force-directed graph visualization of the SPORE cluster network topology. It displays each cluster member as a node and shows the connections (links) between them with latency information. - -## Features - -### 🎯 **Interactive Network Graph** -- **Force-directed layout**: Nodes automatically arrange themselves based on connections -- **Zoom and pan**: Navigate through large network topologies -- **Drag and drop**: Reposition nodes manually for better visualization -- **Responsive design**: Adapts to different screen sizes - -### 📊 **Node Information** -- **Status indicators**: Color-coded nodes based on member status (ACTIVE, INACTIVE, DEAD) -- **Hostname display**: Shows the human-readable name of each node -- **IP addresses**: Displays the network address of each member -- **Resource information**: Access to system resources and capabilities - -### 🔗 **Connection Visualization** -- **Latency display**: Shows network latency between connected nodes -- **Color-coded links**: Different colors indicate latency ranges: - - 🟢 Green: ≤5ms (excellent) - - 🟠 Orange: 6-15ms (good) - - 🔴 Red-orange: 16-30ms (fair) - - 🔴 Red: >30ms (poor) -- **Bidirectional connections**: Shows actual network topology from each node's perspective - -### 🎨 **Visual Enhancements** -- **Legend**: Explains node status colors and latency ranges -- **Hover effects**: Interactive feedback when hovering over nodes and links -- **Selection highlighting**: Click nodes to select and highlight them -- **Smooth animations**: Force simulation provides natural movement - -## Technical Implementation - -### Architecture -- **ViewModel**: `TopologyViewModel` manages data and state -- **Component**: `TopologyGraphComponent` handles rendering and interactions -- **Framework**: Integrates with the existing SPORE UI framework -- **Library**: Uses D3.js v7 for graph visualization - -### Data Flow -1. **Primary node query**: Fetches cluster members from the primary node -2. **Individual node queries**: Gets cluster view from each member node -3. **Topology building**: Constructs network graph from actual connections -4. **Fallback mesh**: Creates basic mesh if no actual connections found - -### API Endpoints -- `/api/cluster/members` - Get cluster membership from primary node -- `/api/cluster/members?ip={nodeIP}` - Get cluster view from specific node - -## Usage - -### Navigation -1. Click the "🌐 Topology" tab in the main navigation -2. The view automatically loads and displays the network topology -3. Use the refresh button to update the visualization - -### Interaction -- **Zoom**: Use mouse wheel or pinch gestures -- **Pan**: Click and drag on empty space -- **Select nodes**: Click on any node to highlight it -- **Move nodes**: Drag nodes to reposition them -- **Hover**: Hover over nodes and links for additional information - -### Refresh -- Click the "Refresh" button to reload network topology data -- Useful after network changes or when adding/removing nodes - -## Configuration - -### Graph Parameters -- **Node spacing**: 120px between connected nodes -- **Repulsion force**: -400 strength for node separation -- **Collision radius**: 40px minimum distance between nodes -- **Zoom limits**: 0.1x to 4x zoom range - -### Visual Settings -- **Node sizes**: Vary based on status (ACTIVE: 10px, INACTIVE: 8px, DEAD: 6px) -- **Link thickness**: Proportional to latency (2-8px range) -- **Colors**: Semantic color scheme for status and latency - -## Troubleshooting - -### Common Issues - -#### No Graph Displayed -- Check browser console for JavaScript errors -- Verify D3.js library is loading correctly -- Ensure cluster has discovered nodes - -#### Missing Connections -- Verify nodes are responding to API calls -- Check network connectivity between nodes -- Review cluster discovery configuration - -#### Performance Issues -- Reduce number of displayed nodes -- Adjust force simulation parameters -- Use zoom to focus on specific areas - -### Debug Information -- Test file available at `test-topology-view.html` -- Console logging provides detailed component lifecycle information -- Network topology data is logged during updates - -## Future Enhancements - -### Planned Features -- **Real-time updates**: WebSocket integration for live topology changes -- **Metrics overlay**: CPU, memory, and network usage display -- **Path finding**: Show routes between specific nodes -- **Export options**: Save graph as image or data file -- **Custom layouts**: Alternative visualization algorithms - -### Performance Optimizations -- **Lazy loading**: Load node details on demand -- **Virtualization**: Handle large numbers of nodes efficiently -- **Caching**: Store topology data locally -- **Web Workers**: Offload computation to background threads - -## Dependencies - -- **D3.js v7**: Force-directed graph visualization -- **SPORE UI Framework**: Component architecture and state management -- **Modern Browser**: ES6+ support required -- **Network Access**: Ability to reach cluster nodes - -## Browser Support - -- **Chrome**: 80+ (recommended) -- **Firefox**: 75+ -- **Safari**: 13+ -- **Edge**: 80+ - -## Contributing - -To contribute to the Members view: - -1. Follow the existing code style and patterns -2. Test with different cluster configurations -3. Ensure responsive design works on mobile devices -4. Add appropriate error handling and logging -5. Update documentation for new features \ No newline at end of file diff --git a/docs/VIEW_SWITCHING_FIXES.md b/docs/VIEW_SWITCHING_FIXES.md deleted file mode 100644 index 0ad35e8..0000000 --- a/docs/VIEW_SWITCHING_FIXES.md +++ /dev/null @@ -1,223 +0,0 @@ -# View Switching Fixes for Member Card Issues - -## Problem Description - -When switching between the cluster and firmware views, member cards were experiencing: -- **Wrong UI state**: Expanded cards, active tabs, and other UI state was being lost -- **Flickering**: Visual glitches and rapid re-rendering during view switches -- **Broken functionality**: Member cards not working properly after view switches -- **Inefficient rendering**: Components were completely unmounted and remounted on every view switch -- **Incorrect state restoration**: UI state was incorrectly restored on first load (all cards expanded, wrong tabs active) - -## Root Causes Identified - -1. **Aggressive DOM Manipulation**: Complete component unmounting/remounting on every view switch -2. **Race Conditions**: Multiple async operations and timeouts interfering with each other -3. **State Loss**: UI state not properly preserved across view switches -4. **Rapid Navigation**: Multiple rapid clicks could cause navigation conflicts -5. **CSS Transition Conflicts**: Multiple transitions causing visual flickering -6. **No Component Caching**: Every view switch created new component instances -7. **Complex State Restoration**: Attempting to restore UI state caused incorrect behavior on first load - -## Fixes Implemented - -### 1. **Component Caching System** (`framework.js`) - -- **Component Cache**: Components are created once and cached, never re-created -- **Pause/Resume Pattern**: Components are paused (not unmounted) when switching away -- **Pre-initialization**: Components are created during route registration for better performance -- **Simple Show/Hide**: Components are just shown/hidden without touching UI state - -### 2. **Enhanced Navigation System** (`framework.js`) - -- **Debounced Navigation**: Added 300ms cooldown between navigation requests -- **Navigation Queue**: Queues navigation requests when one is already in progress -- **Smooth Transitions**: Added opacity transitions to prevent abrupt view changes -- **No Component Destruction**: Components are kept alive and just paused/resumed - -### 3. **Simplified State Management** (`view-models.js`) - -- **No UI State Persistence**: Removed complex localStorage state restoration -- **Clean State on Load**: Components start with default state (collapsed cards, status tab) -- **No State Corruption**: Eliminates incorrect state restoration on first load - -### 4. **Enhanced Component Lifecycle** (`components.js`) - -- **Pause/Resume Methods**: Components can be paused and resumed without losing state -- **Default State**: Member cards always start collapsed, tabs start on 'status' -- **No State Restoration**: Components maintain their current state without external interference -- **Render Guards**: Prevents multiple simultaneous render operations -- **View Switch Detection**: Skips rendering during view transitions -- **Improved Unmounting**: Better cleanup of timeouts and event listeners -- **State Tracking**: Tracks if data has already been loaded to prevent unnecessary reloads - -### 5. **CSS Improvements** (`styles.css`) - -- **Smooth Transitions**: Added fade-in/fade-out animations for view switching -- **Reduced Transition Times**: Shortened member card transitions from 0.3s to 0.2s -- **Better Animations**: Improved expand/collapse animations for member cards -- **Loading States**: Added fade-in animations for loading, error, and empty states - -### 6. **View Model Enhancements** - -- **Smart Updates**: Only updates changed data to minimize re-renders -- **Change Detection**: Compares data before triggering updates -- **Clean Initialization**: No complex state restoration logic - -## Technical Details - -### Component Caching Flow - -1. **Route Registration**: Components are created and cached during app initialization -2. **Navigation**: When switching views, current component is paused (not unmounted) -3. **State Preservation**: All component state, DOM, and event listeners remain intact -4. **Resume**: When returning to a view, component is resumed from paused state -5. **No Re-rendering**: Components maintain their exact state and appearance -6. **Simple Show/Hide**: No complex state restoration, just show/hide components - -### Pause/Resume Pattern - -```javascript -// Component is paused instead of unmounted -onPause() { - // Clear timers, pause operations - // Component state and DOM remain intact -} - -onResume() { - // Restore timers, resume operations - // No re-rendering needed -} -``` - -### Navigation Flow - -1. **Cooldown Check**: 300ms minimum between navigation requests -2. **Queue Management**: Multiple requests queued and processed sequentially -3. **Pause Current**: Current component paused (opacity: 0) -4. **Show New View**: New view becomes visible with fade-in animation -5. **Resume Component**: Cached component resumed from paused state -6. **No Unmounting**: Components are never destroyed during view switches -7. **No State Touch**: UI state is not modified during view switches - -### State Management - -- **Default State**: Member cards start collapsed, tabs start on 'status' -- **No Persistence**: No localStorage state restoration -- **Clean Initialization**: Components always start with predictable state -- **No State Corruption**: Eliminates incorrect state restoration issues - -### Render Optimization - -- **No Re-rendering**: Components maintain their exact state across view switches -- **Pause/Resume**: Components are paused instead of unmounted -- **State Persistence**: All UI state preserved in memory (not localStorage) -- **Change Detection**: Only updates changed data when resuming -- **Default Behavior**: Always starts with clean, predictable state - -## Testing - -Use the test page `test-view-switching.html` to verify fixes: - -1. **Rapid Switching Test**: Clicks navigation tabs rapidly to test cooldown -2. **State Preservation Test**: Expands cards, switches views, verifies state restoration -3. **Component Caching Test**: Verify components are not re-created on view switches -4. **Default State Test**: Verify components start with correct default state -5. **Console Monitoring**: Check console for detailed operation logs - -## Expected Results - -After implementing these fixes: - -- ✅ **No More Re-rendering**: Components are cached and never re-created -- ✅ **No More Flickering**: Smooth transitions between views -- ✅ **Correct Default State**: Member cards start collapsed, tabs start on 'status' -- ✅ **No State Corruption**: No incorrect state restoration on first load -- ✅ **Stable Navigation**: No more broken member cards after view switches -- ✅ **Better Performance**: No unnecessary component creation/destruction -- ✅ **Improved UX**: Smoother, more professional feel -- ✅ **Memory Efficiency**: Components reused instead of recreated -- ✅ **Predictable Behavior**: Components always start with clean state - -## Configuration - -### Navigation Cooldown -```javascript -this.navigationCooldown = 300; // 300ms between navigation requests -``` - -### Component Caching -```javascript -// Components are automatically cached during route registration -app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel); -``` - -### Transition Timing -```css -.view-content { - transition: opacity 0.2s ease-in-out; -} -``` - -### Member Card Transitions -```css -.member-card { - transition: all 0.2s ease; -} -``` - -## Architecture Benefits - -### 1. **Performance** -- No component recreation on view switches -- Faster view transitions -- Reduced memory allocation/deallocation - -### 2. **State Management** -- Clean, predictable default state -- No state corruption on first load -- Consistent user experience - -### 3. **Maintainability** -- Cleaner component lifecycle -- No complex state restoration logic -- Easier debugging and testing -- More predictable behavior - -### 4. **User Experience** -- No flickering or visual glitches -- Instant view switching -- Maintained user context -- Predictable component behavior - -## Key Changes Made - -### Removed Complex State Restoration -- ❌ `preserveUIState()` method -- ❌ `restoreUIState()` method -- ❌ localStorage state persistence -- ❌ Complex tab state restoration -- ❌ Expanded card state restoration - -### Simplified Component Behavior -- ✅ Components start with default state -- ✅ Member cards always start collapsed -- ✅ Tabs always start on 'status' -- ✅ No external state interference -- ✅ Clean, predictable initialization - -### Maintained Performance Benefits -- ✅ Component caching still works -- ✅ No re-rendering on view switches -- ✅ Smooth transitions -- ✅ Better memory efficiency - -## Future Improvements - -1. **Virtual Scrolling**: For large numbers of member cards -2. **Animation Preferences**: User-configurable transition speeds -3. **State Sync**: Real-time state synchronization across multiple tabs -4. **Performance Metrics**: Track and optimize render performance -5. **Lazy Loading**: Load components only when first accessed -6. **Memory Management**: Intelligent cache cleanup for unused components -7. **User Preferences**: Allow users to set default states if desired \ No newline at end of file From cc7fa0fa0065215f80002812ef98b689cfeb339f Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 14:00:33 +0200 Subject: [PATCH 10/18] refactor(components): split components.js into separate files and add loader; app waits for components before init --- public/index.html | 10 +- public/scripts/app.js | 11 +- .../components/ClusterMembersComponent.js | 628 ++++++++++++++ .../components/ClusterStatusComponent.js | 50 ++ .../components/ClusterViewComponent.js | 210 +++++ public/scripts/components/ComponentsLoader.js | 16 + .../scripts/components/FirmwareComponent.js | 698 +++++++++++++++ .../components/FirmwareViewComponent.js | 82 ++ .../components/NodeDetailsComponent.js | 529 ++++++++++++ .../components/PrimaryNodeComponent.js | 90 ++ .../components/TopologyGraphComponent.js | 802 ++++++++++++++++++ 11 files changed, 3124 insertions(+), 2 deletions(-) create mode 100644 public/scripts/components/ClusterMembersComponent.js create mode 100644 public/scripts/components/ClusterStatusComponent.js create mode 100644 public/scripts/components/ClusterViewComponent.js create mode 100644 public/scripts/components/ComponentsLoader.js create mode 100644 public/scripts/components/FirmwareComponent.js create mode 100644 public/scripts/components/FirmwareViewComponent.js create mode 100644 public/scripts/components/NodeDetailsComponent.js create mode 100644 public/scripts/components/PrimaryNodeComponent.js create mode 100644 public/scripts/components/TopologyGraphComponent.js diff --git a/public/index.html b/public/index.html index 0dffe3c..dafb6af 100644 --- a/public/index.html +++ b/public/index.html @@ -147,7 +147,15 @@ - + + + + + + + + + diff --git a/public/scripts/app.js b/public/scripts/app.js index fa86415..1ee982a 100644 --- a/public/scripts/app.js +++ b/public/scripts/app.js @@ -1,13 +1,22 @@ // Main SPORE UI Application // Initialize the application when DOM is loaded -document.addEventListener('DOMContentLoaded', function() { +document.addEventListener('DOMContentLoaded', async function() { logger.debug('=== SPORE UI Application Initialization ==='); // Initialize the framework (but don't navigate yet) logger.debug('App: Creating framework instance...'); const app = window.app; + // Wait for components to be ready (loader ensures constructors exist) + try { + if (typeof window.waitForComponentsReady === 'function') { + await window.waitForComponentsReady(); + } + } catch (e) { + logger.warn('App: Components loader timeout; proceeding anyway'); + } + // Create view models logger.debug('App: Creating view models...'); const clusterViewModel = new ClusterViewModel(); diff --git a/public/scripts/components/ClusterMembersComponent.js b/public/scripts/components/ClusterMembersComponent.js new file mode 100644 index 0000000..e6dcb14 --- /dev/null +++ b/public/scripts/components/ClusterMembersComponent.js @@ -0,0 +1,628 @@ +// Cluster Members Component with enhanced state preservation +class ClusterMembersComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + + logger.debug('ClusterMembersComponent: Constructor called'); + logger.debug('ClusterMembersComponent: Container:', container); + logger.debug('ClusterMembersComponent: Container ID:', container?.id); + logger.debug('ClusterMembersComponent: Container innerHTML:', container?.innerHTML); + + // Track if we're in the middle of a render operation + this.renderInProgress = false; + this.lastRenderData = null; + + // Ensure initial render happens even if no data + setTimeout(() => { + if (this.isMounted && !this.renderInProgress) { + logger.debug('ClusterMembersComponent: Performing initial render check'); + this.render(); + } + }, 200); + } + + mount() { + logger.debug('ClusterMembersComponent: Starting mount...'); + super.mount(); + + // Show loading state immediately when mounted + logger.debug('ClusterMembersComponent: Showing initial loading state'); + this.showLoadingState(); + + // Set up loading timeout safeguard + this.setupLoadingTimeout(); + + logger.debug('ClusterMembersComponent: Mounted successfully'); + } + + // Setup loading timeout safeguard to prevent getting stuck in loading state + setupLoadingTimeout() { + this.loadingTimeout = setTimeout(() => { + const isLoading = this.viewModel.get('isLoading'); + if (isLoading) { + logger.warn('ClusterMembersComponent: Loading timeout reached, forcing render check'); + this.forceRenderCheck(); + } + }, 10000); // 10 second timeout + } + + // Force a render check when loading gets stuck + forceRenderCheck() { + logger.debug('ClusterMembersComponent: Force render check called'); + const members = this.viewModel.get('members'); + const error = this.viewModel.get('error'); + const isLoading = this.viewModel.get('isLoading'); + + logger.debug('ClusterMembersComponent: Force render check state:', { members, error, isLoading }); + + if (error) { + this.showErrorState(error); + } else if (members && members.length > 0) { + this.renderMembers(members); + } else if (!isLoading) { + this.showEmptyState(); + } + } + + setupEventListeners() { + logger.debug('ClusterMembersComponent: Setting up event listeners...'); + // Note: Refresh button is now handled by ClusterViewComponent + // since it's in the cluster header, not in the members container + } + + setupViewModelListeners() { + logger.debug('ClusterMembersComponent: Setting up view model listeners...'); + // Listen to cluster members changes with change detection + this.subscribeToProperty('members', this.handleMembersUpdate.bind(this)); + this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this)); + this.subscribeToProperty('error', this.handleErrorUpdate.bind(this)); + logger.debug('ClusterMembersComponent: View model listeners set up'); + } + + // Handle members update with state preservation + handleMembersUpdate(newMembers, previousMembers) { + logger.debug('ClusterMembersComponent: Members updated:', { newMembers, previousMembers }); + + // Prevent multiple simultaneous renders + if (this.renderInProgress) { + logger.debug('ClusterMembersComponent: Render already in progress, skipping update'); + return; + } + + // Check if we're currently loading - if so, let the loading handler deal with it + const isLoading = this.viewModel.get('isLoading'); + if (isLoading) { + logger.debug('ClusterMembersComponent: Currently loading, skipping members update (will be handled by loading completion)'); + return; + } + + // On first load (no previous members), always render + if (!previousMembers || !Array.isArray(previousMembers) || previousMembers.length === 0) { + logger.debug('ClusterMembersComponent: First load or no previous members, performing full render'); + this.render(); + return; + } + + if (this.shouldPreserveState(newMembers, previousMembers)) { + // Perform partial update to preserve UI state + logger.debug('ClusterMembersComponent: Preserving state, performing partial update'); + this.updateMembersPartially(newMembers, previousMembers); + } else { + // Full re-render if structure changed significantly + logger.debug('ClusterMembersComponent: Structure changed, performing full re-render'); + this.render(); + } + } + + // Handle loading state update + handleLoadingUpdate(isLoading) { + logger.debug('ClusterMembersComponent: Loading state changed:', isLoading); + + if (isLoading) { + logger.debug('ClusterMembersComponent: Showing loading state'); + this.renderLoading(` +
+
Loading cluster members...
+
+ `); + + // Set up a loading completion check + this.checkLoadingCompletion(); + } else { + logger.debug('ClusterMembersComponent: Loading completed, checking if we need to render'); + // When loading completes, check if we have data to render + this.handleLoadingCompletion(); + } + } + + // Check if loading has completed and handle accordingly + handleLoadingCompletion() { + const members = this.viewModel.get('members'); + const error = this.viewModel.get('error'); + const isLoading = this.viewModel.get('isLoading'); + + logger.debug('ClusterMembersComponent: Handling loading completion:', { members, error, isLoading }); + + if (error) { + logger.debug('ClusterMembersComponent: Loading completed with error, showing error state'); + this.showErrorState(error); + } else if (members && members.length > 0) { + logger.debug('ClusterMembersComponent: Loading completed with data, rendering members'); + this.renderMembers(members); + } else if (!isLoading) { + logger.debug('ClusterMembersComponent: Loading completed but no data, showing empty state'); + this.showEmptyState(); + } + } + + // Set up a check to ensure loading completion is handled + checkLoadingCompletion() { + // Clear any existing completion check + if (this.loadingCompletionCheck) { + clearTimeout(this.loadingCompletionCheck); + } + + // Set up a completion check that runs after a short delay + this.loadingCompletionCheck = setTimeout(() => { + const isLoading = this.viewModel.get('isLoading'); + if (!isLoading) { + logger.debug('ClusterMembersComponent: Loading completion check triggered'); + this.handleLoadingCompletion(); + } + }, 1000); // Check after 1 second + } + + // Handle error state update + handleErrorUpdate(error) { + if (error) { + this.showErrorState(error); + } + } + + // Check if we should preserve UI state during update + shouldPreserveState(newMembers, previousMembers) { + if (!previousMembers || !Array.isArray(previousMembers)) return false; + if (!Array.isArray(newMembers)) return false; + + // If member count changed, we need to re-render + if (newMembers.length !== previousMembers.length) return false; + + // Check if member IPs are the same (same nodes) + const newIps = new Set(newMembers.map(m => m.ip)); + const prevIps = new Set(previousMembers.map(m => m.ip)); + + // If IPs are the same, we can preserve state + return newIps.size === prevIps.size && + [...newIps].every(ip => prevIps.has(ip)); + } + + // Check if we should skip rendering during view switches + shouldSkipRender() { + // Rely on lifecycle flags controlled by App + if (!this.isMounted || this.isPaused) { + logger.debug('ClusterMembersComponent: Not mounted or paused, skipping render'); + return true; + } + return false; + } + + // Update members partially to preserve UI state + updateMembersPartially(newMembers, previousMembers) { + logger.debug('ClusterMembersComponent: Performing partial update to preserve UI state'); + + // Build previous map by IP for stable diffs + const prevByIp = new Map((previousMembers || []).map(m => [m.ip, m])); + newMembers.forEach((newMember) => { + const prevMember = prevByIp.get(newMember.ip); + if (prevMember && this.hasMemberChanged(newMember, prevMember)) { + this.updateMemberCard(newMember); + } + }); + } + + // Check if a specific member has changed + hasMemberChanged(newMember, prevMember) { + return newMember.status !== prevMember.status || + newMember.latency !== prevMember.latency || + newMember.hostname !== prevMember.hostname; + } + + // Update a specific member card without re-rendering the entire component + updateMemberCard(member) { + const card = this.findElement(`[data-member-ip="${member.ip}"]`); + if (!card) return; + + // Update status + const statusElement = card.querySelector('.member-status'); + if (statusElement) { + const statusClass = member.status === 'active' ? 'status-online' : 'status-offline'; + const statusIcon = member.status === 'active' ? '🟢' : '🔴'; + + statusElement.className = `member-status ${statusClass}`; + statusElement.innerHTML = `${statusIcon}`; + } + + // Update latency + const latencyElement = card.querySelector('.latency-value'); + if (latencyElement) { + latencyElement.textContent = member.latency ? member.latency + 'ms' : 'N/A'; + } + + // Update hostname if changed + const hostnameElement = card.querySelector('.member-hostname'); + if (hostnameElement && member.hostname !== hostnameElement.textContent) { + hostnameElement.textContent = member.hostname || 'Unknown Device'; + } + } + + render() { + if (this.renderInProgress) { + logger.debug('ClusterMembersComponent: Render already in progress, skipping'); + return; + } + + // Check if we should skip rendering during view switches + if (this.shouldSkipRender()) { + return; + } + + this.renderInProgress = true; + + try { + logger.debug('ClusterMembersComponent: render() called'); + logger.debug('ClusterMembersComponent: Container element:', this.container); + logger.debug('ClusterMembersComponent: Is mounted:', this.isMounted); + + const members = this.viewModel.get('members'); + const isLoading = this.viewModel.get('isLoading'); + const error = this.viewModel.get('error'); + + logger.debug('ClusterMembersComponent: render data:', { members, isLoading, error }); + + if (isLoading) { + logger.debug('ClusterMembersComponent: Showing loading state'); + this.showLoadingState(); + return; + } + + if (error) { + logger.debug('ClusterMembersComponent: Showing error state'); + this.showErrorState(error); + return; + } + + if (!members || members.length === 0) { + logger.debug('ClusterMembersComponent: Showing empty state'); + this.showEmptyState(); + return; + } + + logger.debug('ClusterMembersComponent: Rendering members:', members); + this.renderMembers(members); + + } finally { + this.renderInProgress = false; + } + } + + // Show loading state + showLoadingState() { + logger.debug('ClusterMembersComponent: showLoadingState() called'); + this.renderLoading(` +
+
Loading cluster members...
+
+ `); + } + + // Show error state + showErrorState(error) { + logger.debug('ClusterMembersComponent: showErrorState() called with error:', error); + this.renderError(`Error loading cluster members: ${error}`); + } + + // Show empty state + showEmptyState() { + logger.debug('ClusterMembersComponent: showEmptyState() called'); + this.renderEmpty(` +
+
🌐
+
No cluster members found
+
+ The cluster might be empty or not yet discovered +
+
+ `); + } + + renderMembers(members) { + logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members'); + + const membersHTML = members.map(member => { + const statusClass = member.status === 'active' ? 'status-online' : 'status-offline'; + const statusText = member.status === 'active' ? 'Online' : 'Offline'; + const statusIcon = member.status === 'active' ? '🟢' : '🔴'; + + logger.debug('ClusterMembersComponent: Rendering member:', member); + + return ` +
+
+
+
+
+
+ ${statusIcon} +
+
${this.escapeHtml(member.hostname || 'Unknown Device')}
+
+
${this.escapeHtml(member.ip || 'No IP')}
+
+ Latency: + ${member.latency ? member.latency + 'ms' : 'N/A'} +
+
+ ${member.labels && Object.keys(member.labels).length ? ` +
+
+ ${Object.entries(member.labels).map(([key, value]) => `${this.escapeHtml(key)}: ${this.escapeHtml(value)}`).join('')} +
+
+ ` : ''} +
+
+ + + +
+
+
+
Loading detailed information...
+
+
+ `; + }).join(''); + + logger.debug('ClusterMembersComponent: Setting HTML, length:', membersHTML.length); + this.setHTML('', membersHTML); + logger.debug('ClusterMembersComponent: HTML set, setting up member cards...'); + this.setupMemberCards(members); + } + + setupMemberCards(members) { + setTimeout(() => { + this.findAllElements('.member-card').forEach((card, index) => { + const expandIcon = card.querySelector('.expand-icon'); + const memberDetails = card.querySelector('.member-details'); + const memberIp = card.dataset.memberIp; + + // Ensure all cards start collapsed by default + card.classList.remove('expanded'); + if (expandIcon) { + expandIcon.classList.remove('expanded'); + } + + // Clear any previous content + memberDetails.innerHTML = '
Loading detailed information...
'; + + // Make the entire card clickable + this.addEventListener(card, 'click', async (e) => { + if (e.target === expandIcon) return; + + const isExpanding = !card.classList.contains('expanded'); + + if (isExpanding) { + await this.expandCard(card, memberIp, memberDetails); + } else { + this.collapseCard(card, expandIcon); + } + }); + + // Keep the expand icon click handler for visual feedback + if (expandIcon) { + this.addEventListener(expandIcon, 'click', async (e) => { + e.stopPropagation(); + + const isExpanding = !card.classList.contains('expanded'); + + if (isExpanding) { + await this.expandCard(card, memberIp, memberDetails); + } else { + this.collapseCard(card, expandIcon); + } + }); + } + }); + }, 100); + } + + async expandCard(card, memberIp, memberDetails) { + try { + // Create node details view model and component + const nodeDetailsVM = new NodeDetailsViewModel(); + const nodeDetailsComponent = new NodeDetailsComponent(memberDetails, nodeDetailsVM, this.eventBus); + + // Load node details + await nodeDetailsVM.loadNodeDetails(memberIp); + + // Mount the component + nodeDetailsComponent.mount(); + + // Update UI + card.classList.add('expanded'); + const expandIcon = card.querySelector('.expand-icon'); + if (expandIcon) { + expandIcon.classList.add('expanded'); + } + + } catch (error) { + logger.error('Failed to expand card:', error); + memberDetails.innerHTML = ` +
+ Error loading node details:
+ ${error.message} +
+ `; + } + } + + collapseCard(card, expandIcon) { + card.classList.remove('expanded'); + if (expandIcon) { + expandIcon.classList.remove('expanded'); + } + } + + setupTabs(container) { + super.setupTabs(container, { + onChange: (targetTab) => { + const memberCard = container.closest('.member-card'); + if (memberCard) { + const memberIp = memberCard.dataset.memberIp; + this.viewModel.storeActiveTab(memberIp, targetTab); + } + } + }); + } + + // Restore active tab state + restoreActiveTab(container, activeTab) { + const tabButtons = container.querySelectorAll('.tab-button'); + const tabContents = container.querySelectorAll('.tab-content'); + + // 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 the restored tab + const activeButton = container.querySelector(`[data-tab="${activeTab}"]`); + const activeContent = container.querySelector(`#${activeTab}-tab`); + + if (activeButton) activeButton.classList.add('active'); + if (activeContent) activeContent.classList.add('active'); + } + + // Note: handleRefresh method has been moved to ClusterViewComponent + // since the refresh button is in the cluster header, not in the members container + + // Debug method to check component state + debugState() { + const members = this.viewModel.get('members'); + const isLoading = this.viewModel.get('isLoading'); + const error = this.viewModel.get('error'); + const expandedCards = this.viewModel.get('expandedCards'); + const activeTabs = this.viewModel.get('activeTabs'); + + logger.debug('ClusterMembersComponent: Debug State:', { + isMounted: this.isMounted, + container: this.container, + members: members, + membersCount: members?.length || 0, + isLoading: isLoading, + error: error, + expandedCardsCount: expandedCards?.size || 0, + activeTabsCount: activeTabs?.size || 0, + loadingTimeout: this.loadingTimeout + }); + + return { members, isLoading, error, expandedCards, activeTabs }; + } + + // Manual refresh method that bypasses potential state conflicts + async manualRefresh() { + logger.debug('ClusterMembersComponent: Manual refresh called'); + + try { + // Clear any existing loading state + this.viewModel.set('isLoading', false); + this.viewModel.set('error', null); + + // Force a fresh data load + await this.viewModel.updateClusterMembers(); + + logger.debug('ClusterMembersComponent: Manual refresh completed'); + } catch (error) { + logger.error('ClusterMembersComponent: Manual refresh failed:', error); + this.showErrorState(error.message); + } + } + + unmount() { + if (!this.isMounted) return; + + this.isMounted = false; + + // Clear any pending timeouts + if (this.loadingTimeout) { + clearTimeout(this.loadingTimeout); + this.loadingTimeout = null; + } + + if (this.loadingCompletionCheck) { + clearTimeout(this.loadingCompletionCheck); + this.loadingCompletionCheck = null; + } + + // Clear any pending render operations + this.renderInProgress = false; + + this.cleanupEventListeners(); + this.cleanupViewModelListeners(); + + logger.debug(`${this.constructor.name} unmounted`); + } + + // Override pause method to handle timeouts and operations + onPause() { + logger.debug('ClusterMembersComponent: Pausing...'); + + // Clear any pending timeouts + if (this.loadingTimeout) { + clearTimeout(this.loadingTimeout); + this.loadingTimeout = null; + } + + if (this.loadingCompletionCheck) { + clearTimeout(this.loadingCompletionCheck); + this.loadingCompletionCheck = null; + } + + // Mark as paused to prevent new operations + this.isPaused = true; + } + + // Override resume method to restore functionality + onResume() { + logger.debug('ClusterMembersComponent: Resuming...'); + + this.isPaused = false; + + // Re-setup loading timeout if needed + if (!this.loadingTimeout) { + this.setupLoadingTimeout(); + } + + // Check if we need to handle any pending operations + this.checkPendingOperations(); + } + + // Check for any operations that need to be handled after resume + checkPendingOperations() { + const isLoading = this.viewModel.get('isLoading'); + const members = this.viewModel.get('members'); + + // If we were loading and it completed while paused, handle the completion + if (!isLoading && members && members.length > 0) { + logger.debug('ClusterMembersComponent: Handling pending loading completion after resume'); + this.handleLoadingCompletion(); + } + } + + // Override to determine if re-render is needed on resume + shouldRenderOnResume() { + // Don't re-render on resume - maintain current state + return false; + } +} + +window.ClusterMembersComponent = ClusterMembersComponent; \ No newline at end of file diff --git a/public/scripts/components/ClusterStatusComponent.js b/public/scripts/components/ClusterStatusComponent.js new file mode 100644 index 0000000..f7b964a --- /dev/null +++ b/public/scripts/components/ClusterStatusComponent.js @@ -0,0 +1,50 @@ +// Cluster Status Component for header badge +class ClusterStatusComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + } + + setupViewModelListeners() { + // Subscribe to properties that affect cluster status + this.subscribeToProperty('totalNodes', this.render.bind(this)); + this.subscribeToProperty('clientInitialized', this.render.bind(this)); + this.subscribeToProperty('error', this.render.bind(this)); + } + + render() { + const totalNodes = this.viewModel.get('totalNodes'); + const clientInitialized = this.viewModel.get('clientInitialized'); + const error = this.viewModel.get('error'); + + let statusText, statusIcon, statusClass; + + if (error) { + statusText = 'Cluster Error'; + statusIcon = '❌'; + statusClass = 'cluster-status-error'; + } else if (totalNodes === 0) { + statusText = 'Cluster Offline'; + statusIcon = '🔴'; + statusClass = 'cluster-status-offline'; + } else if (clientInitialized) { + statusText = 'Cluster Online'; + statusIcon = '🟢'; + statusClass = 'cluster-status-online'; + } else { + statusText = 'Cluster Connecting'; + statusIcon = '🟡'; + statusClass = 'cluster-status-connecting'; + } + + // Update the cluster status badge using the container passed to this component + if (this.container) { + this.container.innerHTML = `${statusIcon} ${statusText}`; + + // Remove all existing status classes + this.container.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error'); + + // Add the appropriate status class + this.container.classList.add(statusClass); + } + } +} \ No newline at end of file diff --git a/public/scripts/components/ClusterViewComponent.js b/public/scripts/components/ClusterViewComponent.js new file mode 100644 index 0000000..1164b02 --- /dev/null +++ b/public/scripts/components/ClusterViewComponent.js @@ -0,0 +1,210 @@ +// Cluster View Component +class ClusterViewComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + + logger.debug('ClusterViewComponent: Constructor called'); + logger.debug('ClusterViewComponent: Container:', container); + logger.debug('ClusterViewComponent: Container ID:', container?.id); + + // Find elements for sub-components + const primaryNodeContainer = this.findElement('.primary-node-info'); + const clusterMembersContainer = this.findElement('#cluster-members-container'); + + logger.debug('ClusterViewComponent: Primary node container:', primaryNodeContainer); + logger.debug('ClusterViewComponent: Cluster members container:', clusterMembersContainer); + logger.debug('ClusterViewComponent: Cluster members container ID:', clusterMembersContainer?.id); + logger.debug('ClusterViewComponent: Cluster members container innerHTML:', clusterMembersContainer?.innerHTML); + + // Create sub-components + this.primaryNodeComponent = new PrimaryNodeComponent( + primaryNodeContainer, + viewModel, + eventBus + ); + + this.clusterMembersComponent = new ClusterMembersComponent( + clusterMembersContainer, + viewModel, + eventBus + ); + + logger.debug('ClusterViewComponent: Sub-components created'); + + // Track if we've already loaded data to prevent unnecessary reloads + this.dataLoaded = false; + } + + mount() { + logger.debug('ClusterViewComponent: Mounting...'); + super.mount(); + + logger.debug('ClusterViewComponent: Mounting sub-components...'); + // Mount sub-components + this.primaryNodeComponent.mount(); + this.clusterMembersComponent.mount(); + + // Set up refresh button event listener (since it's in the cluster header, not in the members container) + this.setupRefreshButton(); + + // Only load data if we haven't already or if the view model is empty + const members = this.viewModel.get('members'); + const shouldLoadData = !this.dataLoaded || !members || members.length === 0; + + if (shouldLoadData) { + logger.debug('ClusterViewComponent: Starting initial data load...'); + // Initial data load - ensure it happens after mounting + setTimeout(() => { + this.viewModel.updateClusterMembers().then(() => { + this.dataLoaded = true; + }).catch(error => { + logger.error('ClusterViewComponent: Failed to load initial data:', error); + }); + }, 100); + } else { + logger.debug('ClusterViewComponent: Data already loaded, skipping initial load'); + } + + // Set up periodic updates + // this.setupPeriodicUpdates(); // Disabled automatic refresh + logger.debug('ClusterViewComponent: Mounted successfully'); + } + + setupRefreshButton() { + logger.debug('ClusterViewComponent: Setting up refresh button...'); + + const refreshBtn = this.findElement('.refresh-btn'); + logger.debug('ClusterViewComponent: Found refresh button:', !!refreshBtn, refreshBtn); + + if (refreshBtn) { + logger.debug('ClusterViewComponent: Adding click event listener to refresh button'); + this.addEventListener(refreshBtn, 'click', this.handleRefresh.bind(this)); + logger.debug('ClusterViewComponent: Event listener added successfully'); + } else { + logger.error('ClusterViewComponent: Refresh button not found!'); + logger.debug('ClusterViewComponent: Container HTML:', this.container.innerHTML); + logger.debug('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button')); + } + } + + async handleRefresh() { + logger.debug('ClusterViewComponent: Refresh button clicked, performing full refresh...'); + + // Get the refresh button and show loading state + const refreshBtn = this.findElement('.refresh-btn'); + logger.debug('ClusterViewComponent: Found refresh button for loading state:', !!refreshBtn); + + if (refreshBtn) { + const originalText = refreshBtn.innerHTML; + logger.debug('ClusterViewComponent: Original button text:', originalText); + + refreshBtn.innerHTML = ` + + + + + Refreshing... + `; + refreshBtn.disabled = true; + + try { + logger.debug('ClusterViewComponent: Starting cluster members update...'); + // Always perform a full refresh when user clicks refresh button + await this.viewModel.updateClusterMembers(); + logger.debug('ClusterViewComponent: Cluster members update completed successfully'); + } catch (error) { + logger.error('ClusterViewComponent: Error during refresh:', error); + // Show error state + if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) { + this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed'); + } + } finally { + logger.debug('ClusterViewComponent: Restoring button state...'); + // Restore button state + refreshBtn.innerHTML = originalText; + refreshBtn.disabled = false; + } + } else { + logger.warn('ClusterViewComponent: Refresh button not found, using fallback refresh'); + // Fallback if button not found + try { + await this.viewModel.updateClusterMembers(); + } catch (error) { + logger.error('ClusterViewComponent: Fallback refresh failed:', error); + if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) { + this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed'); + } + } + } + } + + unmount() { + logger.debug('ClusterViewComponent: Unmounting...'); + + // Unmount sub-components + if (this.primaryNodeComponent) { + this.primaryNodeComponent.unmount(); + } + if (this.clusterMembersComponent) { + this.clusterMembersComponent.unmount(); + } + + // Clear intervals + if (this.updateInterval) { + clearInterval(this.updateInterval); + } + + super.unmount(); + logger.debug('ClusterViewComponent: Unmounted'); + } + + // Override pause method to handle sub-components + onPause() { + logger.debug('ClusterViewComponent: Pausing...'); + + // Pause sub-components + if (this.primaryNodeComponent && this.primaryNodeComponent.isMounted) { + this.primaryNodeComponent.pause(); + } + if (this.clusterMembersComponent && this.clusterMembersComponent.isMounted) { + this.clusterMembersComponent.pause(); + } + + // Clear any active intervals + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + } + + // Override resume method to handle sub-components + onResume() { + logger.debug('ClusterViewComponent: Resuming...'); + + // Resume sub-components + if (this.primaryNodeComponent && this.primaryNodeComponent.isMounted) { + this.primaryNodeComponent.resume(); + } + if (this.clusterMembersComponent && this.clusterMembersComponent.isMounted) { + this.clusterMembersComponent.resume(); + } + + // Restart periodic updates if needed + // this.setupPeriodicUpdates(); // Disabled automatic refresh + } + + // Override to determine if re-render is needed on resume + shouldRenderOnResume() { + // Don't re-render on resume - the component should maintain its state + return false; + } + + setupPeriodicUpdates() { + // Update primary node display every 10 seconds + this.updateInterval = setInterval(() => { + this.viewModel.updatePrimaryNodeDisplay(); + }, 10000); + } +} + +window.ClusterViewComponent = ClusterViewComponent; \ No newline at end of file diff --git a/public/scripts/components/ComponentsLoader.js b/public/scripts/components/ComponentsLoader.js new file mode 100644 index 0000000..4e8b580 --- /dev/null +++ b/public/scripts/components/ComponentsLoader.js @@ -0,0 +1,16 @@ +(function(){ + // Simple readiness flag once all component constructors are present + function allReady(){ + return !!(window.PrimaryNodeComponent && window.ClusterMembersComponent && window.NodeDetailsComponent && window.FirmwareComponent && window.ClusterViewComponent && window.FirmwareViewComponent && window.TopologyGraphComponent && window.MemberCardOverlayComponent && window.ClusterStatusComponent); + } + window.waitForComponentsReady = function(timeoutMs = 5000){ + return new Promise((resolve, reject) => { + const start = Date.now(); + (function check(){ + if (allReady()) return resolve(true); + if (Date.now() - start > timeoutMs) return reject(new Error('Components did not load in time')); + setTimeout(check, 25); + })(); + }); + }; +})(); \ No newline at end of file diff --git a/public/scripts/components/FirmwareComponent.js b/public/scripts/components/FirmwareComponent.js new file mode 100644 index 0000000..2f8c07b --- /dev/null +++ b/public/scripts/components/FirmwareComponent.js @@ -0,0 +1,698 @@ +// Firmware Component +class FirmwareComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + + logger.debug('FirmwareComponent: Constructor called'); + logger.debug('FirmwareComponent: Container:', container); + logger.debug('FirmwareComponent: Container ID:', container?.id); + + // Check if the dropdown exists in the container + if (container) { + const dropdown = container.querySelector('#specific-node-select'); + logger.debug('FirmwareComponent: Dropdown found in constructor:', !!dropdown); + if (dropdown) { + logger.debug('FirmwareComponent: Dropdown tagName:', dropdown.tagName); + logger.debug('FirmwareComponent: Dropdown id:', dropdown.id); + } + } + } + + setupEventListeners() { + // Setup global firmware file input + const globalFirmwareFile = this.findElement('#global-firmware-file'); + if (globalFirmwareFile) { + this.addEventListener(globalFirmwareFile, 'change', this.handleFileSelect.bind(this)); + } + + // Setup target selection + const targetRadios = this.findAllElements('input[name="target-type"]'); + targetRadios.forEach(radio => { + this.addEventListener(radio, 'change', this.handleTargetChange.bind(this)); + }); + + // Setup specific node select change handler + const specificNodeSelect = this.findElement('#specific-node-select'); + logger.debug('FirmwareComponent: setupEventListeners - specificNodeSelect found:', !!specificNodeSelect); + if (specificNodeSelect) { + logger.debug('FirmwareComponent: specificNodeSelect element:', specificNodeSelect); + logger.debug('FirmwareComponent: specificNodeSelect tagName:', specificNodeSelect.tagName); + logger.debug('FirmwareComponent: specificNodeSelect id:', specificNodeSelect.id); + + // Store the bound handler as an instance property + this._boundNodeSelectHandler = this.handleNodeSelect.bind(this); + this.addEventListener(specificNodeSelect, 'change', this._boundNodeSelectHandler); + logger.debug('FirmwareComponent: Event listener added to specificNodeSelect'); + } else { + logger.warn('FirmwareComponent: specificNodeSelect element not found during setupEventListeners'); + } + + // Setup label select change handler (single-select add-to-chips) + const labelSelect = this.findElement('#label-select'); + if (labelSelect) { + this._boundLabelSelectHandler = (e) => { + const value = e.target.value; + if (!value) return; + const current = this.viewModel.get('selectedLabels') || []; + if (!current.includes(value)) { + this.viewModel.setSelectedLabels([...current, value]); + } + // Reset select back to placeholder + e.target.value = ''; + this.renderSelectedLabelChips(); + this.updateAffectedNodesPreview(); + this.updateDeployButton(); + }; + this.addEventListener(labelSelect, 'change', this._boundLabelSelectHandler); + } + + // Setup deploy button + const deployBtn = this.findElement('#deploy-btn'); + if (deployBtn) { + this.addEventListener(deployBtn, 'click', this.handleDeploy.bind(this)); + } + } + + setupViewModelListeners() { + this.subscribeToProperty('selectedFile', () => { + this.updateFileInfo(); + this.updateDeployButton(); + }); + this.subscribeToProperty('targetType', () => { + this.updateTargetVisibility(); + this.updateDeployButton(); + this.updateAffectedNodesPreview(); + }); + this.subscribeToProperty('specificNode', this.updateDeployButton.bind(this)); + this.subscribeToProperty('availableNodes', () => { + this.populateNodeSelect(); + this.populateLabelSelect(); + this.updateDeployButton(); + this.updateAffectedNodesPreview(); + }); + this.subscribeToProperty('uploadProgress', this.updateUploadProgress.bind(this)); + this.subscribeToProperty('uploadResults', this.updateUploadResults.bind(this)); + this.subscribeToProperty('isUploading', this.updateUploadState.bind(this)); + this.subscribeToProperty('selectedLabels', () => { + this.populateLabelSelect(); + this.updateAffectedNodesPreview(); + this.updateDeployButton(); + }); + } + + mount() { + super.mount(); + + logger.debug('FirmwareComponent: Mounting...'); + + // Check if the dropdown exists when mounted + const dropdown = this.findElement('#specific-node-select'); + logger.debug('FirmwareComponent: Mount - dropdown found:', !!dropdown); + if (dropdown) { + logger.debug('FirmwareComponent: Mount - dropdown tagName:', dropdown.tagName); + logger.debug('FirmwareComponent: Mount - dropdown id:', dropdown.id); + logger.debug('FirmwareComponent: Mount - dropdown innerHTML:', dropdown.innerHTML); + } + + // Initialize target visibility and label list on first mount + try { + this.updateTargetVisibility(); + this.populateLabelSelect(); + this.updateAffectedNodesPreview(); + } catch (e) { + logger.warn('FirmwareComponent: Initialization after mount failed:', e); + } + + logger.debug('FirmwareComponent: Mounted successfully'); + } + + render() { + // Initial render is handled by the HTML template + this.updateDeployButton(); + } + + handleFileSelect(event) { + const file = event.target.files[0]; + this.viewModel.setSelectedFile(file); + } + + handleTargetChange(event) { + const targetType = event.target.value; + this.viewModel.setTargetType(targetType); + } + + handleNodeSelect(event) { + const nodeIp = event.target.value; + logger.debug('FirmwareComponent: handleNodeSelect called with nodeIp:', nodeIp); + logger.debug('Event:', event); + logger.debug('Event target:', event.target); + logger.debug('Event target value:', event.target.value); + + this.viewModel.setSpecificNode(nodeIp); + + // Also update the deploy button state + this.updateDeployButton(); + } + + async handleDeploy() { + const file = this.viewModel.get('selectedFile'); + const targetType = this.viewModel.get('targetType'); + const specificNode = this.viewModel.get('specificNode'); + + if (!file) { + alert('Please select a firmware file first.'); + return; + } + + if (targetType === 'specific' && !specificNode) { + alert('Please select a specific node to update.'); + return; + } + + try { + this.viewModel.startUpload(); + + if (targetType === 'all') { + await this.uploadToAllNodes(file); + } else if (targetType === 'specific') { + await this.uploadToSpecificNode(file, specificNode); + } else if (targetType === 'labels') { + await this.uploadToLabelFilteredNodes(file); + } + + // Reset interface after successful upload + this.viewModel.resetUploadState(); + + } catch (error) { + logger.error('Firmware deployment failed:', error); + alert(`Deployment failed: ${error.message}`); + } finally { + this.viewModel.completeUpload(); + } + } + + async uploadToAllNodes(file) { + try { + // Get current cluster members + const response = await window.apiClient.getClusterMembers(); + const nodes = response.members || []; + + if (nodes.length === 0) { + alert('No nodes available for firmware update.'); + return; + } + + const confirmed = confirm(`Upload firmware to all ${nodes.length} nodes? This will update: ${nodes.map(n => n.hostname || n.ip).join(', ')}`); + if (!confirmed) return; + + // Show upload progress area + this.showUploadProgress(file, nodes); + + // Start batch upload + const results = await this.performBatchUpload(file, nodes); + + // Display results + this.displayUploadResults(results); + + } catch (error) { + logger.error('Failed to upload firmware to all nodes:', error); + throw error; + } + } + + async uploadToSpecificNode(file, nodeIp) { + try { + const confirmed = confirm(`Upload firmware to node ${nodeIp}?`); + if (!confirmed) return; + + // Show upload progress area + this.showUploadProgress(file, [{ ip: nodeIp, hostname: nodeIp }]); + + // Update progress to show starting + this.updateNodeProgress(1, 1, nodeIp, 'Uploading...'); + + // Perform single node upload + const result = await this.performSingleUpload(file, nodeIp); + + // Update progress to show completion + this.updateNodeProgress(1, 1, nodeIp, 'Completed'); + this.updateOverallProgress(1, 1); + + // Display results + this.displayUploadResults([result]); + + } catch (error) { + logger.error(`Failed to upload firmware to node ${nodeIp}:`, error); + + // Update progress to show failure + this.updateNodeProgress(1, 1, nodeIp, 'Failed'); + this.updateOverallProgress(0, 1); + + // Display error results + const errorResult = { + nodeIp: nodeIp, + hostname: nodeIp, + success: false, + error: error.message, + timestamp: new Date().toISOString() + }; + this.displayUploadResults([errorResult]); + + throw error; + } + } + + async uploadToLabelFilteredNodes(file) { + try { + const nodes = this.viewModel.getAffectedNodesByLabels(); + if (!nodes || nodes.length === 0) { + alert('No nodes match the selected labels.'); + return; + } + const labels = this.viewModel.get('selectedLabels') || []; + const confirmed = confirm(`Upload firmware to ${nodes.length} node(s) matching labels (${labels.join(', ')})?`); + if (!confirmed) return; + + // Show upload progress area + this.showUploadProgress(file, nodes); + + // Start batch upload + const results = await this.performBatchUpload(file, nodes); + + // Display results + this.displayUploadResults(results); + } catch (error) { + logger.error('Failed to upload firmware to label-filtered nodes:', error); + throw error; + } + } + + async performBatchUpload(file, nodes) { + const results = []; + const totalNodes = nodes.length; + let successfulUploads = 0; + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const nodeIp = node.ip; + + try { + // Update progress + this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Uploading...'); + + // Upload to this node + const result = await this.performSingleUpload(file, nodeIp); + results.push(result); + successfulUploads++; + + // Update progress + this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Completed'); + this.updateOverallProgress(successfulUploads, totalNodes); + + } catch (error) { + logger.error(`Failed to upload to node ${nodeIp}:`, error); + const errorResult = { + nodeIp: nodeIp, + hostname: node.hostname || nodeIp, + success: false, + error: error.message, + timestamp: new Date().toISOString() + }; + results.push(errorResult); + + // Update progress + this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Failed'); + this.updateOverallProgress(successfulUploads, totalNodes); + } + + // Small delay between uploads + if (i < nodes.length - 1) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + return results; + } + + async performSingleUpload(file, nodeIp) { + try { + const result = await window.apiClient.uploadFirmware(file, nodeIp); + + return { + nodeIp: nodeIp, + hostname: nodeIp, + success: true, + result: result, + timestamp: new Date().toISOString() + }; + + } catch (error) { + throw new Error(`Upload to ${nodeIp} failed: ${error.message}`); + } + } + + showUploadProgress(file, nodes) { + const container = this.findElement('#firmware-nodes-list'); + + const progressHTML = ` +
+
+

📤 Firmware Upload Progress

+
+ File: ${file.name} + Size: ${(file.size / 1024).toFixed(1)}KB + Targets: ${nodes.length} node(s) +
+
+
+
+
+ 0/${nodes.length} Successful (0%) +
+
+ Status: Preparing upload... +
+
+
+ ${nodes.map(node => ` +
+
+ ${node.hostname || node.ip} + ${node.ip} +
+
Pending...
+
+
+ `).join('')} +
+
+ `; + + container.innerHTML = progressHTML; + + // Initialize progress for single-node uploads + if (nodes.length === 1) { + const node = nodes[0]; + this.updateNodeProgress(1, 1, node.ip, 'Pending...'); + } + } + + updateNodeProgress(current, total, nodeIp, status) { + const progressItem = this.findElement(`[data-node-ip="${nodeIp}"]`); + if (progressItem) { + const statusElement = progressItem.querySelector('.progress-status'); + const timeElement = progressItem.querySelector('.progress-time'); + + if (statusElement) { + statusElement.textContent = status; + + // Add status-specific styling + statusElement.className = 'progress-status'; + if (status === 'Completed') { + statusElement.classList.add('success'); + if (timeElement) { + timeElement.textContent = new Date().toLocaleTimeString(); + } + } else if (status === 'Failed') { + statusElement.classList.add('error'); + if (timeElement) { + timeElement.textContent = new Date().toLocaleTimeString(); + } + } else if (status === 'Uploading...') { + statusElement.classList.add('uploading'); + if (timeElement) { + timeElement.textContent = 'Started: ' + new Date().toLocaleTimeString(); + } + } + } + } + } + + updateOverallProgress(successfulUploads, totalNodes) { + const progressBar = this.findElement('#overall-progress-bar'); + const progressText = this.findElement('.progress-text'); + + if (progressBar and progressText) { + const successPercentage = Math.round((successfulUploads / totalNodes) * 100); + progressBar.style.width = `${successPercentage}%`; + progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`; + + // Update progress bar color based on completion + if (successPercentage === 100) { + progressBar.style.backgroundColor = '#4ade80'; + } else if (successPercentage > 50) { + progressBar.style.backgroundColor = '#60a5fa'; + } else { + progressBar.style.backgroundColor = '#fbbf24'; + } + + // Update progress summary for single-node uploads + const progressSummary = this.findElement('#progress-summary'); + if (progressSummary and totalNodes === 1) { + if (successfulUploads === 1) { + progressSummary.innerHTML = 'Status: Upload completed successfully'; + } else if (successfulUploads === 0) { + progressSummary.innerHTML = 'Status: Upload failed'; + } + } + } + } + + displayUploadResults(results) { + const progressHeader = this.findElement('.progress-header h3'); + const progressSummary = this.findElement('#progress-summary'); + + if (progressHeader and progressSummary) { + const successCount = results.filter(r => r.success).length; + const totalCount = results.length; + const successRate = Math.round((successCount / totalCount) * 100); + + if (totalCount === 1) { + // Single node upload + if (successCount === 1) { + progressHeader.textContent = `📤 Firmware Upload Complete`; + progressSummary.innerHTML = `✅ Upload to ${results[0].hostname || results[0].nodeIp} completed successfully at ${new Date().toLocaleTimeString()}`; + } else { + progressHeader.textContent = `📤 Firmware Upload Failed`; + progressSummary.innerHTML = `❌ Upload to ${results[0].hostname || results[0].nodeIp} failed at ${new Date().toLocaleTimeString()}`; + } + } else if (successCount === totalCount) { + // Multi-node upload - all successful + progressHeader.textContent = `📤 Firmware Upload Complete (${successCount}/${totalCount} Successful)`; + progressSummary.innerHTML = `✅ All uploads completed successfully at ${new Date().toLocaleTimeString()}`; + } else { + // Multi-node upload - some failed + progressHeader.textContent = `📤 Firmware Upload Results (${successCount}/${totalCount} Successful)`; + progressSummary.innerHTML = `⚠️ Upload completed with ${totalCount - successCount} failure(s) at ${new Date().toLocaleTimeString()}`; + } + } + } + + updateFileInfo() { + const file = this.viewModel.get('selectedFile'); + const fileInfo = this.findElement('#file-info'); + const deployBtn = this.findElement('#deploy-btn'); + + if (file) { + fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`; + fileInfo.classList.add('has-file'); + } else { + fileInfo.textContent = 'No file selected'; + fileInfo.classList.remove('has-file'); + } + + this.updateDeployButton(); + } + + updateTargetVisibility() { + const targetType = this.viewModel.get('targetType'); + const specificNodeSelect = this.findElement('#specific-node-select'); + const labelSelect = this.findElement('#label-select'); + + logger.debug('FirmwareComponent: updateTargetVisibility called with targetType:', targetType); + + if (targetType === 'specific') { + if (specificNodeSelect) { specificNodeSelect.style.display = 'inline-block'; } + if (labelSelect) { labelSelect.style.display = 'none'; } + this.populateNodeSelect(); + } else if (targetType === 'labels') { + if (specificNodeSelect) { specificNodeSelect.style.display = 'none'; } + if (labelSelect) { + labelSelect.style.display = 'inline-block'; + this.populateLabelSelect(); + } + } else { + if (specificNodeSelect) { specificNodeSelect.style.display = 'none'; } + if (labelSelect) { labelSelect.style.display = 'none'; } + } + this.updateDeployButton(); + } + + // Note: handleNodeSelect is already defined above and handles the actual node selection + // This duplicate method was causing the issue - removing it + + updateDeployButton() { + const deployBtn = this.findElement('#deploy-btn'); + if (deployBtn) { + deployBtn.disabled = !this.viewModel.isDeployEnabled(); + } + } + + populateNodeSelect() { + const select = this.findElement('#specific-node-select'); + if (!select) { + logger.warn('FirmwareComponent: populateNodeSelect - select element not found'); + return; + } + + if (select.tagName !== 'SELECT') { + logger.warn('FirmwareComponent: populateNodeSelect - element is not a SELECT:', select.tagName); + return; + } + + logger.debug('FirmwareComponent: populateNodeSelect called'); + logger.debug('FirmwareComponent: Select element:', select); + logger.debug('FirmwareComponent: Available nodes:', this.viewModel.get('availableNodes')); + + // Clear existing options + select.innerHTML = ''; + + // Get available nodes from the view model + const availableNodes = this.viewModel.get('availableNodes'); + + if (!availableNodes || availableNodes.length === 0) { + // No nodes available + const option = document.createElement('option'); + option.value = ""; + option.textContent = "No nodes available"; + option.disabled = true; + select.appendChild(option); + return; + } + + availableNodes.forEach(node => { + const option = document.createElement('option'); + option.value = node.ip; + option.textContent = `${node.hostname} (${node.ip})`; + select.appendChild(option); + }); + + // Ensure event listener is still bound after repopulating + this.ensureNodeSelectListener(select); + + logger.debug('FirmwareComponent: Node select populated with', availableNodes.length, 'nodes'); + } + + // Ensure the node select change listener is properly bound + ensureNodeSelectListener(select) { + if (!select) return; + + // Store the bound handler as an instance property to avoid binding issues + if (!this._boundNodeSelectHandler) { + this._boundNodeSelectHandler = this.handleNodeSelect.bind(this); + } + + // Remove any existing listeners and add the bound one + select.removeEventListener('change', this._boundNodeSelectHandler); + select.addEventListener('change', this._boundNodeSelectHandler); + + logger.debug('FirmwareComponent: Node select event listener ensured'); + } + + updateUploadProgress() { + // This will be implemented when we add upload progress tracking + } + + updateUploadResults() { + // This will be implemented when we add upload results display + } + + updateUploadState() { + const isUploading = this.viewModel.get('isUploading'); + const deployBtn = this.findElement('#deploy-btn'); + + if (deployBtn) { + deployBtn.disabled = isUploading; + if (isUploading) { + deployBtn.classList.add('loading'); + deployBtn.textContent = '⏳ Deploying...'; + } else { + deployBtn.classList.remove('loading'); + deployBtn.textContent = '🚀 Deploy'; + } + } + + this.updateDeployButton(); + } + + populateLabelSelect() { + const select = this.findElement('#label-select'); + if (!select) return; + const labels = this.viewModel.get('availableLabels') || []; + const selected = new Set(this.viewModel.get('selectedLabels') || []); + const options = [''] + .concat(labels.filter(l => !selected.has(l)).map(l => ``)); + select.innerHTML = options.join(''); + // Ensure change listener remains bound + if (this._boundLabelSelectHandler) { + select.removeEventListener('change', this._boundLabelSelectHandler); + select.addEventListener('change', this._boundLabelSelectHandler); + } + this.renderSelectedLabelChips(); + } + + renderSelectedLabelChips() { + const container = this.findElement('#selected-labels-container'); + if (!container) return; + const selected = this.viewModel.get('selectedLabels') || []; + if (selected.length === 0) { + container.innerHTML = ''; + return; + } + container.innerHTML = selected.map(l => ` + + ${l} + + + `).join(''); + Array.from(container.querySelectorAll('.chip-remove')).forEach(btn => { + this.addEventListener(btn, 'click', (e) => { + e.stopPropagation(); + const label = btn.getAttribute('data-label'); + const current = this.viewModel.get('selectedLabels') || []; + this.viewModel.setSelectedLabels(current.filter(x => x !== label)); + this.populateLabelSelect(); + this.updateAffectedNodesPreview(); + this.updateDeployButton(); + }); + }); + } + + updateAffectedNodesPreview() { + const container = this.findElement('#firmware-nodes-list'); + if (!container) return; + if (this.viewModel.get('targetType') !== 'labels') { + container.innerHTML = ''; + return; + } + const nodes = this.viewModel.getAffectedNodesByLabels(); + if (!nodes.length) { + container.innerHTML = `
No nodes match the selected labels
`; + return; + } + const html = ` +
+

🎯 Affected Nodes (${nodes.length})

+
+ ${nodes.map(n => ` +
+
${n.hostname || n.ip}${n.ip}
+
+ `).join('')} +
+
`; + container.innerHTML = html; + } +} + +window.FirmwareComponent = FirmwareComponent; \ No newline at end of file diff --git a/public/scripts/components/FirmwareViewComponent.js b/public/scripts/components/FirmwareViewComponent.js new file mode 100644 index 0000000..82aecca --- /dev/null +++ b/public/scripts/components/FirmwareViewComponent.js @@ -0,0 +1,82 @@ +// Firmware View Component +class FirmwareViewComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + + logger.debug('FirmwareViewComponent: Constructor called'); + logger.debug('FirmwareViewComponent: Container:', container); + + const firmwareContainer = this.findElement('#firmware-container'); + logger.debug('FirmwareViewComponent: Firmware container found:', !!firmwareContainer); + + this.firmwareComponent = new FirmwareComponent( + firmwareContainer, + viewModel, + eventBus + ); + + logger.debug('FirmwareViewComponent: FirmwareComponent created'); + } + + mount() { + super.mount(); + + logger.debug('FirmwareViewComponent: Mounting...'); + + // Mount sub-component + this.firmwareComponent.mount(); + + // Update available nodes + this.updateAvailableNodes(); + + logger.debug('FirmwareViewComponent: Mounted successfully'); + } + + unmount() { + // Unmount sub-component + if (this.firmwareComponent) { + this.firmwareComponent.unmount(); + } + + super.unmount(); + } + + // Override pause method to handle sub-components + onPause() { + logger.debug('FirmwareViewComponent: Pausing...'); + + // Pause sub-component + if (this.firmwareComponent && this.firmwareComponent.isMounted) { + this.firmwareComponent.pause(); + } + } + + // Override resume method to handle sub-components + onResume() { + logger.debug('FirmwareViewComponent: Resuming...'); + + // Resume sub-component + if (this.firmwareComponent && this.firmwareComponent.isMounted) { + this.firmwareComponent.resume(); + } + } + + // Override to determine if re-render is needed on resume + shouldRenderOnResume() { + // Don't re-render on resume - maintain current state + return false; + } + + async updateAvailableNodes() { + try { + logger.debug('FirmwareViewComponent: updateAvailableNodes called'); + const response = await window.apiClient.getClusterMembers(); + const nodes = response.members || []; + logger.debug('FirmwareViewComponent: Got nodes:', nodes); + this.viewModel.updateAvailableNodes(nodes); + logger.debug('FirmwareViewComponent: Available nodes updated in view model'); + } catch (error) { + logger.error('Failed to update available nodes:', error); + } + } +} \ No newline at end of file diff --git a/public/scripts/components/NodeDetailsComponent.js b/public/scripts/components/NodeDetailsComponent.js new file mode 100644 index 0000000..6653694 --- /dev/null +++ b/public/scripts/components/NodeDetailsComponent.js @@ -0,0 +1,529 @@ +// Node Details Component with enhanced state preservation +class NodeDetailsComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + } + + setupViewModelListeners() { + this.subscribeToProperty('nodeStatus', this.handleNodeStatusUpdate.bind(this)); + this.subscribeToProperty('tasks', this.handleTasksUpdate.bind(this)); + this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this)); + this.subscribeToProperty('error', this.handleErrorUpdate.bind(this)); + this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this)); + this.subscribeToProperty('capabilities', this.handleCapabilitiesUpdate.bind(this)); + } + + // Handle node status update with state preservation + handleNodeStatusUpdate(newStatus, previousStatus) { + if (newStatus && !this.viewModel.get('isLoading')) { + this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('capabilities')); + } + } + + // Handle tasks update with state preservation + handleTasksUpdate(newTasks, previousTasks) { + const nodeStatus = this.viewModel.get('nodeStatus'); + if (nodeStatus && !this.viewModel.get('isLoading')) { + this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('capabilities')); + } + } + + // Handle loading state update + handleLoadingUpdate(isLoading) { + if (isLoading) { + this.renderLoading('
Loading detailed information...
'); + } + } + + // Handle error state update + handleErrorUpdate(error) { + if (error) { + this.renderError(`Error loading node details: ${error}`); + } + } + + // Handle active tab update + handleActiveTabUpdate(newTab, previousTab) { + // Update tab UI without full re-render + this.updateActiveTab(newTab, previousTab); + } + + // Handle capabilities update with state preservation + handleCapabilitiesUpdate(newCapabilities, previousCapabilities) { + const nodeStatus = this.viewModel.get('nodeStatus'); + const tasks = this.viewModel.get('tasks'); + if (nodeStatus && !this.viewModel.get('isLoading')) { + this.renderNodeDetails(nodeStatus, tasks, newCapabilities); + } + } + + render() { + const nodeStatus = this.viewModel.get('nodeStatus'); + const tasks = this.viewModel.get('tasks'); + const isLoading = this.viewModel.get('isLoading'); + const error = this.viewModel.get('error'); + const capabilities = this.viewModel.get('capabilities'); + + if (isLoading) { + this.renderLoading('
Loading detailed information...
'); + return; + } + + if (error) { + this.renderError(`Error loading node details: ${error}`); + return; + } + + if (!nodeStatus) { + this.renderEmpty('
No node status available
'); + return; + } + + this.renderNodeDetails(nodeStatus, tasks, capabilities); + } + + renderNodeDetails(nodeStatus, tasks, capabilities) { + // Use persisted active tab from the view model, default to 'status' + const activeTab = (this.viewModel && typeof this.viewModel.get === 'function' && this.viewModel.get('activeTab')) || 'status'; + logger.debug('NodeDetailsComponent: Rendering with activeTab:', activeTab); + + const html = ` +
+
+ + + + + +
+ +
+
+ Free Heap: + ${Math.round(nodeStatus.freeHeap / 1024)}KB +
+
+ Chip ID: + ${nodeStatus.chipId} +
+
+ SDK Version: + ${nodeStatus.sdkVersion} +
+
+ CPU Frequency: + ${nodeStatus.cpuFreqMHz}MHz +
+
+ Flash Size: + ${Math.round(nodeStatus.flashChipSize / 1024)}KB +
+
+ +
+ ${nodeStatus.api ? nodeStatus.api.map(endpoint => + `
${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}
` + ).join('') : '
No API endpoints available
'} +
+ +
+ ${this.renderCapabilitiesTab(capabilities)} +
+ +
+ ${this.renderTasksTab(tasks)} +
+ +
+ ${this.renderFirmwareTab()} +
+
+ `; + + this.setHTML('', html); + this.setupTabs(); + // Restore last active tab from view model if available + const restored = this.viewModel && typeof this.viewModel.get === 'function' ? this.viewModel.get('activeTab') : null; + if (restored) { + this.setActiveTab(restored); + } + this.setupFirmwareUpload(); + } + + renderCapabilitiesTab(capabilities) { + if (!capabilities || !Array.isArray(capabilities.endpoints) || capabilities.endpoints.length === 0) { + return ` +
+
🧩 No capabilities reported
+
This node did not return any capabilities
+
+ `; + } + + // Sort endpoints by URI (name), then by method for stable ordering + const endpoints = [...capabilities.endpoints].sort((a, b) => { + const aUri = String(a.uri || '').toLowerCase(); + const bUri = String(b.uri || '').toLowerCase(); + if (aUri < bUri) return -1; + if (aUri > bUri) return 1; + const aMethod = String(a.method || '').toLowerCase(); + const bMethod = String(b.method || '').toLowerCase(); + return aMethod.localeCompare(bMethod); + }); + + const total = endpoints.length; + + // Preserve selection based on a stable key of method+uri if available + const selectedKey = String(this.getUIState('capSelectedKey') || ''); + let selectedIndex = endpoints.findIndex(ep => `${ep.method} ${ep.uri}` === selectedKey); + if (selectedIndex === -1) { + selectedIndex = Number(this.getUIState('capSelectedIndex')); + if (Number.isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= total) { + selectedIndex = 0; + } + } + + // Compute padding for aligned display in dropdown + const maxMethodLen = endpoints.reduce((m, ep) => Math.max(m, String(ep.method || '').length), 0); + + const selectorOptions = endpoints.map((ep, idx) => { + const method = String(ep.method || ''); + const uri = String(ep.uri || ''); + const padCount = Math.max(1, (maxMethodLen - method.length) + 2); + const spacer = ' '.repeat(padCount); + return ``; + }).join(''); + + const items = endpoints.map((ep, idx) => { + const formId = `cap-form-${idx}`; + const resultId = `cap-result-${idx}`; + const params = Array.isArray(ep.params) && ep.params.length > 0 + ? `
${ep.params.map((p, pidx) => ` + + `).join('')}
` + : '
No parameters
'; + return ` +
+
+ ${ep.method} + ${ep.uri} + +
+
+ ${params} +
+ +
+ `; + }).join(''); + + // Attach events after render in setupCapabilitiesEvents() + setTimeout(() => this.setupCapabilitiesEvents(), 0); + + return ` +
+ + +
+
${items}
+ `; + } + + setupCapabilitiesEvents() { + const selector = this.findElement('#capability-select'); + if (selector) { + this.addEventListener(selector, 'change', (e) => { + const selected = Number(e.target.value); + const items = Array.from(this.findAllElements('.capability-item')); + items.forEach((el, idx) => { + el.style.display = (idx === selected) ? '' : 'none'; + }); + this.setUIState('capSelectedIndex', selected); + const opt = e.target.selectedOptions && e.target.selectedOptions[0]; + if (opt) { + const method = opt.dataset.method || ''; + const uri = opt.dataset.uri || ''; + this.setUIState('capSelectedKey', `${method} ${uri}`); + } + }); + } + + const buttons = this.findAllElements('.cap-call-btn'); + buttons.forEach(btn => { + this.addEventListener(btn, 'click', async (e) => { + e.stopPropagation(); + const method = btn.dataset.method || 'GET'; + const uri = btn.dataset.uri || ''; + const formId = btn.dataset.formId; + const resultId = btn.dataset.resultId; + + const formEl = this.findElement(`#${formId}`); + const resultEl = this.findElement(`#${resultId}`); + if (!formEl || !resultEl) return; + + const inputs = Array.from(formEl.querySelectorAll('.param-input')); + const params = inputs.map(input => ({ + name: input.dataset.paramName, + location: input.dataset.paramLocation || 'body', + type: input.dataset.paramType || 'string', + required: input.dataset.paramRequired === '1', + value: input.value + })); + + // Required validation + const missing = params.filter(p => p.required && (!p.value || String(p.value).trim() === '')); + if (missing.length > 0) { + resultEl.style.display = 'block'; + resultEl.innerHTML = ` +
+
❌ Missing required fields: ${missing.map(m => this.escapeHtml(m.name)).join(', ')}
+
+ `; + return; + } + + // Show loading state + resultEl.style.display = 'block'; + resultEl.innerHTML = '
Calling endpoint...
'; + + try { + const response = await this.viewModel.callCapability(method, uri, params); + const pretty = typeof response?.data === 'object' ? JSON.stringify(response.data, null, 2) : String(response?.data ?? ''); + resultEl.innerHTML = ` +
+
✅ Success
+
${this.escapeHtml(pretty)}
+
+ `; + } catch (err) { + resultEl.innerHTML = ` +
+
❌ Error: ${this.escapeHtml(err.message || 'Request failed')}
+
+ `; + } + }); + }); + } + + escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>'); + } + + renderTasksTab(tasks) { + const summary = this.viewModel.get('tasksSummary'); + if (tasks && tasks.length > 0) { + const summaryHTML = summary ? ` +
+
+
📋
+
+
Tasks Overview
+
System task management and monitoring
+
+
+
+
+
${summary.totalTasks ?? tasks.length}
+
Total
+
+
+
${summary.activeTasks ?? tasks.filter(t => t.running).length}
+
Active
+
+
+
${(summary.totalTasks ?? tasks.length) - (summary.activeTasks ?? tasks.filter(t => t.running).length)}
+
Stopped
+
+
+
+ ` : ''; + const tasksHTML = tasks.map(task => ` +
+
+ ${task.name || 'Unknown Task'} + + ${task.running ? '🟢 Running' : '🔴 Stopped'} + +
+
+ Interval: ${task.interval}ms + ${task.enabled ? '🟢 Enabled' : '🔴 Disabled'} +
+
+ `).join(''); + + return ` + ${summaryHTML} + ${tasksHTML} + `; + } else { + const total = summary?.totalTasks ?? 0; + const active = summary?.activeTasks ?? 0; + return ` +
+
+
📋
+
+
Tasks Overview
+
${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
+
+
+
+
+
${total}
+
Total
+
+
+
${active}
+
Active
+
+
+
${total - active}
+
Stopped
+
+
+
+
+
📋 No active tasks found
+
+ ${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'} +
+
+ `; + } + } + + renderFirmwareTab() { + return ` +
+

Firmware Update

+
+ + +
Select a .bin or .hex file to upload
+ +
+
+ `; + } + + setupTabs() { + logger.debug('NodeDetailsComponent: Setting up tabs'); + super.setupTabs(this.container, { + onChange: (tab) => { + // Persist active tab in the view model for restoration + if (this.viewModel && typeof this.viewModel.setActiveTab === 'function') { + this.viewModel.setActiveTab(tab); + } + } + }); + } + + // Update active tab without full re-render + updateActiveTab(newTab, previousTab = null) { + this.setActiveTab(newTab); + logger.debug(`NodeDetailsComponent: Active tab updated to '${newTab}'`); + } + + setupFirmwareUpload() { + const uploadBtn = this.findElement('.upload-btn[data-action="select-file"]'); + if (uploadBtn) { + this.addEventListener(uploadBtn, 'click', (e) => { + e.stopPropagation(); + const fileInput = this.findElement('#firmware-file'); + if (fileInput) { + fileInput.click(); + } + }); + + // Set up file input change handler + const fileInput = this.findElement('#firmware-file'); + if (fileInput) { + this.addEventListener(fileInput, 'change', async (e) => { + e.stopPropagation(); + const file = e.target.files[0]; + if (file) { + await this.uploadFirmware(file); + } + }); + } + } + } + + async uploadFirmware(file) { + const uploadStatus = this.findElement('#upload-status'); + const uploadBtn = this.findElement('.upload-btn'); + const originalText = uploadBtn.textContent; + + try { + // Show upload status + uploadStatus.style.display = 'block'; + uploadStatus.innerHTML = ` +
+
📤 Uploading ${file.name}...
+
Size: ${(file.size / 1024).toFixed(1)}KB
+
+ `; + + // Disable upload button + uploadBtn.disabled = true; + uploadBtn.textContent = '⏳ Uploading...'; + + // Get the member IP from the card + const memberCard = this.container.closest('.member-card'); + const memberIp = memberCard.dataset.memberIp; + + if (!memberIp) { + throw new Error('Could not determine target node IP address'); + } + + // Upload firmware + const result = await this.viewModel.uploadFirmware(file, memberIp); + + // Show success + uploadStatus.innerHTML = ` +
+
✅ Firmware uploaded successfully!
+
Node: ${memberIp}
+
Size: ${(file.size / 1024).toFixed(1)}KB
+
+ `; + + logger.debug('Firmware upload successful:', result); + + } catch (error) { + logger.error('Firmware upload failed:', error); + + // Show error + uploadStatus.innerHTML = ` +
+
❌ Upload failed: ${error.message}
+
+ `; + } finally { + // Re-enable upload button + uploadBtn.disabled = false; + uploadBtn.textContent = originalText; + + // Clear file input + const fileInput = this.findElement('#firmware-file'); + if (fileInput) { + fileInput.value = ''; + } + } + } +} + +window.NodeDetailsComponent = NodeDetailsComponent; \ No newline at end of file diff --git a/public/scripts/components/PrimaryNodeComponent.js b/public/scripts/components/PrimaryNodeComponent.js new file mode 100644 index 0000000..61da853 --- /dev/null +++ b/public/scripts/components/PrimaryNodeComponent.js @@ -0,0 +1,90 @@ +// Primary Node Component +class PrimaryNodeComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + } + + setupEventListeners() { + const refreshBtn = this.findElement('.primary-node-refresh'); + if (refreshBtn) { + this.addEventListener(refreshBtn, 'click', this.handleRandomSelection.bind(this)); + } + } + + setupViewModelListeners() { + // Listen to primary node changes + this.subscribeToProperty('primaryNode', this.render.bind(this)); + this.subscribeToProperty('clientInitialized', this.render.bind(this)); + this.subscribeToProperty('totalNodes', this.render.bind(this)); + this.subscribeToProperty('onlineNodes', this.render.bind(this)); + this.subscribeToProperty('error', this.render.bind(this)); + } + + render() { + const primaryNode = this.viewModel.get('primaryNode'); + const clientInitialized = this.viewModel.get('clientInitialized'); + const totalNodes = this.viewModel.get('totalNodes'); + const onlineNodes = this.viewModel.get('onlineNodes'); + const error = this.viewModel.get('error'); + + if (error) { + this.setText('#primary-node-ip', '❌ Discovery Failed'); + this.setClass('#primary-node-ip', 'error', true); + this.setClass('#primary-node-ip', 'discovering', false); + this.setClass('#primary-node-ip', 'selecting', false); + return; + } + + if (!primaryNode) { + this.setText('#primary-node-ip', '🔍 No Nodes Found'); + this.setClass('#primary-node-ip', 'error', true); + this.setClass('#primary-node-ip', 'discovering', false); + this.setClass('#primary-node-ip', 'selecting', false); + return; + } + + const status = clientInitialized ? '✅' : '⚠️'; + const nodeCount = (onlineNodes && onlineNodes > 0) + ? ` (${onlineNodes}/${totalNodes} online)` + : (totalNodes > 1 ? ` (${totalNodes} nodes)` : ''); + + this.setText('#primary-node-ip', `${status} ${primaryNode}${nodeCount}`); + this.setClass('#primary-node-ip', 'error', false); + this.setClass('#primary-node-ip', 'discovering', false); + this.setClass('#primary-node-ip', 'selecting', false); + } + + async handleRandomSelection() { + try { + // Show selecting state + this.setText('#primary-node-ip', '🎲 Selecting...'); + this.setClass('#primary-node-ip', 'selecting', true); + this.setClass('#primary-node-ip', 'discovering', false); + this.setClass('#primary-node-ip', 'error', false); + + await this.viewModel.selectRandomPrimaryNode(); + + // Show success briefly + this.setText('#primary-node-ip', '🎯 Selection Complete'); + + // Update display after delay + setTimeout(() => { + this.viewModel.updatePrimaryNodeDisplay(); + }, 1500); + + } catch (error) { + logger.error('Failed to select random primary node:', error); + this.setText('#primary-node-ip', '❌ Selection Failed'); + this.setClass('#primary-node-ip', 'error', true); + this.setClass('#primary-node-ip', 'selecting', false); + this.setClass('#primary-node-ip', 'discovering', false); + + // Revert to normal display after error + setTimeout(() => { + this.viewModel.updatePrimaryNodeDisplay(); + }, 2000); + } + } +} + +window.PrimaryNodeComponent = PrimaryNodeComponent; \ No newline at end of file diff --git a/public/scripts/components/TopologyGraphComponent.js b/public/scripts/components/TopologyGraphComponent.js new file mode 100644 index 0000000..7d7c59d --- /dev/null +++ b/public/scripts/components/TopologyGraphComponent.js @@ -0,0 +1,802 @@ +// Topology Graph Component with D3.js force-directed visualization +class TopologyGraphComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + logger.debug('TopologyGraphComponent: Constructor called'); + this.svg = null; + this.simulation = null; + this.zoom = null; + this.width = 0; // Will be set dynamically based on container size + this.height = 0; // Will be set dynamically based on container size + this.isInitialized = false; + } + + updateDimensions(container) { + // Get the container's actual dimensions + const rect = container.getBoundingClientRect(); + this.width = rect.width || 1400; // Fallback to 1400 if width is 0 + this.height = rect.height || 1000; // Fallback to 1000 if height is 0 + + // Ensure minimum dimensions + this.width = Math.max(this.width, 800); + this.height = Math.max(this.height, 600); + + logger.debug('TopologyGraphComponent: Updated dimensions to', this.width, 'x', this.height); + } + + handleResize() { + // Debounce resize events to avoid excessive updates + if (this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + } + + this.resizeTimeout = setTimeout(() => { + const container = this.findElement('#topology-graph-container'); + if (container && this.svg) { + this.updateDimensions(container); + // Update SVG viewBox and force center + this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`); + if (this.simulation) { + this.simulation.force('center', d3.forceCenter(this.width / 2, this.height / 2)); + this.simulation.alpha(0.3).restart(); + } + } + }, 250); // 250ms debounce + } + + // Override mount to ensure proper initialization + mount() { + if (this.isMounted) return; + + logger.debug('TopologyGraphComponent: Starting mount...'); + logger.debug('TopologyGraphComponent: isInitialized =', this.isInitialized); + + // Call initialize if not already done + if (!this.isInitialized) { + logger.debug('TopologyGraphComponent: Initializing during mount...'); + this.initialize().then(() => { + logger.debug('TopologyGraphComponent: Initialization completed, calling completeMount...'); + // Complete mount after initialization + this.completeMount(); + }).catch(error => { + logger.error('TopologyGraphComponent: Initialization failed during mount:', error); + // Still complete mount to prevent blocking + this.completeMount(); + }); + } else { + logger.debug('TopologyGraphComponent: Already initialized, calling completeMount directly...'); + this.completeMount(); + } + } + + completeMount() { + logger.debug('TopologyGraphComponent: completeMount called'); + this.isMounted = true; + logger.debug('TopologyGraphComponent: Setting up event listeners...'); + this.setupEventListeners(); + logger.debug('TopologyGraphComponent: Setting up view model listeners...'); + this.setupViewModelListeners(); + logger.debug('TopologyGraphComponent: Calling render...'); + this.render(); + + logger.debug('TopologyGraphComponent: Mounted successfully'); + } + + setupEventListeners() { + logger.debug('TopologyGraphComponent: setupEventListeners called'); + logger.debug('TopologyGraphComponent: Container:', this.container); + logger.debug('TopologyGraphComponent: Container ID:', this.container?.id); + + // Add resize listener to update dimensions when window is resized + this.resizeHandler = this.handleResize.bind(this); + window.addEventListener('resize', this.resizeHandler); + + // Refresh button removed from HTML, so no need to set up event listeners + logger.debug('TopologyGraphComponent: No event listeners needed (refresh button removed)'); + } + + setupViewModelListeners() { + logger.debug('TopologyGraphComponent: setupViewModelListeners called'); + logger.debug('TopologyGraphComponent: isInitialized =', this.isInitialized); + + if (this.isInitialized) { + // Component is already initialized, set up subscriptions immediately + logger.debug('TopologyGraphComponent: Setting up property subscriptions immediately'); + this.subscribeToProperty('nodes', this.renderGraph.bind(this)); + this.subscribeToProperty('links', this.renderGraph.bind(this)); + this.subscribeToProperty('isLoading', this.handleLoadingState.bind(this)); + this.subscribeToProperty('error', this.handleError.bind(this)); + this.subscribeToProperty('selectedNode', this.updateSelection.bind(this)); + } else { + // Component not yet initialized, store for later + logger.debug('TopologyGraphComponent: Component not initialized, storing pending subscriptions'); + this._pendingSubscriptions = [ + ['nodes', this.renderGraph.bind(this)], + ['links', this.renderGraph.bind(this)], + ['isLoading', this.handleLoadingState.bind(this)], + ['error', this.handleError.bind(this)], + ['selectedNode', this.updateSelection.bind(this)] + ]; + } + } + + async initialize() { + logger.debug('TopologyGraphComponent: Initializing...'); + + // Wait for DOM to be ready + if (document.readyState === 'loading') { + await new Promise(resolve => { + document.addEventListener('DOMContentLoaded', resolve); + }); + } + + // Set up the SVG container + this.setupSVG(); + + // Mark as initialized + this.isInitialized = true; + + // Now set up the actual property listeners after initialization + if (this._pendingSubscriptions) { + this._pendingSubscriptions.forEach(([property, callback]) => { + this.subscribeToProperty(property, callback); + }); + this._pendingSubscriptions = null; + } + + // Initial data load + await this.viewModel.updateNetworkTopology(); + } + + setupSVG() { + const container = this.findElement('#topology-graph-container'); + if (!container) { + logger.error('TopologyGraphComponent: Graph container not found'); + return; + } + + // Calculate dynamic dimensions based on container size + this.updateDimensions(container); + + // Clear existing content + container.innerHTML = ''; + + // Create SVG element + this.svg = d3.select(container) + .append('svg') + .attr('width', '100%') + .attr('height', '100%') + .attr('viewBox', `0 0 ${this.width} ${this.height}`) + .style('border', '1px solid rgba(255, 255, 255, 0.1)') + .style('background', 'rgba(0, 0, 0, 0.2)') + .style('border-radius', '12px'); + + // Add zoom behavior + this.zoom = d3.zoom() + .scaleExtent([0.5, 5]) + .on('zoom', (event) => { + this.svg.select('g').attr('transform', event.transform); + }); + + this.svg.call(this.zoom); + + // Create main group for zoom and apply initial zoom + const mainGroup = this.svg.append('g'); + + // Apply initial zoom to show the graph more zoomed in + mainGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); + + logger.debug('TopologyGraphComponent: SVG setup completed'); + } + + // Ensure component is initialized + async ensureInitialized() { + if (!this.isInitialized) { + logger.debug('TopologyGraphComponent: Ensuring initialization...'); + await this.initialize(); + } + return this.isInitialized; + } + + renderGraph() { + try { + // Check if component is initialized + if (!this.isInitialized) { + logger.debug('TopologyGraphComponent: Component not yet initialized, ensuring initialization...'); + this.ensureInitialized().then(() => { + // Re-render after initialization + this.renderGraph(); + }).catch(error => { + logger.error('TopologyGraphComponent: Failed to initialize:', error); + }); + return; + } + + const nodes = this.viewModel.get('nodes'); + const links = this.viewModel.get('links'); + + // Check if SVG is initialized + if (!this.svg) { + logger.debug('TopologyGraphComponent: SVG not initialized yet, setting up SVG first'); + this.setupSVG(); + } + + if (!nodes || nodes.length === 0) { + this.showNoData(); + return; + } + + logger.debug('TopologyGraphComponent: Rendering graph with', nodes.length, 'nodes and', links.length, 'links'); + + // Get the main SVG group (the one created in setupSVG) + let svgGroup = this.svg.select('g'); + if (!svgGroup || svgGroup.empty()) { + logger.debug('TopologyGraphComponent: Creating new SVG group'); + svgGroup = this.svg.append('g'); + // Apply initial zoom to show the graph more zoomed in + svgGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); + } + + // Clear existing graph elements but preserve the main group and its transform + svgGroup.selectAll('.graph-element').remove(); + + // Create links + const link = svgGroup.append('g') + .attr('class', 'graph-element') + .selectAll('line') + .data(links) + .enter().append('line') + .attr('stroke', d => this.getLinkColor(d.latency)) + .attr('stroke-opacity', 0.7) + .attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8))) + .attr('marker-end', null); + + // Create nodes + const node = svgGroup.append('g') + .attr('class', 'graph-element') + .selectAll('g') + .data(nodes) + .enter().append('g') + .attr('class', 'node') + .call(this.drag(this.simulation)); + + // Add circles to nodes + node.append('circle') + .attr('r', d => this.getNodeRadius(d.status)) + .attr('fill', d => this.getNodeColor(d.status)) + .attr('stroke', '#fff') + .attr('stroke-width', 2); + + // Status indicator + node.append('circle') + .attr('r', 3) + .attr('fill', d => this.getStatusIndicatorColor(d.status)) + .attr('cx', -8) + .attr('cy', -8); + + // Hostname + node.append('text') + .text(d => d.hostname.length > 15 ? d.hostname.substring(0, 15) + '...' : d.hostname) + .attr('x', 15) + .attr('y', 4) + .attr('font-size', '13px') + .attr('fill', '#ecf0f1') + .attr('font-weight', '500'); + + // IP + node.append('text') + .text(d => d.ip) + .attr('x', 15) + .attr('y', 20) + .attr('font-size', '11px') + .attr('fill', 'rgba(255, 255, 255, 0.7)'); + + // Status text + node.append('text') + .text(d => d.status) + .attr('x', 15) + .attr('y', 35) + .attr('font-size', '11px') + .attr('fill', d => this.getNodeColor(d.status)) + .attr('font-weight', '600'); + + // Latency labels on links + const linkLabels = svgGroup.append('g') + .attr('class', 'graph-element') + .selectAll('text') + .data(links) + .enter().append('text') + .attr('font-size', '12px') + .attr('fill', '#ecf0f1') + .attr('font-weight', '600') + .attr('text-anchor', 'middle') + .style('text-shadow', '0 1px 2px rgba(0, 0, 0, 0.8)') + .text(d => `${d.latency}ms`); + + // Simulation + if (!this.simulation) { + this.simulation = d3.forceSimulation(nodes) + .force('link', d3.forceLink(links).id(d => d.id).distance(300)) + .force('charge', d3.forceManyBody().strength(-800)) + .force('center', d3.forceCenter(this.width / 2, this.height / 2)) + .force('collision', d3.forceCollide().radius(80)); + + this.simulation.on('tick', () => { + link + .attr('x1', d => d.source.x) + .attr('y1', d => d.source.y) + .attr('x2', d => d.target.x) + .attr('y2', d => d.target.y); + + linkLabels + .attr('x', d => (d.source.x + d.target.x) / 2) + .attr('y', d => (d.source.y + d.target.y) / 2 - 5); + + node + .attr('transform', d => `translate(${d.x},${d.y})`); + }); + } else { + this.simulation.nodes(nodes); + this.simulation.force('link').links(links); + this.simulation.alpha(0.3).restart(); + } + + // Node interactions + node.on('click', (event, d) => { + this.viewModel.selectNode(d.id); + this.updateSelection(d.id); + this.showMemberCardOverlay(d); + }); + + node.on('mouseover', (event, d) => { + d3.select(event.currentTarget).select('circle') + .attr('r', d => this.getNodeRadius(d.status) + 4) + .attr('stroke-width', 3); + }); + + node.on('mouseout', (event, d) => { + d3.select(event.currentTarget).select('circle') + .attr('r', d => this.getNodeRadius(d.status)) + .attr('stroke-width', 2); + }); + + link.on('mouseover', (event, d) => { + d3.select(event.currentTarget) + .attr('stroke-width', d => Math.max(2, Math.min(4, d.latency / 6))) + .attr('stroke-opacity', 0.9); + }); + + link.on('mouseout', (event, d) => { + d3.select(event.currentTarget) + .attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8))) + .attr('stroke-opacity', 0.7); + }); + + this.addLegend(svgGroup); + } catch (error) { + logger.error('Failed to render graph:', error); + } + } + + addLegend(svgGroup) { + const legend = svgGroup.append('g') + .attr('class', 'graph-element') + .attr('transform', `translate(120, 120)`) // Hidden by CSS opacity + .style('opacity', '0'); + + legend.append('rect') + .attr('width', 320) + .attr('height', 120) + .attr('fill', 'rgba(0, 0, 0, 0.7)') + .attr('rx', 8) + .attr('stroke', 'rgba(255, 255, 255, 0.2)') + .attr('stroke-width', 1); + + const nodeLegend = legend.append('g') + .attr('transform', 'translate(20, 20)'); + + nodeLegend.append('text') + .text('Node Status:') + .attr('x', 0) + .attr('y', 0) + .attr('font-size', '14px') + .attr('font-weight', '600') + .attr('fill', '#ecf0f1'); + + const statuses = [ + { status: 'ACTIVE', color: '#10b981', y: 20 }, + { status: 'INACTIVE', color: '#f59e0b', y: 40 }, + { status: 'DEAD', color: '#ef4444', y: 60 } + ]; + + statuses.forEach(item => { + nodeLegend.append('circle') + .attr('r', 6) + .attr('cx', 0) + .attr('cy', item.y) + .attr('fill', item.color); + + nodeLegend.append('text') + .text(item.status) + .attr('x', 15) + .attr('y', item.y + 4) + .attr('font-size', '12px') + .attr('fill', '#ecf0f1'); + }); + + const linkLegend = legend.append('g') + .attr('transform', 'translate(150, 20)'); + + linkLegend.append('text') + .text('Link Latency:') + .attr('x', 0) + .attr('y', 0) + .attr('font-size', '14px') + .attr('font-weight', '600') + .attr('fill', '#ecf0f1'); + + const latencies = [ + { range: '≤30ms', color: '#10b981', y: 20 }, + { range: '31-50ms', color: '#f59e0b', y: 40 }, + { range: '>50ms', color: '#ef4444', y: 60 } + ]; + + latencies.forEach(item => { + linkLegend.append('line') + .attr('x1', 0) + .attr('y1', item.y) + .attr('x2', 20) + .attr('y2', item.y) + .attr('stroke', item.color) + .attr('stroke-width', 2); + + linkLegend.append('text') + .text(item.range) + .attr('x', 25) + .attr('y', item.y + 4) + .attr('font-size', '12px') + .attr('fill', '#ecf0f1'); + }); + } + + getNodeRadius(status) { + switch (status?.toUpperCase()) { + case 'ACTIVE': + return 10; + case 'INACTIVE': + return 8; + case 'DEAD': + return 6; + default: + return 8; + } + } + + getStatusIndicatorColor(status) { + switch (status?.toUpperCase()) { + case 'ACTIVE': + return '#10b981'; + case 'INACTIVE': + return '#f59e0b'; + case 'DEAD': + return '#ef4444'; + default: + return '#6b7280'; + } + } + + getLinkColor(latency) { + if (latency <= 30) return '#10b981'; + if (latency <= 50) return '#f59e0b'; + return '#ef4444'; + } + + getNodeColor(status) { + switch (status?.toUpperCase()) { + case 'ACTIVE': + return '#10b981'; + case 'INACTIVE': + return '#f59e0b'; + case 'DEAD': + return '#ef4444'; + default: + return '#6b7280'; + } + } + + drag(simulation) { + return d3.drag() + .on('start', function(event, d) { + if (!event.active && simulation && simulation.alphaTarget) { + simulation.alphaTarget(0.3).restart(); + } + d.fx = d.x; + d.fy = d.y; + }) + .on('drag', function(event, d) { + d.fx = event.x; + d.fy = event.y; + }) + .on('end', function(event, d) { + if (!event.active && simulation && simulation.alphaTarget) { + simulation.alphaTarget(0); + } + d.fx = null; + d.fy = null; + }); + } + + updateSelection(selectedNodeId) { + // Update visual selection + if (!this.svg || !this.isInitialized) { + return; + } + + this.svg.selectAll('.node').select('circle') + .attr('stroke-width', d => d.id === selectedNodeId ? 4 : 2) + .attr('stroke', d => d.id === selectedNodeId ? '#2196F3' : '#fff'); + } + + handleRefresh() { + logger.debug('TopologyGraphComponent: handleRefresh called'); + + if (!this.isInitialized) { + logger.debug('TopologyGraphComponent: Component not yet initialized, ensuring initialization...'); + this.ensureInitialized().then(() => { + // Refresh after initialization + this.viewModel.updateNetworkTopology(); + }).catch(error => { + logger.error('TopologyGraphComponent: Failed to initialize for refresh:', error); + }); + return; + } + + logger.debug('TopologyGraphComponent: Calling updateNetworkTopology...'); + this.viewModel.updateNetworkTopology(); + } + + handleLoadingState(isLoading) { + logger.debug('TopologyGraphComponent: handleLoadingState called with:', isLoading); + const container = this.findElement('#topology-graph-container'); + + if (isLoading) { + container.innerHTML = '
Loading network topology...
'; + } + } + + handleError() { + const error = this.viewModel.get('error'); + if (error) { + const container = this.findElement('#topology-graph-container'); + container.innerHTML = `
Error: ${error}
`; + } + } + + showNoData() { + const container = this.findElement('#topology-graph-container'); + container.innerHTML = '
No cluster members found
'; + } + + showMemberCardOverlay(nodeData) { + // Create overlay container if it doesn't exist + let overlayContainer = document.getElementById('member-card-overlay'); + if (!overlayContainer) { + overlayContainer = document.createElement('div'); + overlayContainer.id = 'member-card-overlay'; + overlayContainer.className = 'member-card-overlay'; + document.body.appendChild(overlayContainer); + } + + // Create and show the overlay component + if (!this.memberOverlayComponent) { + const overlayVM = new ViewModel(); + this.memberOverlayComponent = new MemberCardOverlayComponent(overlayContainer, overlayVM, this.eventBus); + this.memberOverlayComponent.mount(); + } + + // Convert node data to member data format + const memberData = { + ip: nodeData.ip, + hostname: nodeData.hostname, + status: this.normalizeStatus(nodeData.status), + latency: nodeData.latency, + labels: nodeData.resources || {} + }; + + this.memberOverlayComponent.show(memberData); + } + + // Normalize status from topology format to member card format + normalizeStatus(status) { + if (!status) return 'unknown'; + + const normalized = status.toLowerCase(); + switch (normalized) { + case 'active': + return 'active'; + case 'inactive': + return 'inactive'; + case 'dead': + return 'offline'; + default: + return 'unknown'; + } + } + + // Override render method to display the graph + render() { + logger.debug('TopologyGraphComponent: render called'); + if (!this.isInitialized) { + logger.debug('TopologyGraphComponent: Not initialized yet, skipping render'); + return; + } + const nodes = this.viewModel.get('nodes'); + const links = this.viewModel.get('links'); + if (nodes && nodes.length > 0) { + logger.debug('TopologyGraphComponent: Rendering graph with data'); + this.renderGraph(); + } else { + logger.debug('TopologyGraphComponent: No data available, showing loading state'); + this.handleLoadingState(true); + } + } + + unmount() { + // Clean up resize listener + if (this.resizeHandler) { + window.removeEventListener('resize', this.resizeHandler); + this.resizeHandler = null; + } + + // Clear resize timeout + if (this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = null; + } + + // Call parent unmount + super.unmount(); + } +} + +// Minimal Member Card Overlay Component (kept in same file to avoid circular loads) +class MemberCardOverlayComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + this.isVisible = false; + this.currentMember = null; + } + + mount() { + super.mount(); + this.setupEventListeners(); + } + + setupEventListeners() { + // Close overlay when clicking outside or pressing escape + this.addEventListener(this.container, 'click', (e) => { + if (!this.isVisible) return; + if (e.target === this.container) { + this.hide(); + } + }); + + this.addEventListener(document, 'keydown', (e) => { + if (e.key === 'Escape' && this.isVisible) { + this.hide(); + } + }); + } + + show(memberData) { + this.currentMember = memberData; + this.isVisible = true; + + const memberCardHTML = this.renderMemberCard(memberData); + this.setHTML('', memberCardHTML); + + setTimeout(() => { + this.container.classList.add('visible'); + }, 10); + + this.setupMemberCardInteractions(); + } + + hide() { + this.isVisible = false; + this.container.classList.remove('visible'); + this.currentMember = null; + } + + renderMemberCard(member) { + const statusClass = member.status === 'active' ? 'status-online' : + member.status === 'inactive' ? 'status-inactive' : 'status-offline'; + const statusIcon = member.status === 'active' ? '🟢' : + member.status === 'inactive' ? '🟠' : '🔴'; + + return ` +
+
+
+
+
+
+ ${statusIcon} +
+
${member.hostname || 'Unknown Device'}
+
+
${member.ip || 'No IP'}
+
+ Latency: + ${member.latency ? member.latency + 'ms' : 'N/A'} +
+
+ +
+ +
+ +
+
+
+
Loading detailed information...
+
+
+
+
+ `; + } + + setupMemberCardInteractions() { + const closeBtn = this.findElement('.member-overlay-close'); + if (closeBtn) { + this.addEventListener(closeBtn, 'click', () => { + this.hide(); + }); + } + + setTimeout(async () => { + const memberCard = this.findElement('.member-card'); + if (memberCard) { + const memberDetails = memberCard.querySelector('.member-details'); + const memberIp = memberCard.dataset.memberIp; + await this.expandCard(memberCard, memberIp, memberDetails); + } + }, 100); + } + + async expandCard(card, memberIp, memberDetails) { + try { + const nodeDetailsVM = new NodeDetailsViewModel(); + const nodeDetailsComponent = new NodeDetailsComponent(memberDetails, nodeDetailsVM, this.eventBus); + await nodeDetailsVM.loadNodeDetails(memberIp); + + const nodeStatus = nodeDetailsVM.get('nodeStatus'); + if (nodeStatus && nodeStatus.labels) { + const labelsContainer = document.querySelector('.member-overlay-header .member-labels'); + if (labelsContainer) { + labelsContainer.innerHTML = Object.entries(nodeStatus.labels) + .map(([key, value]) => `${key}: ${value}`) + .join(''); + labelsContainer.style.display = 'block'; + } + } + nodeDetailsComponent.mount(); + card.classList.add('expanded'); + } catch (error) { + logger.error('Failed to expand member card:', error); + card.classList.add('expanded'); + const details = card.querySelector('.member-details'); + if (details) { + details.innerHTML = '
Failed to load node details
'; + } + } + } +} + +window.TopologyGraphComponent = TopologyGraphComponent; +window.MemberCardOverlayComponent = MemberCardOverlayComponent; \ No newline at end of file From 8b0267ea2a2d780538988fdc9dd7bb242c73557c Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 14:14:11 +0200 Subject: [PATCH 11/18] fix(components): correct JS operators in FirmwareComponent; reorder script tags to ensure FirmwareComponent loads before FirmwareViewComponent --- public/index.html | 17 ++++------------- public/scripts/components/FirmwareComponent.js | 6 +++--- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/public/index.html b/public/index.html index dafb6af..045a009 100644 --- a/public/index.html +++ b/public/index.html @@ -71,17 +71,6 @@
- -
@@ -147,12 +136,14 @@ + - + - + + diff --git a/public/scripts/components/FirmwareComponent.js b/public/scripts/components/FirmwareComponent.js index 2f8c07b..6473125 100644 --- a/public/scripts/components/FirmwareComponent.js +++ b/public/scripts/components/FirmwareComponent.js @@ -432,7 +432,7 @@ class FirmwareComponent extends Component { const progressBar = this.findElement('#overall-progress-bar'); const progressText = this.findElement('.progress-text'); - if (progressBar and progressText) { + if (progressBar && progressText) { const successPercentage = Math.round((successfulUploads / totalNodes) * 100); progressBar.style.width = `${successPercentage}%`; progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`; @@ -448,7 +448,7 @@ class FirmwareComponent extends Component { // Update progress summary for single-node uploads const progressSummary = this.findElement('#progress-summary'); - if (progressSummary and totalNodes === 1) { + if (progressSummary && totalNodes === 1) { if (successfulUploads === 1) { progressSummary.innerHTML = 'Status: Upload completed successfully'; } else if (successfulUploads === 0) { @@ -462,7 +462,7 @@ class FirmwareComponent extends Component { const progressHeader = this.findElement('.progress-header h3'); const progressSummary = this.findElement('#progress-summary'); - if (progressHeader and progressSummary) { + if (progressHeader && progressSummary) { const successCount = results.filter(r => r.success).length; const totalCount = results.length; const successRate = Math.round((successCount / totalCount) * 100); From ac6c2fbb80ecaeac1d44698a15fc7b4de458f3a6 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 14:27:33 +0200 Subject: [PATCH 12/18] perf(startup): remove blocking components loader wait; defer component instantiation until navigation; trigger initial cluster load immediately --- public/scripts/app.js | 9 +-------- public/scripts/components/ClusterViewComponent.js | 15 +++++++-------- public/scripts/framework.js | 14 +++++++++++--- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/public/scripts/app.js b/public/scripts/app.js index 1ee982a..ed17044 100644 --- a/public/scripts/app.js +++ b/public/scripts/app.js @@ -8,14 +8,7 @@ document.addEventListener('DOMContentLoaded', async function() { logger.debug('App: Creating framework instance...'); const app = window.app; - // Wait for components to be ready (loader ensures constructors exist) - try { - if (typeof window.waitForComponentsReady === 'function') { - await window.waitForComponentsReady(); - } - } catch (e) { - logger.warn('App: Components loader timeout; proceeding anyway'); - } + // Components are loaded via script tags in order; no blocking wait required // Create view models logger.debug('App: Creating view models...'); diff --git a/public/scripts/components/ClusterViewComponent.js b/public/scripts/components/ClusterViewComponent.js index 1164b02..f341e2e 100644 --- a/public/scripts/components/ClusterViewComponent.js +++ b/public/scripts/components/ClusterViewComponent.js @@ -49,18 +49,17 @@ class ClusterViewComponent extends Component { // Only load data if we haven't already or if the view model is empty const members = this.viewModel.get('members'); - const shouldLoadData = !this.dataLoaded || !members || members.length === 0; + const shouldLoadData = true; // always perform initial refresh quickly if (shouldLoadData) { logger.debug('ClusterViewComponent: Starting initial data load...'); // Initial data load - ensure it happens after mounting - setTimeout(() => { - this.viewModel.updateClusterMembers().then(() => { - this.dataLoaded = true; - }).catch(error => { - logger.error('ClusterViewComponent: Failed to load initial data:', error); - }); - }, 100); + // Trigger immediately to reduce perceived startup latency + this.viewModel.updateClusterMembers().then(() => { + this.dataLoaded = true; + }).catch(error => { + logger.error('ClusterViewComponent: Failed to load initial data:', error); + }); } else { logger.debug('ClusterViewComponent: Data already loaded, skipping initial load'); } diff --git a/public/scripts/framework.js b/public/scripts/framework.js index d1d32bd..c33d474 100644 --- a/public/scripts/framework.js +++ b/public/scripts/framework.js @@ -635,8 +635,8 @@ class App { 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); + // Defer instantiation until navigation to reduce startup work + // this.preInitializeComponent(name, componentClass, containerId, viewModel); } // Pre-initialize component in cache @@ -771,7 +771,15 @@ class App { async showView(routeName, component) { const container = component.container; - // Ensure component is mounted (but not necessarily active) + // Ensure component is mounted (but not necessarily active); lazy-create now if needed + if (!component) { + const route = this.routes.get(routeName); + const container = document.getElementById(route.containerId); + component = new route.componentClass(container, route.viewModel, this.eventBus); + component.routeName = routeName; + component.isCached = true; + this.componentCache.set(routeName, component); + } if (!component.isMounted) { logger.debug(`App: Mounting component for '${routeName}'`); component.mount(); From 2f271f4b294e32a108e8c0b843eab6730f3c40c5 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 16:42:29 +0200 Subject: [PATCH 13/18] style(mobile): reduce horizontal padding and tighten spacing to maximize usable width on small screens --- public/styles/main.css | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/public/styles/main.css b/public/styles/main.css index c16d546..823e15e 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -3282,4 +3282,45 @@ html { -webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */ scrollbar-width: thin; /* Thin scrollbar on Firefox */ scrollbar-color: rgba(255, 255, 255, 0.3) rgba(255, 255, 255, 0.1); /* Firefox scrollbar colors */ +} + +/* Mobile width optimization: maximize horizontal space */ +@media (max-width: 768px) { + body { + padding: 0.5rem; + } + .container { + padding: 0 0.5rem; + max-height: calc(100vh - 1rem); + } + .cluster-section, + .firmware-section { + padding: 0.5rem; + } + .main-navigation { + padding: 0.25rem; + } + .nav-tab { + padding: 0.6rem 0.8rem; + } + .members-grid { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 0.5rem; + } +} + +@media (max-width: 480px) { + body { + padding: 0.25rem; + } + .container { + padding: 0 0.25rem; + max-height: calc(100vh - 0.5rem); + } + .member-card { + padding: 0.75rem; + } + .main-navigation { + padding: 0.25rem; + } } \ No newline at end of file From ef40bf1ee2bf4e9d27fb6a07cee7e91389f8706d Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 17:23:24 +0200 Subject: [PATCH 14/18] fix: flicker on mobile member card --- public/scripts/index.js | 1 + public/styles/main.css | 12 ++++++++++++ 2 files changed, 13 insertions(+) create mode 100644 public/scripts/index.js diff --git a/public/scripts/index.js b/public/scripts/index.js new file mode 100644 index 0000000..354a787 --- /dev/null +++ b/public/scripts/index.js @@ -0,0 +1 @@ +// intentionally empty placeholder \ No newline at end of file diff --git a/public/styles/main.css b/public/styles/main.css index 823e15e..5d10472 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -244,6 +244,7 @@ p { margin-bottom: 0.5rem; opacity: 1; z-index: 1; + -webkit-tap-highlight-color: transparent; } /* Labels */ @@ -288,6 +289,17 @@ p { z-index: 2; } +/* Disable hover effects on touch devices to prevent flicker */ +@media (hover: none) { + .member-card:hover::before { + opacity: 0 !important; + } + .member-card:hover { + box-shadow: none !important; + z-index: 1 !important; + } +} + .member-card.expanded { border-color: rgba(255, 255, 255, 0.2); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); From a4736948f5370312b54698e6562bf931bcec4d2e Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 17:46:48 +0200 Subject: [PATCH 15/18] UI (mobile): fix member tile flicker on touch; compact primary node header; unify to single page scroll on mobile --- public/styles/main.css | 47 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/public/styles/main.css b/public/styles/main.css index 5d10472..5b7cc6f 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -3335,4 +3335,51 @@ html { .main-navigation { padding: 0.25rem; } +} + +@media (max-width: 768px) { + /* Use single scroll on mobile: let the page/body scroll */ + body { + height: auto; + min-height: 100vh; + overflow-y: auto; + } + .container { + max-height: none; + overflow: visible; + } + .view-content, + #cluster-view.active { + max-height: none; + overflow: visible; + } + #cluster-members-container, + #firmware-container, + .firmware-nodes-list { + max-height: none; + overflow: visible; + padding-right: 0; + } +} + +@media (max-width: 480px) { + /* Make primary node section more compact */ + .cluster-header { + gap: 0.5rem; + padding: 0.5rem 0; + } + .primary-node-info { + gap: 0.35rem; + padding: 0.35rem 0.5rem; + } + .primary-node-label { + font-size: 0.8rem; + } + .primary-node-ip { + font-size: 0.85rem; + padding: 0.2rem 0.4rem; + } + .primary-node-refresh { + padding: 0.3rem; + } } \ No newline at end of file From ab20128008e708a153acb7f98659cf442fd258cb Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 17:51:04 +0200 Subject: [PATCH 16/18] UI (mobile): prevent firmware view flicker on touch by disabling hover effects and tap highlight --- public/styles/main.css | 44 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/public/styles/main.css b/public/styles/main.css index 5b7cc6f..41986c4 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -3382,4 +3382,48 @@ html { .primary-node-refresh { padding: 0.3rem; } +} + +/* Reduce tap highlight and flicker in firmware view */ +#firmware-view, +.upload-btn, +.upload-btn-compact, +.deploy-btn, +.cap-call-btn, +.progress-refresh-btn, +.clear-btn, +.refresh-btn, +.progress-item, +.result-item, +.file-info { + -webkit-tap-highlight-color: transparent; +} + +/* Disable hover-driven animations/effects on touch devices in firmware view */ +@media (hover: none) { + #firmware-view .upload-btn:hover, + #firmware-view .upload-btn-compact:hover, + #firmware-view .deploy-btn:hover:not(:disabled), + #firmware-view .progress-refresh-btn:hover, + #firmware-view .cap-call-btn:hover, + #firmware-view .clear-btn:hover, + #firmware-view .refresh-btn:hover, + #firmware-view .progress-item:hover, + #firmware-view .result-item:hover, + #firmware-view .firmware-upload-progress:hover, + #firmware-view .firmware-upload-results:hover, + #firmware-view .file-info:hover { + transform: none !important; + box-shadow: none !important; + } + + #firmware-view .progress-item:hover::before, + #firmware-view .result-item:hover::before { + opacity: 0 !important; + } + + /* Prevent shimmer animation on deploy button hover */ + #firmware-view .deploy-btn:hover:not(:disabled)::before { + left: -100% !important; + } } \ No newline at end of file From d49a586eb0a380c28d639022593649a27f96de6a Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 18:20:45 +0200 Subject: [PATCH 17/18] chore: remove obsolete components file --- public/scripts/components.js | 3125 ---------------------------------- 1 file changed, 3125 deletions(-) delete mode 100644 public/scripts/components.js diff --git a/public/scripts/components.js b/public/scripts/components.js deleted file mode 100644 index dea26ac..0000000 --- a/public/scripts/components.js +++ /dev/null @@ -1,3125 +0,0 @@ -// SPORE UI Components - -// Primary Node Component -class PrimaryNodeComponent extends Component { - constructor(container, viewModel, eventBus) { - super(container, viewModel, eventBus); - } - - setupEventListeners() { - const refreshBtn = this.findElement('.primary-node-refresh'); - if (refreshBtn) { - this.addEventListener(refreshBtn, 'click', this.handleRandomSelection.bind(this)); - } - } - - setupViewModelListeners() { - // Listen to primary node changes - this.subscribeToProperty('primaryNode', this.render.bind(this)); - this.subscribeToProperty('clientInitialized', this.render.bind(this)); - this.subscribeToProperty('totalNodes', this.render.bind(this)); - this.subscribeToProperty('onlineNodes', this.render.bind(this)); - this.subscribeToProperty('error', this.render.bind(this)); - } - - render() { - const primaryNode = this.viewModel.get('primaryNode'); - const clientInitialized = this.viewModel.get('clientInitialized'); - const totalNodes = this.viewModel.get('totalNodes'); - const onlineNodes = this.viewModel.get('onlineNodes'); - const error = this.viewModel.get('error'); - - if (error) { - this.setText('#primary-node-ip', '❌ Discovery Failed'); - this.setClass('#primary-node-ip', 'error', true); - this.setClass('#primary-node-ip', 'discovering', false); - this.setClass('#primary-node-ip', 'selecting', false); - return; - } - - if (!primaryNode) { - this.setText('#primary-node-ip', '🔍 No Nodes Found'); - this.setClass('#primary-node-ip', 'error', true); - this.setClass('#primary-node-ip', 'discovering', false); - this.setClass('#primary-node-ip', 'selecting', false); - return; - } - - const status = clientInitialized ? '✅' : '⚠️'; - const nodeCount = (onlineNodes && onlineNodes > 0) - ? ` (${onlineNodes}/${totalNodes} online)` - : (totalNodes > 1 ? ` (${totalNodes} nodes)` : ''); - - this.setText('#primary-node-ip', `${status} ${primaryNode}${nodeCount}`); - this.setClass('#primary-node-ip', 'error', false); - this.setClass('#primary-node-ip', 'discovering', false); - this.setClass('#primary-node-ip', 'selecting', false); - } - - async handleRandomSelection() { - try { - // Show selecting state - this.setText('#primary-node-ip', '🎲 Selecting...'); - this.setClass('#primary-node-ip', 'selecting', true); - this.setClass('#primary-node-ip', 'discovering', false); - this.setClass('#primary-node-ip', 'error', false); - - await this.viewModel.selectRandomPrimaryNode(); - - // Show success briefly - this.setText('#primary-node-ip', '🎯 Selection Complete'); - - // Update display after delay - setTimeout(() => { - this.viewModel.updatePrimaryNodeDisplay(); - }, 1500); - - } catch (error) { - logger.error('Failed to select random primary node:', error); - this.setText('#primary-node-ip', '❌ Selection Failed'); - this.setClass('#primary-node-ip', 'error', true); - this.setClass('#primary-node-ip', 'selecting', false); - this.setClass('#primary-node-ip', 'discovering', false); - - // Revert to normal display after error - setTimeout(() => { - this.viewModel.updatePrimaryNodeDisplay(); - }, 2000); - } - } -} - -// Cluster Members Component with enhanced state preservation -class ClusterMembersComponent extends Component { - constructor(container, viewModel, eventBus) { - super(container, viewModel, eventBus); - - logger.debug('ClusterMembersComponent: Constructor called'); - logger.debug('ClusterMembersComponent: Container:', container); - logger.debug('ClusterMembersComponent: Container ID:', container?.id); - logger.debug('ClusterMembersComponent: Container innerHTML:', container?.innerHTML); - - // Track if we're in the middle of a render operation - this.renderInProgress = false; - this.lastRenderData = null; - - // Ensure initial render happens even if no data - setTimeout(() => { - if (this.isMounted && !this.renderInProgress) { - logger.debug('ClusterMembersComponent: Performing initial render check'); - this.render(); - } - }, 200); - } - - mount() { - logger.debug('ClusterMembersComponent: Starting mount...'); - super.mount(); - - // Show loading state immediately when mounted - logger.debug('ClusterMembersComponent: Showing initial loading state'); - this.showLoadingState(); - - // Set up loading timeout safeguard - this.setupLoadingTimeout(); - - logger.debug('ClusterMembersComponent: Mounted successfully'); - } - - // Setup loading timeout safeguard to prevent getting stuck in loading state - setupLoadingTimeout() { - this.loadingTimeout = setTimeout(() => { - const isLoading = this.viewModel.get('isLoading'); - if (isLoading) { - logger.warn('ClusterMembersComponent: Loading timeout reached, forcing render check'); - this.forceRenderCheck(); - } - }, 10000); // 10 second timeout - } - - // Force a render check when loading gets stuck - forceRenderCheck() { - logger.debug('ClusterMembersComponent: Force render check called'); - const members = this.viewModel.get('members'); - const error = this.viewModel.get('error'); - const isLoading = this.viewModel.get('isLoading'); - - logger.debug('ClusterMembersComponent: Force render check state:', { members, error, isLoading }); - - if (error) { - this.showErrorState(error); - } else if (members && members.length > 0) { - this.renderMembers(members); - } else if (!isLoading) { - this.showEmptyState(); - } - } - - setupEventListeners() { - logger.debug('ClusterMembersComponent: Setting up event listeners...'); - // Note: Refresh button is now handled by ClusterViewComponent - // since it's in the cluster header, not in the members container - } - - setupViewModelListeners() { - logger.debug('ClusterMembersComponent: Setting up view model listeners...'); - // Listen to cluster members changes with change detection - this.subscribeToProperty('members', this.handleMembersUpdate.bind(this)); - this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this)); - this.subscribeToProperty('error', this.handleErrorUpdate.bind(this)); - logger.debug('ClusterMembersComponent: View model listeners set up'); - } - - // Handle members update with state preservation - handleMembersUpdate(newMembers, previousMembers) { - logger.debug('ClusterMembersComponent: Members updated:', { newMembers, previousMembers }); - - // Prevent multiple simultaneous renders - if (this.renderInProgress) { - logger.debug('ClusterMembersComponent: Render already in progress, skipping update'); - return; - } - - // Check if we're currently loading - if so, let the loading handler deal with it - const isLoading = this.viewModel.get('isLoading'); - if (isLoading) { - logger.debug('ClusterMembersComponent: Currently loading, skipping members update (will be handled by loading completion)'); - return; - } - - // On first load (no previous members), always render - if (!previousMembers || !Array.isArray(previousMembers) || previousMembers.length === 0) { - logger.debug('ClusterMembersComponent: First load or no previous members, performing full render'); - this.render(); - return; - } - - if (this.shouldPreserveState(newMembers, previousMembers)) { - // Perform partial update to preserve UI state - logger.debug('ClusterMembersComponent: Preserving state, performing partial update'); - this.updateMembersPartially(newMembers, previousMembers); - } else { - // Full re-render if structure changed significantly - logger.debug('ClusterMembersComponent: Structure changed, performing full re-render'); - this.render(); - } - } - - // Handle loading state update - handleLoadingUpdate(isLoading) { - logger.debug('ClusterMembersComponent: Loading state changed:', isLoading); - - if (isLoading) { - logger.debug('ClusterMembersComponent: Showing loading state'); - this.renderLoading(`\n
\n
Loading cluster members...
\n
\n `); - - // Set up a loading completion check - this.checkLoadingCompletion(); - } else { - logger.debug('ClusterMembersComponent: Loading completed, checking if we need to render'); - // When loading completes, check if we have data to render - this.handleLoadingCompletion(); - } - } - - // Check if loading has completed and handle accordingly - handleLoadingCompletion() { - const members = this.viewModel.get('members'); - const error = this.viewModel.get('error'); - const isLoading = this.viewModel.get('isLoading'); - - logger.debug('ClusterMembersComponent: Handling loading completion:', { members, error, isLoading }); - - if (error) { - logger.debug('ClusterMembersComponent: Loading completed with error, showing error state'); - this.showErrorState(error); - } else if (members && members.length > 0) { - logger.debug('ClusterMembersComponent: Loading completed with data, rendering members'); - this.renderMembers(members); - } else if (!isLoading) { - logger.debug('ClusterMembersComponent: Loading completed but no data, showing empty state'); - this.showEmptyState(); - } - } - - // Set up a check to ensure loading completion is handled - checkLoadingCompletion() { - // Clear any existing completion check - if (this.loadingCompletionCheck) { - clearTimeout(this.loadingCompletionCheck); - } - - // Set up a completion check that runs after a short delay - this.loadingCompletionCheck = setTimeout(() => { - const isLoading = this.viewModel.get('isLoading'); - if (!isLoading) { - logger.debug('ClusterMembersComponent: Loading completion check triggered'); - this.handleLoadingCompletion(); - } - }, 1000); // Check after 1 second - } - - // Handle error state update - handleErrorUpdate(error) { - if (error) { - this.showErrorState(error); - } - } - - // Check if we should preserve UI state during update - shouldPreserveState(newMembers, previousMembers) { - if (!previousMembers || !Array.isArray(previousMembers)) return false; - if (!Array.isArray(newMembers)) return false; - - // If member count changed, we need to re-render - if (newMembers.length !== previousMembers.length) return false; - - // Check if member IPs are the same (same nodes) - const newIps = new Set(newMembers.map(m => m.ip)); - const prevIps = new Set(previousMembers.map(m => m.ip)); - - // If IPs are the same, we can preserve state - return newIps.size === prevIps.size && - [...newIps].every(ip => prevIps.has(ip)); - } - - // Check if we should skip rendering during view switches - shouldSkipRender() { - // Rely on lifecycle flags controlled by App - if (!this.isMounted || this.isPaused) { - logger.debug('ClusterMembersComponent: Not mounted or paused, skipping render'); - return true; - } - return false; - } - - // Update members partially to preserve UI state - updateMembersPartially(newMembers, previousMembers) { - logger.debug('ClusterMembersComponent: Performing partial update to preserve UI state'); - - // Build previous map by IP for stable diffs - const prevByIp = new Map((previousMembers || []).map(m => [m.ip, m])); - newMembers.forEach((newMember) => { - const prevMember = prevByIp.get(newMember.ip); - if (prevMember && this.hasMemberChanged(newMember, prevMember)) { - this.updateMemberCard(newMember); - } - }); - } - - // Check if a specific member has changed - hasMemberChanged(newMember, prevMember) { - return newMember.status !== prevMember.status || - newMember.latency !== prevMember.latency || - newMember.hostname !== prevMember.hostname; - } - - // Update a specific member card without re-rendering the entire component - updateMemberCard(member) { - const card = this.findElement(`[data-member-ip="${member.ip}"]`); - if (!card) return; - - // Update status - const statusElement = card.querySelector('.member-status'); - if (statusElement) { - const statusClass = member.status === 'active' ? 'status-online' : 'status-offline'; - const statusIcon = member.status === 'active' ? '🟢' : '🔴'; - - statusElement.className = `member-status ${statusClass}`; - statusElement.innerHTML = `${statusIcon}`; - } - - // Update latency - const latencyElement = card.querySelector('.latency-value'); - if (latencyElement) { - latencyElement.textContent = member.latency ? member.latency + 'ms' : 'N/A'; - } - - // Update hostname if changed - const hostnameElement = card.querySelector('.member-hostname'); - if (hostnameElement && member.hostname !== hostnameElement.textContent) { - hostnameElement.textContent = member.hostname || 'Unknown Device'; - } - } - - render() { - if (this.renderInProgress) { - logger.debug('ClusterMembersComponent: Render already in progress, skipping'); - return; - } - - // Check if we should skip rendering during view switches - if (this.shouldSkipRender()) { - return; - } - - this.renderInProgress = true; - - try { - logger.debug('ClusterMembersComponent: render() called'); - logger.debug('ClusterMembersComponent: Container element:', this.container); - logger.debug('ClusterMembersComponent: Is mounted:', this.isMounted); - - const members = this.viewModel.get('members'); - const isLoading = this.viewModel.get('isLoading'); - const error = this.viewModel.get('error'); - - logger.debug('ClusterMembersComponent: render data:', { members, isLoading, error }); - - if (isLoading) { - logger.debug('ClusterMembersComponent: Showing loading state'); - this.showLoadingState(); - return; - } - - if (error) { - logger.debug('ClusterMembersComponent: Showing error state'); - this.showErrorState(error); - return; - } - - if (!members || members.length === 0) { - logger.debug('ClusterMembersComponent: Showing empty state'); - this.showEmptyState(); - return; - } - - logger.debug('ClusterMembersComponent: Rendering members:', members); - this.renderMembers(members); - - } finally { - this.renderInProgress = false; - } - } - - // Show loading state - showLoadingState() { - logger.debug('ClusterMembersComponent: showLoadingState() called'); - this.renderLoading(` -
-
Loading cluster members...
-
- `); - } - - // Show error state - showErrorState(error) { - logger.debug('ClusterMembersComponent: showErrorState() called with error:', error); - this.renderError(`Error loading cluster members: ${error}`); - } - - // Show empty state - showEmptyState() { - logger.debug('ClusterMembersComponent: showEmptyState() called'); - this.renderEmpty(` -
-
🌐
-
No cluster members found
-
- The cluster might be empty or not yet discovered -
-
- `); - } - - renderMembers(members) { - logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members'); - - const membersHTML = members.map(member => { - const statusClass = member.status === 'active' ? 'status-online' : 'status-offline'; - const statusText = member.status === 'active' ? 'Online' : 'Offline'; - const statusIcon = member.status === 'active' ? '🟢' : '🔴'; - - logger.debug('ClusterMembersComponent: Rendering member:', member); - - return ` -
-
-
-
-
-
- ${statusIcon} -
-
${this.escapeHtml(member.hostname || 'Unknown Device')}
-
-
${this.escapeHtml(member.ip || 'No IP')}
-
- Latency: - ${member.latency ? member.latency + 'ms' : 'N/A'} -
-
- ${member.labels && Object.keys(member.labels).length ? ` -
-
- ${Object.entries(member.labels).map(([key, value]) => `${this.escapeHtml(key)}: ${this.escapeHtml(value)}`).join('')} -
-
- ` : ''} -
-
- - - -
-
-
-
Loading detailed information...
-
-
- `; - }).join(''); - - logger.debug('ClusterMembersComponent: Setting HTML, length:', membersHTML.length); - this.setHTML('', membersHTML); - logger.debug('ClusterMembersComponent: HTML set, setting up member cards...'); - this.setupMemberCards(members); - } - - setupMemberCards(members) { - setTimeout(() => { - this.findAllElements('.member-card').forEach((card, index) => { - const expandIcon = card.querySelector('.expand-icon'); - const memberDetails = card.querySelector('.member-details'); - const memberIp = card.dataset.memberIp; - - // Ensure all cards start collapsed by default - card.classList.remove('expanded'); - if (expandIcon) { - expandIcon.classList.remove('expanded'); - } - - // Clear any previous content - memberDetails.innerHTML = '
Loading detailed information...
'; - - // Make the entire card clickable - this.addEventListener(card, 'click', async (e) => { - if (e.target === expandIcon) return; - - const isExpanding = !card.classList.contains('expanded'); - - if (isExpanding) { - await this.expandCard(card, memberIp, memberDetails); - } else { - this.collapseCard(card, expandIcon); - } - }); - - // Keep the expand icon click handler for visual feedback - if (expandIcon) { - this.addEventListener(expandIcon, 'click', async (e) => { - e.stopPropagation(); - - const isExpanding = !card.classList.contains('expanded'); - - if (isExpanding) { - await this.expandCard(card, memberIp, memberDetails); - } else { - this.collapseCard(card, expandIcon); - } - }); - } - }); - }, 100); - } - - async expandCard(card, memberIp, memberDetails) { - try { - // Create node details view model and component - const nodeDetailsVM = new NodeDetailsViewModel(); - const nodeDetailsComponent = new NodeDetailsComponent(memberDetails, nodeDetailsVM, this.eventBus); - - // Load node details - await nodeDetailsVM.loadNodeDetails(memberIp); - - // Mount the component - nodeDetailsComponent.mount(); - - // Update UI - card.classList.add('expanded'); - const expandIcon = card.querySelector('.expand-icon'); - if (expandIcon) { - expandIcon.classList.add('expanded'); - } - - } catch (error) { - logger.error('Failed to expand card:', error); - memberDetails.innerHTML = ` -
- Error loading node details:
- ${error.message} -
- `; - } - } - - collapseCard(card, expandIcon) { - card.classList.remove('expanded'); - if (expandIcon) { - expandIcon.classList.remove('expanded'); - } - } - - setupTabs(container) { - super.setupTabs(container, { - onChange: (targetTab) => { - const memberCard = container.closest('.member-card'); - if (memberCard) { - const memberIp = memberCard.dataset.memberIp; - this.viewModel.storeActiveTab(memberIp, targetTab); - } - } - }); - } - - // Restore active tab state - restoreActiveTab(container, activeTab) { - const tabButtons = container.querySelectorAll('.tab-button'); - const tabContents = container.querySelectorAll('.tab-content'); - - // 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 the restored tab - const activeButton = container.querySelector(`[data-tab="${activeTab}"]`); - const activeContent = container.querySelector(`#${activeTab}-tab`); - - if (activeButton) activeButton.classList.add('active'); - if (activeContent) activeContent.classList.add('active'); - } - - // Note: handleRefresh method has been moved to ClusterViewComponent - // since the refresh button is in the cluster header, not in the members container - - // Debug method to check component state - debugState() { - const members = this.viewModel.get('members'); - const isLoading = this.viewModel.get('isLoading'); - const error = this.viewModel.get('error'); - const expandedCards = this.viewModel.get('expandedCards'); - const activeTabs = this.viewModel.get('activeTabs'); - - logger.debug('ClusterMembersComponent: Debug State:', { - isMounted: this.isMounted, - container: this.container, - members: members, - membersCount: members?.length || 0, - isLoading: isLoading, - error: error, - expandedCardsCount: expandedCards?.size || 0, - activeTabsCount: activeTabs?.size || 0, - loadingTimeout: this.loadingTimeout - }); - - return { members, isLoading, error, expandedCards, activeTabs }; - } - - // Manual refresh method that bypasses potential state conflicts - async manualRefresh() { - logger.debug('ClusterMembersComponent: Manual refresh called'); - - try { - // Clear any existing loading state - this.viewModel.set('isLoading', false); - this.viewModel.set('error', null); - - // Force a fresh data load - await this.viewModel.updateClusterMembers(); - - logger.debug('ClusterMembersComponent: Manual refresh completed'); - } catch (error) { - logger.error('ClusterMembersComponent: Manual refresh failed:', error); - this.showErrorState(error.message); - } - } - - unmount() { - if (!this.isMounted) return; - - this.isMounted = false; - - // Clear any pending timeouts - if (this.loadingTimeout) { - clearTimeout(this.loadingTimeout); - this.loadingTimeout = null; - } - - if (this.loadingCompletionCheck) { - clearTimeout(this.loadingCompletionCheck); - this.loadingCompletionCheck = null; - } - - // Clear any pending render operations - this.renderInProgress = false; - - this.cleanupEventListeners(); - this.cleanupViewModelListeners(); - - logger.debug(`${this.constructor.name} unmounted`); - } - - // Override pause method to handle timeouts and operations - onPause() { - logger.debug('ClusterMembersComponent: Pausing...'); - - // Clear any pending timeouts - if (this.loadingTimeout) { - clearTimeout(this.loadingTimeout); - this.loadingTimeout = null; - } - - if (this.loadingCompletionCheck) { - clearTimeout(this.loadingCompletionCheck); - this.loadingCompletionCheck = null; - } - - // Mark as paused to prevent new operations - this.isPaused = true; - } - - // Override resume method to restore functionality - onResume() { - logger.debug('ClusterMembersComponent: Resuming...'); - - this.isPaused = false; - - // Re-setup loading timeout if needed - if (!this.loadingTimeout) { - this.setupLoadingTimeout(); - } - - // Check if we need to handle any pending operations - this.checkPendingOperations(); - } - - // Check for any operations that need to be handled after resume - checkPendingOperations() { - const isLoading = this.viewModel.get('isLoading'); - const members = this.viewModel.get('members'); - - // If we were loading and it completed while paused, handle the completion - if (!isLoading && members && members.length > 0) { - logger.debug('ClusterMembersComponent: Handling pending loading completion after resume'); - this.handleLoadingCompletion(); - } - } - - // Override to determine if re-render is needed on resume - shouldRenderOnResume() { - // Don't re-render on resume - maintain current state - return false; - } -} - -// Node Details Component with enhanced state preservation -class NodeDetailsComponent extends Component { - constructor(container, viewModel, eventBus) { - super(container, viewModel, eventBus); - } - - setupViewModelListeners() { - this.subscribeToProperty('nodeStatus', this.handleNodeStatusUpdate.bind(this)); - this.subscribeToProperty('tasks', this.handleTasksUpdate.bind(this)); - this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this)); - this.subscribeToProperty('error', this.handleErrorUpdate.bind(this)); - this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this)); - this.subscribeToProperty('capabilities', this.handleCapabilitiesUpdate.bind(this)); - } - - // Handle node status update with state preservation - handleNodeStatusUpdate(newStatus, previousStatus) { - if (newStatus && !this.viewModel.get('isLoading')) { - this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('capabilities')); - } - } - - // Handle tasks update with state preservation - handleTasksUpdate(newTasks, previousTasks) { - const nodeStatus = this.viewModel.get('nodeStatus'); - if (nodeStatus && !this.viewModel.get('isLoading')) { - this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('capabilities')); - } - } - - // Handle loading state update - handleLoadingUpdate(isLoading) { - if (isLoading) { - this.renderLoading('
Loading detailed information...
'); - } - } - - // Handle error state update - handleErrorUpdate(error) { - if (error) { - this.renderError(`Error loading node details: ${error}`); - } - } - - // Handle active tab update - handleActiveTabUpdate(newTab, previousTab) { - // Update tab UI without full re-render - this.updateActiveTab(newTab, previousTab); - } - - // Handle capabilities update with state preservation - handleCapabilitiesUpdate(newCapabilities, previousCapabilities) { - const nodeStatus = this.viewModel.get('nodeStatus'); - const tasks = this.viewModel.get('tasks'); - if (nodeStatus && !this.viewModel.get('isLoading')) { - this.renderNodeDetails(nodeStatus, tasks, newCapabilities); - } - } - - render() { - const nodeStatus = this.viewModel.get('nodeStatus'); - const tasks = this.viewModel.get('tasks'); - const isLoading = this.viewModel.get('isLoading'); - const error = this.viewModel.get('error'); - const capabilities = this.viewModel.get('capabilities'); - - if (isLoading) { - this.renderLoading('
Loading detailed information...
'); - return; - } - - if (error) { - this.renderError(`Error loading node details: ${error}`); - return; - } - - if (!nodeStatus) { - this.renderEmpty('
No node status available
'); - return; - } - - this.renderNodeDetails(nodeStatus, tasks, capabilities); - } - - renderNodeDetails(nodeStatus, tasks, capabilities) { - // Use persisted active tab from the view model, default to 'status' - const activeTab = (this.viewModel && typeof this.viewModel.get === 'function' && this.viewModel.get('activeTab')) || 'status'; - logger.debug('NodeDetailsComponent: Rendering with activeTab:', activeTab); - - const html = ` -
-
- - - - - -
- -
-
- Free Heap: - ${Math.round(nodeStatus.freeHeap / 1024)}KB -
-
- Chip ID: - ${nodeStatus.chipId} -
-
- SDK Version: - ${nodeStatus.sdkVersion} -
-
- CPU Frequency: - ${nodeStatus.cpuFreqMHz}MHz -
-
- Flash Size: - ${Math.round(nodeStatus.flashChipSize / 1024)}KB -
-
- -
- ${nodeStatus.api ? nodeStatus.api.map(endpoint => - `
${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}
` - ).join('') : '
No API endpoints available
'} -
- -
- ${this.renderCapabilitiesTab(capabilities)} -
- -
- ${this.renderTasksTab(tasks)} -
- -
- ${this.renderFirmwareTab()} -
-
- `; - - this.setHTML('', html); - this.setupTabs(); - // Restore last active tab from view model if available - const restored = this.viewModel && typeof this.viewModel.get === 'function' ? this.viewModel.get('activeTab') : null; - if (restored) { - this.setActiveTab(restored); - } - this.setupFirmwareUpload(); - } - - renderCapabilitiesTab(capabilities) { - if (!capabilities || !Array.isArray(capabilities.endpoints) || capabilities.endpoints.length === 0) { - return ` -
-
🧩 No capabilities reported
-
This node did not return any capabilities
-
- `; - } - - // Sort endpoints by URI (name), then by method for stable ordering - const endpoints = [...capabilities.endpoints].sort((a, b) => { - const aUri = String(a.uri || '').toLowerCase(); - const bUri = String(b.uri || '').toLowerCase(); - if (aUri < bUri) return -1; - if (aUri > bUri) return 1; - const aMethod = String(a.method || '').toLowerCase(); - const bMethod = String(b.method || '').toLowerCase(); - return aMethod.localeCompare(bMethod); - }); - - const total = endpoints.length; - - // Preserve selection based on a stable key of method+uri if available - const selectedKey = String(this.getUIState('capSelectedKey') || ''); - let selectedIndex = endpoints.findIndex(ep => `${ep.method} ${ep.uri}` === selectedKey); - if (selectedIndex === -1) { - selectedIndex = Number(this.getUIState('capSelectedIndex')); - if (Number.isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= total) { - selectedIndex = 0; - } - } - - // Compute padding for aligned display in dropdown - const maxMethodLen = endpoints.reduce((m, ep) => Math.max(m, String(ep.method || '').length), 0); - - const selectorOptions = endpoints.map((ep, idx) => { - const method = String(ep.method || ''); - const uri = String(ep.uri || ''); - const padCount = Math.max(1, (maxMethodLen - method.length) + 2); - const spacer = ' '.repeat(padCount); - return ``; - }).join(''); - - const items = endpoints.map((ep, idx) => { - const formId = `cap-form-${idx}`; - const resultId = `cap-result-${idx}`; - const params = Array.isArray(ep.params) && ep.params.length > 0 - ? `
${ep.params.map((p, pidx) => ` - - `).join('')}
` - : '
No parameters
'; - return ` -
-
- ${ep.method} - ${ep.uri} - -
-
- ${params} -
- -
- `; - }).join(''); - - // Attach events after render in setupCapabilitiesEvents() - setTimeout(() => this.setupCapabilitiesEvents(), 0); - - return ` -
- - -
-
${items}
- `; - } - - setupCapabilitiesEvents() { - const selector = this.findElement('#capability-select'); - if (selector) { - this.addEventListener(selector, 'change', (e) => { - const selected = Number(e.target.value); - const items = Array.from(this.findAllElements('.capability-item')); - items.forEach((el, idx) => { - el.style.display = (idx === selected) ? '' : 'none'; - }); - this.setUIState('capSelectedIndex', selected); - const opt = e.target.selectedOptions && e.target.selectedOptions[0]; - if (opt) { - const method = opt.dataset.method || ''; - const uri = opt.dataset.uri || ''; - this.setUIState('capSelectedKey', `${method} ${uri}`); - } - }); - } - - const buttons = this.findAllElements('.cap-call-btn'); - buttons.forEach(btn => { - this.addEventListener(btn, 'click', async (e) => { - e.stopPropagation(); - const method = btn.dataset.method || 'GET'; - const uri = btn.dataset.uri || ''; - const formId = btn.dataset.formId; - const resultId = btn.dataset.resultId; - - const formEl = this.findElement(`#${formId}`); - const resultEl = this.findElement(`#${resultId}`); - if (!formEl || !resultEl) return; - - const inputs = Array.from(formEl.querySelectorAll('.param-input')); - const params = inputs.map(input => ({ - name: input.dataset.paramName, - location: input.dataset.paramLocation || 'body', - type: input.dataset.paramType || 'string', - required: input.dataset.paramRequired === '1', - value: input.value - })); - - // Required validation - const missing = params.filter(p => p.required && (!p.value || String(p.value).trim() === '')); - if (missing.length > 0) { - resultEl.style.display = 'block'; - resultEl.innerHTML = ` -
-
❌ Missing required fields: ${missing.map(m => this.escapeHtml(m.name)).join(', ')}
-
- `; - return; - } - - // Show loading state - resultEl.style.display = 'block'; - resultEl.innerHTML = '
Calling endpoint...
'; - - try { - const response = await this.viewModel.callCapability(method, uri, params); - const pretty = typeof response?.data === 'object' ? JSON.stringify(response.data, null, 2) : String(response?.data ?? ''); - resultEl.innerHTML = ` -
-
✅ Success
-
${this.escapeHtml(pretty)}
-
- `; - } catch (err) { - resultEl.innerHTML = ` -
-
❌ Error: ${this.escapeHtml(err.message || 'Request failed')}
-
- `; - } - }); - }); - } - - escapeHtml(str) { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>'); - } - - renderTasksTab(tasks) { - const summary = this.viewModel.get('tasksSummary'); - if (tasks && tasks.length > 0) { - const summaryHTML = summary ? ` -
-
-
📋
-
-
Tasks Overview
-
System task management and monitoring
-
-
-
-
-
${summary.totalTasks ?? tasks.length}
-
Total
-
-
-
${summary.activeTasks ?? tasks.filter(t => t.running).length}
-
Active
-
-
-
${(summary.totalTasks ?? tasks.length) - (summary.activeTasks ?? tasks.filter(t => t.running).length)}
-
Stopped
-
-
-
- ` : ''; - const tasksHTML = tasks.map(task => ` -
-
- ${task.name || 'Unknown Task'} - - ${task.running ? '🟢 Running' : '🔴 Stopped'} - -
-
- Interval: ${task.interval}ms - ${task.enabled ? '🟢 Enabled' : '🔴 Disabled'} -
-
- `).join(''); - - return ` - ${summaryHTML} - ${tasksHTML} - `; - } else { - const total = summary?.totalTasks ?? 0; - const active = summary?.activeTasks ?? 0; - return ` -
-
-
📋
-
-
Tasks Overview
-
${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
-
-
-
-
-
${total}
-
Total
-
-
-
${active}
-
Active
-
-
-
${total - active}
-
Stopped
-
-
-
-
-
📋 No active tasks found
-
- ${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'} -
-
- `; - } - } - - renderFirmwareTab() { - return ` -
-

Firmware Update

-
- - -
Select a .bin or .hex file to upload
- -
-
- `; - } - - setupTabs() { - logger.debug('NodeDetailsComponent: Setting up tabs'); - super.setupTabs(this.container, { - onChange: (tab) => { - // Persist active tab in the view model for restoration - if (this.viewModel && typeof this.viewModel.setActiveTab === 'function') { - this.viewModel.setActiveTab(tab); - } - } - }); - } - - // Update active tab without full re-render - updateActiveTab(newTab, previousTab = null) { - this.setActiveTab(newTab); - logger.debug(`NodeDetailsComponent: Active tab updated to '${newTab}'`); - } - - setupFirmwareUpload() { - const uploadBtn = this.findElement('.upload-btn[data-action="select-file"]'); - if (uploadBtn) { - this.addEventListener(uploadBtn, 'click', (e) => { - e.stopPropagation(); - const fileInput = this.findElement('#firmware-file'); - if (fileInput) { - fileInput.click(); - } - }); - - // Set up file input change handler - const fileInput = this.findElement('#firmware-file'); - if (fileInput) { - this.addEventListener(fileInput, 'change', async (e) => { - e.stopPropagation(); - const file = e.target.files[0]; - if (file) { - await this.uploadFirmware(file); - } - }); - } - } - } - - async uploadFirmware(file) { - const uploadStatus = this.findElement('#upload-status'); - const uploadBtn = this.findElement('.upload-btn'); - const originalText = uploadBtn.textContent; - - try { - // Show upload status - uploadStatus.style.display = 'block'; - uploadStatus.innerHTML = ` -
-
📤 Uploading ${file.name}...
-
Size: ${(file.size / 1024).toFixed(1)}KB
-
- `; - - // Disable upload button - uploadBtn.disabled = true; - uploadBtn.textContent = '⏳ Uploading...'; - - // Get the member IP from the card - const memberCard = this.container.closest('.member-card'); - const memberIp = memberCard.dataset.memberIp; - - if (!memberIp) { - throw new Error('Could not determine target node IP address'); - } - - // Upload firmware - const result = await this.viewModel.uploadFirmware(file, memberIp); - - // Show success - uploadStatus.innerHTML = ` -
-
✅ Firmware uploaded successfully!
-
Node: ${memberIp}
-
Size: ${(file.size / 1024).toFixed(1)}KB
-
- `; - - logger.debug('Firmware upload successful:', result); - - } catch (error) { - logger.error('Firmware upload failed:', error); - - // Show error - uploadStatus.innerHTML = ` -
-
❌ Upload failed: ${error.message}
-
- `; - } finally { - // Re-enable upload button - uploadBtn.disabled = false; - uploadBtn.textContent = originalText; - - // Clear file input - const fileInput = this.findElement('#firmware-file'); - if (fileInput) { - fileInput.value = ''; - } - } - } -} - -// Firmware Component -class FirmwareComponent extends Component { - constructor(container, viewModel, eventBus) { - super(container, viewModel, eventBus); - - logger.debug('FirmwareComponent: Constructor called'); - logger.debug('FirmwareComponent: Container:', container); - logger.debug('FirmwareComponent: Container ID:', container?.id); - - // Check if the dropdown exists in the container - if (container) { - const dropdown = container.querySelector('#specific-node-select'); - logger.debug('FirmwareComponent: Dropdown found in constructor:', !!dropdown); - if (dropdown) { - logger.debug('FirmwareComponent: Dropdown tagName:', dropdown.tagName); - logger.debug('FirmwareComponent: Dropdown id:', dropdown.id); - } - } - } - - setupEventListeners() { - // Setup global firmware file input - const globalFirmwareFile = this.findElement('#global-firmware-file'); - if (globalFirmwareFile) { - this.addEventListener(globalFirmwareFile, 'change', this.handleFileSelect.bind(this)); - } - - // Setup target selection - const targetRadios = this.findAllElements('input[name="target-type"]'); - targetRadios.forEach(radio => { - this.addEventListener(radio, 'change', this.handleTargetChange.bind(this)); - }); - - // Setup specific node select change handler - const specificNodeSelect = this.findElement('#specific-node-select'); - logger.debug('FirmwareComponent: setupEventListeners - specificNodeSelect found:', !!specificNodeSelect); - if (specificNodeSelect) { - logger.debug('FirmwareComponent: specificNodeSelect element:', specificNodeSelect); - logger.debug('FirmwareComponent: specificNodeSelect tagName:', specificNodeSelect.tagName); - logger.debug('FirmwareComponent: specificNodeSelect id:', specificNodeSelect.id); - - // Store the bound handler as an instance property - this._boundNodeSelectHandler = this.handleNodeSelect.bind(this); - this.addEventListener(specificNodeSelect, 'change', this._boundNodeSelectHandler); - logger.debug('FirmwareComponent: Event listener added to specificNodeSelect'); - } else { - logger.warn('FirmwareComponent: specificNodeSelect element not found during setupEventListeners'); - } - - // Setup label select change handler (single-select add-to-chips) - const labelSelect = this.findElement('#label-select'); - if (labelSelect) { - this._boundLabelSelectHandler = (e) => { - const value = e.target.value; - if (!value) return; - const current = this.viewModel.get('selectedLabels') || []; - if (!current.includes(value)) { - this.viewModel.setSelectedLabels([...current, value]); - } - // Reset select back to placeholder - e.target.value = ''; - this.renderSelectedLabelChips(); - this.updateAffectedNodesPreview(); - this.updateDeployButton(); - }; - this.addEventListener(labelSelect, 'change', this._boundLabelSelectHandler); - } - - // Setup deploy button - const deployBtn = this.findElement('#deploy-btn'); - if (deployBtn) { - this.addEventListener(deployBtn, 'click', this.handleDeploy.bind(this)); - } - } - - setupViewModelListeners() { - this.subscribeToProperty('selectedFile', () => { - this.updateFileInfo(); - this.updateDeployButton(); - }); - this.subscribeToProperty('targetType', () => { - this.updateTargetVisibility(); - this.updateDeployButton(); - this.updateAffectedNodesPreview(); - }); - this.subscribeToProperty('specificNode', this.updateDeployButton.bind(this)); - this.subscribeToProperty('availableNodes', () => { - this.populateNodeSelect(); - this.populateLabelSelect(); - this.updateDeployButton(); - this.updateAffectedNodesPreview(); - }); - this.subscribeToProperty('uploadProgress', this.updateUploadProgress.bind(this)); - this.subscribeToProperty('uploadResults', this.updateUploadResults.bind(this)); - this.subscribeToProperty('isUploading', this.updateUploadState.bind(this)); - this.subscribeToProperty('selectedLabels', () => { - this.populateLabelSelect(); - this.updateAffectedNodesPreview(); - this.updateDeployButton(); - }); - } - - mount() { - super.mount(); - - logger.debug('FirmwareComponent: Mounting...'); - - // Check if the dropdown exists when mounted - const dropdown = this.findElement('#specific-node-select'); - logger.debug('FirmwareComponent: Mount - dropdown found:', !!dropdown); - if (dropdown) { - logger.debug('FirmwareComponent: Mount - dropdown tagName:', dropdown.tagName); - logger.debug('FirmwareComponent: Mount - dropdown id:', dropdown.id); - logger.debug('FirmwareComponent: Mount - dropdown innerHTML:', dropdown.innerHTML); - } - - // Initialize target visibility and label list on first mount - try { - this.updateTargetVisibility(); - this.populateLabelSelect(); - this.updateAffectedNodesPreview(); - } catch (e) { - logger.warn('FirmwareComponent: Initialization after mount failed:', e); - } - - logger.debug('FirmwareComponent: Mounted successfully'); - } - - render() { - // Initial render is handled by the HTML template - this.updateDeployButton(); - } - - handleFileSelect(event) { - const file = event.target.files[0]; - this.viewModel.setSelectedFile(file); - } - - handleTargetChange(event) { - const targetType = event.target.value; - this.viewModel.setTargetType(targetType); - } - - handleNodeSelect(event) { - const nodeIp = event.target.value; - logger.debug('FirmwareComponent: handleNodeSelect called with nodeIp:', nodeIp); - logger.debug('Event:', event); - logger.debug('Event target:', event.target); - logger.debug('Event target value:', event.target.value); - - this.viewModel.setSpecificNode(nodeIp); - - // Also update the deploy button state - this.updateDeployButton(); - } - - async handleDeploy() { - const file = this.viewModel.get('selectedFile'); - const targetType = this.viewModel.get('targetType'); - const specificNode = this.viewModel.get('specificNode'); - - if (!file) { - alert('Please select a firmware file first.'); - return; - } - - if (targetType === 'specific' && !specificNode) { - alert('Please select a specific node to update.'); - return; - } - - try { - this.viewModel.startUpload(); - - if (targetType === 'all') { - await this.uploadToAllNodes(file); - } else if (targetType === 'specific') { - await this.uploadToSpecificNode(file, specificNode); - } else if (targetType === 'labels') { - await this.uploadToLabelFilteredNodes(file); - } - - // Reset interface after successful upload - this.viewModel.resetUploadState(); - - } catch (error) { - logger.error('Firmware deployment failed:', error); - alert(`Deployment failed: ${error.message}`); - } finally { - this.viewModel.completeUpload(); - } - } - - async uploadToAllNodes(file) { - try { - // Get current cluster members - const response = await window.apiClient.getClusterMembers(); - const nodes = response.members || []; - - if (nodes.length === 0) { - alert('No nodes available for firmware update.'); - return; - } - - const confirmed = confirm(`Upload firmware to all ${nodes.length} nodes? This will update: ${nodes.map(n => n.hostname || n.ip).join(', ')}`); - if (!confirmed) return; - - // Show upload progress area - this.showUploadProgress(file, nodes); - - // Start batch upload - const results = await this.performBatchUpload(file, nodes); - - // Display results - this.displayUploadResults(results); - - } catch (error) { - logger.error('Failed to upload firmware to all nodes:', error); - throw error; - } - } - - async uploadToSpecificNode(file, nodeIp) { - try { - const confirmed = confirm(`Upload firmware to node ${nodeIp}?`); - if (!confirmed) return; - - // Show upload progress area - this.showUploadProgress(file, [{ ip: nodeIp, hostname: nodeIp }]); - - // Update progress to show starting - this.updateNodeProgress(1, 1, nodeIp, 'Uploading...'); - - // Perform single node upload - const result = await this.performSingleUpload(file, nodeIp); - - // Update progress to show completion - this.updateNodeProgress(1, 1, nodeIp, 'Completed'); - this.updateOverallProgress(1, 1); - - // Display results - this.displayUploadResults([result]); - - } catch (error) { - logger.error(`Failed to upload firmware to node ${nodeIp}:`, error); - - // Update progress to show failure - this.updateNodeProgress(1, 1, nodeIp, 'Failed'); - this.updateOverallProgress(0, 1); - - // Display error results - const errorResult = { - nodeIp: nodeIp, - hostname: nodeIp, - success: false, - error: error.message, - timestamp: new Date().toISOString() - }; - this.displayUploadResults([errorResult]); - - throw error; - } - } - - async uploadToLabelFilteredNodes(file) { - try { - const nodes = this.viewModel.getAffectedNodesByLabels(); - if (!nodes || nodes.length === 0) { - alert('No nodes match the selected labels.'); - return; - } - const labels = this.viewModel.get('selectedLabels') || []; - const confirmed = confirm(`Upload firmware to ${nodes.length} node(s) matching labels (${labels.join(', ')})?`); - if (!confirmed) return; - - // Show upload progress area - this.showUploadProgress(file, nodes); - - // Start batch upload - const results = await this.performBatchUpload(file, nodes); - - // Display results - this.displayUploadResults(results); - } catch (error) { - logger.error('Failed to upload firmware to label-filtered nodes:', error); - throw error; - } - } - - async performBatchUpload(file, nodes) { - const results = []; - const totalNodes = nodes.length; - let successfulUploads = 0; - - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - const nodeIp = node.ip; - - try { - // Update progress - this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Uploading...'); - - // Upload to this node - const result = await this.performSingleUpload(file, nodeIp); - results.push(result); - successfulUploads++; - - // Update progress - this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Completed'); - this.updateOverallProgress(successfulUploads, totalNodes); - - } catch (error) { - logger.error(`Failed to upload to node ${nodeIp}:`, error); - const errorResult = { - nodeIp: nodeIp, - hostname: node.hostname || nodeIp, - success: false, - error: error.message, - timestamp: new Date().toISOString() - }; - results.push(errorResult); - - // Update progress - this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Failed'); - this.updateOverallProgress(successfulUploads, totalNodes); - } - - // Small delay between uploads - if (i < nodes.length - 1) { - await new Promise(resolve => setTimeout(resolve, 1000)); - } - } - - return results; - } - - async performSingleUpload(file, nodeIp) { - try { - const result = await window.apiClient.uploadFirmware(file, nodeIp); - - return { - nodeIp: nodeIp, - hostname: nodeIp, - success: true, - result: result, - timestamp: new Date().toISOString() - }; - - } catch (error) { - throw new Error(`Upload to ${nodeIp} failed: ${error.message}`); - } - } - - showUploadProgress(file, nodes) { - const container = this.findElement('#firmware-nodes-list'); - - const progressHTML = ` -
-
-

📤 Firmware Upload Progress

-
- File: ${file.name} - Size: ${(file.size / 1024).toFixed(1)}KB - Targets: ${nodes.length} node(s) -
-
-
-
-
- 0/${nodes.length} Successful (0%) -
-
- Status: Preparing upload... -
-
-
- ${nodes.map(node => ` -
-
- ${node.hostname || node.ip} - ${node.ip} -
-
Pending...
-
-
- `).join('')} -
-
- `; - - container.innerHTML = progressHTML; - - // Initialize progress for single-node uploads - if (nodes.length === 1) { - const node = nodes[0]; - this.updateNodeProgress(1, 1, node.ip, 'Pending...'); - } - } - - updateNodeProgress(current, total, nodeIp, status) { - const progressItem = this.findElement(`[data-node-ip="${nodeIp}"]`); - if (progressItem) { - const statusElement = progressItem.querySelector('.progress-status'); - const timeElement = progressItem.querySelector('.progress-time'); - - if (statusElement) { - statusElement.textContent = status; - - // Add status-specific styling - statusElement.className = 'progress-status'; - if (status === 'Completed') { - statusElement.classList.add('success'); - if (timeElement) { - timeElement.textContent = new Date().toLocaleTimeString(); - } - } else if (status === 'Failed') { - statusElement.classList.add('error'); - if (timeElement) { - timeElement.textContent = new Date().toLocaleTimeString(); - } - } else if (status === 'Uploading...') { - statusElement.classList.add('uploading'); - if (timeElement) { - timeElement.textContent = 'Started: ' + new Date().toLocaleTimeString(); - } - } - } - } - } - - updateOverallProgress(successfulUploads, totalNodes) { - const progressBar = this.findElement('#overall-progress-bar'); - const progressText = this.findElement('.progress-text'); - - if (progressBar && progressText) { - const successPercentage = Math.round((successfulUploads / totalNodes) * 100); - progressBar.style.width = `${successPercentage}%`; - progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`; - - // Update progress bar color based on completion - if (successPercentage === 100) { - progressBar.style.backgroundColor = '#4ade80'; - } else if (successPercentage > 50) { - progressBar.style.backgroundColor = '#60a5fa'; - } else { - progressBar.style.backgroundColor = '#fbbf24'; - } - - // Update progress summary for single-node uploads - const progressSummary = this.findElement('#progress-summary'); - if (progressSummary && totalNodes === 1) { - if (successfulUploads === 1) { - progressSummary.innerHTML = 'Status: Upload completed successfully'; - } else if (successfulUploads === 0) { - progressSummary.innerHTML = 'Status: Upload failed'; - } - } - } - } - - displayUploadResults(results) { - const progressHeader = this.findElement('.progress-header h3'); - const progressSummary = this.findElement('#progress-summary'); - - if (progressHeader && progressSummary) { - const successCount = results.filter(r => r.success).length; - const totalCount = results.length; - const successRate = Math.round((successCount / totalCount) * 100); - - if (totalCount === 1) { - // Single node upload - if (successCount === 1) { - progressHeader.textContent = `📤 Firmware Upload Complete`; - progressSummary.innerHTML = `✅ Upload to ${results[0].hostname || results[0].nodeIp} completed successfully at ${new Date().toLocaleTimeString()}`; - } else { - progressHeader.textContent = `📤 Firmware Upload Failed`; - progressSummary.innerHTML = `❌ Upload to ${results[0].hostname || results[0].nodeIp} failed at ${new Date().toLocaleTimeString()}`; - } - } else if (successCount === totalCount) { - // Multi-node upload - all successful - progressHeader.textContent = `📤 Firmware Upload Complete (${successCount}/${totalCount} Successful)`; - progressSummary.innerHTML = `✅ All uploads completed successfully at ${new Date().toLocaleTimeString()}`; - } else { - // Multi-node upload - some failed - progressHeader.textContent = `📤 Firmware Upload Results (${successCount}/${totalCount} Successful)`; - progressSummary.innerHTML = `⚠️ Upload completed with ${totalCount - successCount} failure(s) at ${new Date().toLocaleTimeString()}`; - } - } - } - - updateFileInfo() { - const file = this.viewModel.get('selectedFile'); - const fileInfo = this.findElement('#file-info'); - const deployBtn = this.findElement('#deploy-btn'); - - if (file) { - fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`; - fileInfo.classList.add('has-file'); - } else { - fileInfo.textContent = 'No file selected'; - fileInfo.classList.remove('has-file'); - } - - this.updateDeployButton(); - } - - updateTargetVisibility() { - const targetType = this.viewModel.get('targetType'); - const specificNodeSelect = this.findElement('#specific-node-select'); - const labelSelect = this.findElement('#label-select'); - - logger.debug('FirmwareComponent: updateTargetVisibility called with targetType:', targetType); - - if (targetType === 'specific') { - if (specificNodeSelect) { specificNodeSelect.style.display = 'inline-block'; } - if (labelSelect) { labelSelect.style.display = 'none'; } - this.populateNodeSelect(); - } else if (targetType === 'labels') { - if (specificNodeSelect) { specificNodeSelect.style.display = 'none'; } - if (labelSelect) { - labelSelect.style.display = 'inline-block'; - this.populateLabelSelect(); - } - } else { - if (specificNodeSelect) { specificNodeSelect.style.display = 'none'; } - if (labelSelect) { labelSelect.style.display = 'none'; } - } - this.updateDeployButton(); - } - - // Note: handleNodeSelect is already defined above and handles the actual node selection - // This duplicate method was causing the issue - removing it - - updateDeployButton() { - const deployBtn = this.findElement('#deploy-btn'); - if (deployBtn) { - deployBtn.disabled = !this.viewModel.isDeployEnabled(); - } - } - - populateNodeSelect() { - const select = this.findElement('#specific-node-select'); - if (!select) { - logger.warn('FirmwareComponent: populateNodeSelect - select element not found'); - return; - } - - if (select.tagName !== 'SELECT') { - logger.warn('FirmwareComponent: populateNodeSelect - element is not a SELECT:', select.tagName); - return; - } - - logger.debug('FirmwareComponent: populateNodeSelect called'); - logger.debug('FirmwareComponent: Select element:', select); - logger.debug('FirmwareComponent: Available nodes:', this.viewModel.get('availableNodes')); - - // Clear existing options - select.innerHTML = ''; - - // Get available nodes from the view model - const availableNodes = this.viewModel.get('availableNodes'); - - if (!availableNodes || availableNodes.length === 0) { - // No nodes available - const option = document.createElement('option'); - option.value = ""; - option.textContent = "No nodes available"; - option.disabled = true; - select.appendChild(option); - return; - } - - availableNodes.forEach(node => { - const option = document.createElement('option'); - option.value = node.ip; - option.textContent = `${node.hostname} (${node.ip})`; - select.appendChild(option); - }); - - // Ensure event listener is still bound after repopulating - this.ensureNodeSelectListener(select); - - logger.debug('FirmwareComponent: Node select populated with', availableNodes.length, 'nodes'); - } - - // Ensure the node select change listener is properly bound - ensureNodeSelectListener(select) { - if (!select) return; - - // Store the bound handler as an instance property to avoid binding issues - if (!this._boundNodeSelectHandler) { - this._boundNodeSelectHandler = this.handleNodeSelect.bind(this); - } - - // Remove any existing listeners and add the bound one - select.removeEventListener('change', this._boundNodeSelectHandler); - select.addEventListener('change', this._boundNodeSelectHandler); - - logger.debug('FirmwareComponent: Node select event listener ensured'); - } - - updateUploadProgress() { - // This will be implemented when we add upload progress tracking - } - - updateUploadResults() { - // This will be implemented when we add upload results display - } - - updateUploadState() { - const isUploading = this.viewModel.get('isUploading'); - const deployBtn = this.findElement('#deploy-btn'); - - if (deployBtn) { - deployBtn.disabled = isUploading; - if (isUploading) { - deployBtn.classList.add('loading'); - deployBtn.textContent = '⏳ Deploying...'; - } else { - deployBtn.classList.remove('loading'); - deployBtn.textContent = '🚀 Deploy'; - } - } - - this.updateDeployButton(); - } - - populateLabelSelect() { - const select = this.findElement('#label-select'); - if (!select) return; - const labels = this.viewModel.get('availableLabels') || []; - const selected = new Set(this.viewModel.get('selectedLabels') || []); - const options = [''] - .concat(labels.filter(l => !selected.has(l)).map(l => ``)); - select.innerHTML = options.join(''); - // Ensure change listener remains bound - if (this._boundLabelSelectHandler) { - select.removeEventListener('change', this._boundLabelSelectHandler); - select.addEventListener('change', this._boundLabelSelectHandler); - } - this.renderSelectedLabelChips(); - } - - renderSelectedLabelChips() { - const container = this.findElement('#selected-labels-container'); - if (!container) return; - const selected = this.viewModel.get('selectedLabels') || []; - if (selected.length === 0) { - container.innerHTML = ''; - return; - } - container.innerHTML = selected.map(l => ` - - ${l} - - - `).join(''); - Array.from(container.querySelectorAll('.chip-remove')).forEach(btn => { - this.addEventListener(btn, 'click', (e) => { - e.stopPropagation(); - const label = btn.getAttribute('data-label'); - const current = this.viewModel.get('selectedLabels') || []; - this.viewModel.setSelectedLabels(current.filter(x => x !== label)); - this.populateLabelSelect(); - this.updateAffectedNodesPreview(); - this.updateDeployButton(); - }); - }); - } - - updateAffectedNodesPreview() { - const container = this.findElement('#firmware-nodes-list'); - if (!container) return; - if (this.viewModel.get('targetType') !== 'labels') { - container.innerHTML = ''; - return; - } - const nodes = this.viewModel.getAffectedNodesByLabels(); - if (!nodes.length) { - container.innerHTML = `
No nodes match the selected labels
`; - return; - } - const html = ` -
-

🎯 Affected Nodes (${nodes.length})

-
- ${nodes.map(n => ` -
-
${n.hostname || n.ip}${n.ip}
-
- `).join('')} -
-
`; - container.innerHTML = html; - } -} - -// Cluster View Component -class ClusterViewComponent extends Component { - constructor(container, viewModel, eventBus) { - super(container, viewModel, eventBus); - - logger.debug('ClusterViewComponent: Constructor called'); - logger.debug('ClusterViewComponent: Container:', container); - logger.debug('ClusterViewComponent: Container ID:', container?.id); - - // Find elements for sub-components - const primaryNodeContainer = this.findElement('.primary-node-info'); - const clusterMembersContainer = this.findElement('#cluster-members-container'); - - logger.debug('ClusterViewComponent: Primary node container:', primaryNodeContainer); - logger.debug('ClusterViewComponent: Cluster members container:', clusterMembersContainer); - logger.debug('ClusterViewComponent: Cluster members container ID:', clusterMembersContainer?.id); - logger.debug('ClusterViewComponent: Cluster members container innerHTML:', clusterMembersContainer?.innerHTML); - - // Create sub-components - this.primaryNodeComponent = new PrimaryNodeComponent( - primaryNodeContainer, - viewModel, - eventBus - ); - - this.clusterMembersComponent = new ClusterMembersComponent( - clusterMembersContainer, - viewModel, - eventBus - ); - - logger.debug('ClusterViewComponent: Sub-components created'); - - // Track if we've already loaded data to prevent unnecessary reloads - this.dataLoaded = false; - } - - mount() { - logger.debug('ClusterViewComponent: Mounting...'); - super.mount(); - - logger.debug('ClusterViewComponent: Mounting sub-components...'); - // Mount sub-components - this.primaryNodeComponent.mount(); - this.clusterMembersComponent.mount(); - - // Set up refresh button event listener (since it's in the cluster header, not in the members container) - this.setupRefreshButton(); - - // Only load data if we haven't already or if the view model is empty - const members = this.viewModel.get('members'); - const shouldLoadData = !this.dataLoaded || !members || members.length === 0; - - if (shouldLoadData) { - logger.debug('ClusterViewComponent: Starting initial data load...'); - // Initial data load - ensure it happens after mounting - setTimeout(() => { - this.viewModel.updateClusterMembers().then(() => { - this.dataLoaded = true; - }).catch(error => { - logger.error('ClusterViewComponent: Failed to load initial data:', error); - }); - }, 100); - } else { - logger.debug('ClusterViewComponent: Data already loaded, skipping initial load'); - } - - // Set up periodic updates - // this.setupPeriodicUpdates(); // Disabled automatic refresh - logger.debug('ClusterViewComponent: Mounted successfully'); - } - - setupRefreshButton() { - logger.debug('ClusterViewComponent: Setting up refresh button...'); - - const refreshBtn = this.findElement('.refresh-btn'); - logger.debug('ClusterViewComponent: Found refresh button:', !!refreshBtn, refreshBtn); - - if (refreshBtn) { - logger.debug('ClusterViewComponent: Adding click event listener to refresh button'); - this.addEventListener(refreshBtn, 'click', this.handleRefresh.bind(this)); - logger.debug('ClusterViewComponent: Event listener added successfully'); - } else { - logger.error('ClusterViewComponent: Refresh button not found!'); - logger.debug('ClusterViewComponent: Container HTML:', this.container.innerHTML); - logger.debug('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button')); - } - } - - async handleRefresh() { - logger.debug('ClusterViewComponent: Refresh button clicked, performing full refresh...'); - - // Get the refresh button and show loading state - const refreshBtn = this.findElement('.refresh-btn'); - logger.debug('ClusterViewComponent: Found refresh button for loading state:', !!refreshBtn); - - if (refreshBtn) { - const originalText = refreshBtn.innerHTML; - logger.debug('ClusterViewComponent: Original button text:', originalText); - - refreshBtn.innerHTML = ` - - - - - Refreshing... - `; - refreshBtn.disabled = true; - - try { - logger.debug('ClusterViewComponent: Starting cluster members update...'); - // Always perform a full refresh when user clicks refresh button - await this.viewModel.updateClusterMembers(); - logger.debug('ClusterViewComponent: Cluster members update completed successfully'); - } catch (error) { - logger.error('ClusterViewComponent: Error during refresh:', error); - // Show error state - if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) { - this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed'); - } - } finally { - logger.debug('ClusterViewComponent: Restoring button state...'); - // Restore button state - refreshBtn.innerHTML = originalText; - refreshBtn.disabled = false; - } - } else { - logger.warn('ClusterViewComponent: Refresh button not found, using fallback refresh'); - // Fallback if button not found - try { - await this.viewModel.updateClusterMembers(); - } catch (error) { - logger.error('ClusterViewComponent: Fallback refresh failed:', error); - if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) { - this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed'); - } - } - } - } - - unmount() { - logger.debug('ClusterViewComponent: Unmounting...'); - - // Unmount sub-components - if (this.primaryNodeComponent) { - this.primaryNodeComponent.unmount(); - } - if (this.clusterMembersComponent) { - this.clusterMembersComponent.unmount(); - } - - // Clear intervals - if (this.updateInterval) { - clearInterval(this.updateInterval); - } - - super.unmount(); - logger.debug('ClusterViewComponent: Unmounted'); - } - - // Override pause method to handle sub-components - onPause() { - logger.debug('ClusterViewComponent: Pausing...'); - - // Pause sub-components - if (this.primaryNodeComponent && this.primaryNodeComponent.isMounted) { - this.primaryNodeComponent.pause(); - } - if (this.clusterMembersComponent && this.clusterMembersComponent.isMounted) { - this.clusterMembersComponent.pause(); - } - - // Clear any active intervals - if (this.updateInterval) { - clearInterval(this.updateInterval); - this.updateInterval = null; - } - } - - // Override resume method to handle sub-components - onResume() { - logger.debug('ClusterViewComponent: Resuming...'); - - // Resume sub-components - if (this.primaryNodeComponent && this.primaryNodeComponent.isMounted) { - this.primaryNodeComponent.resume(); - } - if (this.clusterMembersComponent && this.clusterMembersComponent.isMounted) { - this.clusterMembersComponent.resume(); - } - - // Restart periodic updates if needed - // this.setupPeriodicUpdates(); // Disabled automatic refresh - } - - // Override to determine if re-render is needed on resume - shouldRenderOnResume() { - // Don't re-render on resume - the component should maintain its state - return false; - } - - setupPeriodicUpdates() { - // Update primary node display every 10 seconds - this.updateInterval = setInterval(() => { - this.viewModel.updatePrimaryNodeDisplay(); - }, 10000); - } -} - -// Firmware View Component -class FirmwareViewComponent extends Component { - constructor(container, viewModel, eventBus) { - super(container, viewModel, eventBus); - - logger.debug('FirmwareViewComponent: Constructor called'); - logger.debug('FirmwareViewComponent: Container:', container); - - const firmwareContainer = this.findElement('#firmware-container'); - logger.debug('FirmwareViewComponent: Firmware container found:', !!firmwareContainer); - - this.firmwareComponent = new FirmwareComponent( - firmwareContainer, - viewModel, - eventBus - ); - - logger.debug('FirmwareViewComponent: FirmwareComponent created'); - } - - mount() { - super.mount(); - - logger.debug('FirmwareViewComponent: Mounting...'); - - // Mount sub-component - this.firmwareComponent.mount(); - - // Update available nodes - this.updateAvailableNodes(); - - logger.debug('FirmwareViewComponent: Mounted successfully'); - } - - unmount() { - // Unmount sub-component - if (this.firmwareComponent) { - this.firmwareComponent.unmount(); - } - - super.unmount(); - } - - // Override pause method to handle sub-components - onPause() { - logger.debug('FirmwareViewComponent: Pausing...'); - - // Pause sub-component - if (this.firmwareComponent && this.firmwareComponent.isMounted) { - this.firmwareComponent.pause(); - } - } - - // Override resume method to handle sub-components - onResume() { - logger.debug('FirmwareViewComponent: Resuming...'); - - // Resume sub-component - if (this.firmwareComponent && this.firmwareComponent.isMounted) { - this.firmwareComponent.resume(); - } - } - - // Override to determine if re-render is needed on resume - shouldRenderOnResume() { - // Don't re-render on resume - maintain current state - return false; - } - - async updateAvailableNodes() { - try { - logger.debug('FirmwareViewComponent: updateAvailableNodes called'); - const response = await window.apiClient.getClusterMembers(); - const nodes = response.members || []; - logger.debug('FirmwareViewComponent: Got nodes:', nodes); - this.viewModel.updateAvailableNodes(nodes); - logger.debug('FirmwareViewComponent: Available nodes updated in view model'); - } catch (error) { - logger.error('Failed to update available nodes:', error); - } - } -} - -// Cluster Status Component for header badge -class ClusterStatusComponent extends Component { - constructor(container, viewModel, eventBus) { - super(container, viewModel, eventBus); - } - - setupViewModelListeners() { - // Subscribe to properties that affect cluster status - this.subscribeToProperty('totalNodes', this.render.bind(this)); - this.subscribeToProperty('clientInitialized', this.render.bind(this)); - this.subscribeToProperty('error', this.render.bind(this)); - } - - render() { - const totalNodes = this.viewModel.get('totalNodes'); - const clientInitialized = this.viewModel.get('clientInitialized'); - const error = this.viewModel.get('error'); - - let statusText, statusIcon, statusClass; - - if (error) { - statusText = 'Cluster Error'; - statusIcon = '❌'; - statusClass = 'cluster-status-error'; - } else if (totalNodes === 0) { - statusText = 'Cluster Offline'; - statusIcon = '🔴'; - statusClass = 'cluster-status-offline'; - } else if (clientInitialized) { - statusText = 'Cluster Online'; - statusIcon = '🟢'; - statusClass = 'cluster-status-online'; - } else { - statusText = 'Cluster Connecting'; - statusIcon = '🟡'; - statusClass = 'cluster-status-connecting'; - } - - // Update the cluster status badge using the container passed to this component - if (this.container) { - this.container.innerHTML = `${statusIcon} ${statusText}`; - - // Remove all existing status classes - this.container.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error'); - - // Add the appropriate status class - this.container.classList.add(statusClass); - } - } -} - -// Topology Graph Component with D3.js force-directed visualization -class TopologyGraphComponent extends Component { - constructor(container, viewModel, eventBus) { - super(container, viewModel, eventBus); - logger.debug('TopologyGraphComponent: Constructor called'); - this.svg = null; - this.simulation = null; - this.zoom = null; - this.width = 0; // Will be set dynamically based on container size - this.height = 0; // Will be set dynamically based on container size - this.isInitialized = false; - } - - updateDimensions(container) { - // Get the container's actual dimensions - const rect = container.getBoundingClientRect(); - this.width = rect.width || 1400; // Fallback to 1400 if width is 0 - this.height = rect.height || 1000; // Fallback to 1000 if height is 0 - - // Ensure minimum dimensions - this.width = Math.max(this.width, 800); - this.height = Math.max(this.height, 600); - - logger.debug('TopologyGraphComponent: Updated dimensions to', this.width, 'x', this.height); - } - - handleResize() { - // Debounce resize events to avoid excessive updates - if (this.resizeTimeout) { - clearTimeout(this.resizeTimeout); - } - - this.resizeTimeout = setTimeout(() => { - const container = this.findElement('#topology-graph-container'); - if (container && this.svg) { - this.updateDimensions(container); - // Update SVG viewBox and force center - this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`); - if (this.simulation) { - this.simulation.force('center', d3.forceCenter(this.width / 2, this.height / 2)); - this.simulation.alpha(0.3).restart(); - } - } - }, 250); // 250ms debounce - } - - // Override mount to ensure proper initialization - mount() { - if (this.isMounted) return; - - logger.debug('TopologyGraphComponent: Starting mount...'); - logger.debug('TopologyGraphComponent: isInitialized =', this.isInitialized); - - // Call initialize if not already done - if (!this.isInitialized) { - logger.debug('TopologyGraphComponent: Initializing during mount...'); - this.initialize().then(() => { - logger.debug('TopologyGraphComponent: Initialization completed, calling completeMount...'); - // Complete mount after initialization - this.completeMount(); - }).catch(error => { - logger.error('TopologyGraphComponent: Initialization failed during mount:', error); - // Still complete mount to prevent blocking - this.completeMount(); - }); - } else { - logger.debug('TopologyGraphComponent: Already initialized, calling completeMount directly...'); - this.completeMount(); - } - } - - completeMount() { - logger.debug('TopologyGraphComponent: completeMount called'); - this.isMounted = true; - logger.debug('TopologyGraphComponent: Setting up event listeners...'); - this.setupEventListeners(); - logger.debug('TopologyGraphComponent: Setting up view model listeners...'); - this.setupViewModelListeners(); - logger.debug('TopologyGraphComponent: Calling render...'); - this.render(); - - logger.debug('TopologyGraphComponent: Mounted successfully'); - } - - setupEventListeners() { - logger.debug('TopologyGraphComponent: setupEventListeners called'); - logger.debug('TopologyGraphComponent: Container:', this.container); - logger.debug('TopologyGraphComponent: Container ID:', this.container?.id); - - // Add resize listener to update dimensions when window is resized - this.resizeHandler = this.handleResize.bind(this); - window.addEventListener('resize', this.resizeHandler); - - // Refresh button removed from HTML, so no need to set up event listeners - logger.debug('TopologyGraphComponent: No event listeners needed (refresh button removed)'); - } - - setupViewModelListeners() { - logger.debug('TopologyGraphComponent: setupViewModelListeners called'); - logger.debug('TopologyGraphComponent: isInitialized =', this.isInitialized); - - if (this.isInitialized) { - // Component is already initialized, set up subscriptions immediately - logger.debug('TopologyGraphComponent: Setting up property subscriptions immediately'); - this.subscribeToProperty('nodes', this.renderGraph.bind(this)); - this.subscribeToProperty('links', this.renderGraph.bind(this)); - this.subscribeToProperty('isLoading', this.handleLoadingState.bind(this)); - this.subscribeToProperty('error', this.handleError.bind(this)); - this.subscribeToProperty('selectedNode', this.updateSelection.bind(this)); - } else { - // Component not yet initialized, store for later - logger.debug('TopologyGraphComponent: Component not initialized, storing pending subscriptions'); - this._pendingSubscriptions = [ - ['nodes', this.renderGraph.bind(this)], - ['links', this.renderGraph.bind(this)], - ['isLoading', this.handleLoadingState.bind(this)], - ['error', this.handleError.bind(this)], - ['selectedNode', this.updateSelection.bind(this)] - ]; - } - } - - async initialize() { - logger.debug('TopologyGraphComponent: Initializing...'); - - // Wait for DOM to be ready - if (document.readyState === 'loading') { - await new Promise(resolve => { - document.addEventListener('DOMContentLoaded', resolve); - }); - } - - // Set up the SVG container - this.setupSVG(); - - // Mark as initialized - this.isInitialized = true; - - // Now set up the actual property listeners after initialization - if (this._pendingSubscriptions) { - this._pendingSubscriptions.forEach(([property, callback]) => { - this.subscribeToProperty(property, callback); - }); - this._pendingSubscriptions = null; - } - - // Initial data load - await this.viewModel.updateNetworkTopology(); - } - - setupSVG() { - const container = this.findElement('#topology-graph-container'); - if (!container) { - logger.error('TopologyGraphComponent: Graph container not found'); - return; - } - - // Calculate dynamic dimensions based on container size - this.updateDimensions(container); - - // Clear existing content - container.innerHTML = ''; - - // Create SVG element - this.svg = d3.select(container) - .append('svg') - .attr('width', '100%') - .attr('height', '100%') - .attr('viewBox', `0 0 ${this.width} ${this.height}`) - .style('border', '1px solid rgba(255, 255, 255, 0.1)') - .style('background', 'rgba(0, 0, 0, 0.2)') - .style('border-radius', '12px'); - - // Add zoom behavior - this.zoom = d3.zoom() - .scaleExtent([0.5, 5]) // Changed from [0.3, 4] to allow more zoom in and start more zoomed in - .on('zoom', (event) => { - this.svg.select('g').attr('transform', event.transform); - }); - - this.svg.call(this.zoom); - - // Create main group for zoom and apply initial zoom - const mainGroup = this.svg.append('g'); - - // Apply initial zoom to show the graph more zoomed in - mainGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); // Better centered positioning - - logger.debug('TopologyGraphComponent: SVG setup completed'); - } - - // Ensure component is initialized - async ensureInitialized() { - if (!this.isInitialized) { - logger.debug('TopologyGraphComponent: Ensuring initialization...'); - await this.initialize(); - } - return this.isInitialized; - } - - renderGraph() { - try { - // Check if component is initialized - if (!this.isInitialized) { - logger.debug('TopologyGraphComponent: Component not yet initialized, ensuring initialization...'); - this.ensureInitialized().then(() => { - // Re-render after initialization - this.renderGraph(); - }).catch(error => { - logger.error('TopologyGraphComponent: Failed to initialize:', error); - }); - return; - } - - const nodes = this.viewModel.get('nodes'); - const links = this.viewModel.get('links'); - - // Check if SVG is initialized - if (!this.svg) { - logger.debug('TopologyGraphComponent: SVG not initialized yet, setting up SVG first'); - this.setupSVG(); - } - - if (!nodes || nodes.length === 0) { - this.showNoData(); - return; - } - - logger.debug('TopologyGraphComponent: Rendering graph with', nodes.length, 'nodes and', links.length, 'links'); - - // Get the main SVG group (the one created in setupSVG) - let svgGroup = this.svg.select('g'); - if (!svgGroup || svgGroup.empty()) { - logger.debug('TopologyGraphComponent: Creating new SVG group'); - svgGroup = this.svg.append('g'); - // Apply initial zoom to show the graph more zoomed in - svgGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); // Better centered positioning - } - - // Clear existing graph elements but preserve the main group and its transform - svgGroup.selectAll('.graph-element').remove(); - - // Create links with better styling (no arrows needed) - const link = svgGroup.append('g') - .attr('class', 'graph-element') - .selectAll('line') - .data(links) - .enter().append('line') - .attr('stroke', d => this.getLinkColor(d.latency)) - .attr('stroke-opacity', 0.7) // Reduced from 0.8 for subtlety - .attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8))) // Much thinner: reduced from max 6 to max 3 - .attr('marker-end', null); // Remove arrows - - // Create nodes - const node = svgGroup.append('g') - .attr('class', 'graph-element') - .selectAll('g') - .data(nodes) - .enter().append('g') - .attr('class', 'node') - .call(this.drag(this.simulation)); - - // Add circles to nodes with size based on status - node.append('circle') - .attr('r', d => this.getNodeRadius(d.status)) - .attr('fill', d => this.getNodeColor(d.status)) - .attr('stroke', '#fff') - .attr('stroke-width', 2); - - // Add status indicator - node.append('circle') - .attr('r', 3) - .attr('fill', d => this.getStatusIndicatorColor(d.status)) - .attr('cx', -8) - .attr('cy', -8); - - // Add labels to nodes - node.append('text') - .text(d => d.hostname.length > 15 ? d.hostname.substring(0, 15) + '...' : d.hostname) - .attr('x', 15) - .attr('y', 4) - .attr('font-size', '13px') // Increased from 12px for better readability - .attr('fill', '#ecf0f1') // Light text for dark theme - .attr('font-weight', '500'); - - // Add IP address labels - node.append('text') - .text(d => d.ip) - .attr('x', 15) - .attr('y', 20) - .attr('font-size', '11px') // Increased from 10px for better readability - .attr('fill', 'rgba(255, 255, 255, 0.7)'); // Semi-transparent white - - // Add status labels - node.append('text') - .text(d => d.status) - .attr('x', 15) - .attr('y', 35) - .attr('font-size', '11px') // Increased from 10px for better readability - .attr('fill', d => this.getNodeColor(d.status)) - .attr('font-weight', '600'); - - // Add latency labels on links with better positioning - const linkLabels = svgGroup.append('g') - .attr('class', 'graph-element') - .selectAll('text') - .data(links) - .enter().append('text') - .attr('font-size', '12px') // Increased from 11px for better readability - .attr('fill', '#ecf0f1') // Light text for dark theme - .attr('font-weight', '600') // Made slightly bolder for better readability - .attr('text-anchor', 'middle') - .style('text-shadow', '0 1px 2px rgba(0, 0, 0, 0.8)') // Add shadow for better contrast - .text(d => `${d.latency}ms`); - - // Remove the background boxes for link labels - they look out of place - - // Set up force simulation with better parameters (only if not already exists) - if (!this.simulation) { - this.simulation = d3.forceSimulation(nodes) - .force('link', d3.forceLink(links).id(d => d.id).distance(300)) // Increased from 200 for more spacing - .force('charge', d3.forceManyBody().strength(-800)) // Increased from -600 for stronger repulsion - .force('center', d3.forceCenter(this.width / 2, this.height / 2)) - .force('collision', d3.forceCollide().radius(80)); // Increased from 60 for more separation - - // Update positions on simulation tick - this.simulation.on('tick', () => { - link - .attr('x1', d => d.source.x) - .attr('y1', d => d.source.y) - .attr('x2', d => d.target.x) - .attr('y2', d => d.target.y); - - // Update link labels - linkLabels - .attr('x', d => (d.source.x + d.target.x) / 2) - .attr('y', d => (d.source.y + d.target.y) / 2 - 5); - - // Remove the background update code since we removed the backgrounds - - node - .attr('transform', d => `translate(${d.x},${d.y})`); - }); - } else { - // Update existing simulation with new data - this.simulation.nodes(nodes); - this.simulation.force('link').links(links); - this.simulation.alpha(0.3).restart(); - } - - // Add click handlers for node selection and member card overlay - node.on('click', (event, d) => { - this.viewModel.selectNode(d.id); - this.updateSelection(d.id); - - // Show member card overlay - this.showMemberCardOverlay(d); - }); - - // Add hover effects - node.on('mouseover', (event, d) => { - d3.select(event.currentTarget).select('circle') - .attr('r', d => this.getNodeRadius(d.status) + 4) - .attr('stroke-width', 3); - }); - - node.on('mouseout', (event, d) => { - d3.select(event.currentTarget).select('circle') - .attr('r', d => this.getNodeRadius(d.status)) - .attr('stroke-width', 2); - }); - - // Add tooltip for links - link.on('mouseover', (event, d) => { - d3.select(event.currentTarget) - .attr('stroke-width', d => Math.max(2, Math.min(4, d.latency / 6))) // Reduced from max 10 to max 4 - .attr('stroke-opacity', 0.9); // Reduced from 1 for subtlety - }); - - link.on('mouseout', (event, d) => { - d3.select(event.currentTarget) - .attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8))) // Reduced from max 8 to max 3 - .attr('stroke-opacity', 0.7); // Reduced from 0.7 for consistency - }); - - // Add legend - this.addLegend(svgGroup); - } catch (error) { - logger.error('Failed to render graph:', error); - } - } - - addLegend(svgGroup) { - const legend = svgGroup.append('g') - .attr('class', 'graph-element') - .attr('transform', `translate(120, 120)`) // Increased from (80, 80) for more space from edges - .style('opacity', '0'); // Hide the legend but keep it in the code - - // Add background for better visibility - legend.append('rect') - .attr('width', 320) // Increased from 280 for more space - .attr('height', 120) // Increased from 100 for more space - .attr('fill', 'rgba(0, 0, 0, 0.7)') - .attr('rx', 8) - .attr('stroke', 'rgba(255, 255, 255, 0.2)') - .attr('stroke-width', 1); - - // Node status legend - const nodeLegend = legend.append('g') - .attr('transform', 'translate(20, 20)'); // Increased from (15, 15) for more internal padding - - nodeLegend.append('text') - .text('Node Status:') - .attr('x', 0) - .attr('y', 0) - .attr('font-size', '14px') // Increased from 13px for better readability - .attr('font-weight', '600') - .attr('fill', '#ecf0f1'); - - const statuses = [ - { status: 'ACTIVE', color: '#10b981', y: 20 }, - { status: 'INACTIVE', color: '#f59e0b', y: 40 }, - { status: 'DEAD', color: '#ef4444', y: 60 } - ]; - - statuses.forEach(item => { - nodeLegend.append('circle') - .attr('r', 6) - .attr('cx', 0) - .attr('cy', item.y) - .attr('fill', item.color); - - nodeLegend.append('text') - .text(item.status) - .attr('x', 15) - .attr('y', item.y + 4) - .attr('font-size', '12px') // Increased from 11px for better readability - .attr('fill', '#ecf0f1'); - }); - - // Link latency legend - const linkLegend = legend.append('g') - .attr('transform', 'translate(150, 20)'); // Adjusted position for better spacing - - linkLegend.append('text') - .text('Link Latency:') - .attr('x', 0) - .attr('y', 0) - .attr('font-size', '14px') // Increased from 13px for better readability - .attr('font-weight', '600') - .attr('fill', '#ecf0f1'); - - const latencies = [ - { range: '≤30ms', color: '#10b981', y: 20 }, - { range: '31-50ms', color: '#f59e0b', y: 40 }, - { range: '>50ms', color: '#ef4444', y: 60 } - ]; - - latencies.forEach(item => { - linkLegend.append('line') - .attr('x1', 0) - .attr('y1', item.y) - .attr('x2', 20) - .attr('y2', item.y) - .attr('stroke', item.color) - .attr('stroke-width', 2); // Reduced from 3 to match the thinner graph lines - - linkLegend.append('text') - .text(item.range) - .attr('x', 25) - .attr('y', item.y + 4) - .attr('font-size', '12px') // Increased from 11px for better readability - .attr('fill', '#ecf0f1'); - }); - } - - getNodeRadius(status) { - switch (status?.toUpperCase()) { - case 'ACTIVE': - return 10; - case 'INACTIVE': - return 8; - case 'DEAD': - return 6; - default: - return 8; - } - } - - getStatusIndicatorColor(status) { - switch (status?.toUpperCase()) { - case 'ACTIVE': - return '#10b981'; // Green - case 'INACTIVE': - return '#f59e0b'; // Orange - case 'DEAD': - return '#ef4444'; // Red - default: - return '#6b7280'; // Gray - } - } - - getLinkColor(latency) { - if (latency <= 30) return '#10b981'; // Green for low latency (≤30ms) - if (latency <= 50) return '#f59e0b'; // Orange for medium latency (31-50ms) - return '#ef4444'; // Red for high latency (>50ms) - } - - getNodeColor(status) { - switch (status?.toUpperCase()) { - case 'ACTIVE': - return '#10b981'; // Green - case 'INACTIVE': - return '#f59e0b'; // Orange - case 'DEAD': - return '#ef4444'; // Red - default: - return '#6b7280'; // Gray - } - } - - drag(simulation) { - return d3.drag() - .on('start', function(event, d) { - if (!event.active && simulation && simulation.alphaTarget) { - simulation.alphaTarget(0.3).restart(); - } - d.fx = d.x; - d.fy = d.y; - }) - .on('drag', function(event, d) { - d.fx = event.x; - d.fy = event.y; - }) - .on('end', function(event, d) { - if (!event.active && simulation && simulation.alphaTarget) { - simulation.alphaTarget(0); - } - d.fx = null; - d.fy = null; - }); - } - - updateSelection(selectedNodeId) { - // Update visual selection - if (!this.svg || !this.isInitialized) { - return; - } - - this.svg.selectAll('.node').select('circle') - .attr('stroke-width', d => d.id === selectedNodeId ? 4 : 2) - .attr('stroke', d => d.id === selectedNodeId ? '#2196F3' : '#fff'); - } - - handleRefresh() { - logger.debug('TopologyGraphComponent: handleRefresh called'); - - if (!this.isInitialized) { - logger.debug('TopologyGraphComponent: Component not yet initialized, ensuring initialization...'); - this.ensureInitialized().then(() => { - // Refresh after initialization - this.viewModel.updateNetworkTopology(); - }).catch(error => { - logger.error('TopologyGraphComponent: Failed to initialize for refresh:', error); - }); - return; - } - - logger.debug('TopologyGraphComponent: Calling updateNetworkTopology...'); - this.viewModel.updateNetworkTopology(); - } - - handleLoadingState(isLoading) { - logger.debug('TopologyGraphComponent: handleLoadingState called with:', isLoading); - const container = this.findElement('#topology-graph-container'); - - if (isLoading) { - container.innerHTML = '
Loading network topology...
'; - } - } - - handleError() { - const error = this.viewModel.get('error'); - if (error) { - const container = this.findElement('#topology-graph-container'); - container.innerHTML = `
Error: ${error}
`; - } - } - - showNoData() { - const container = this.findElement('#topology-graph-container'); - container.innerHTML = '
No cluster members found
'; - } - - showMemberCardOverlay(nodeData) { - // Create overlay container if it doesn't exist - let overlayContainer = document.getElementById('member-card-overlay'); - if (!overlayContainer) { - overlayContainer = document.createElement('div'); - overlayContainer.id = 'member-card-overlay'; - overlayContainer.className = 'member-card-overlay'; - document.body.appendChild(overlayContainer); - } - - // Create and show the overlay component - if (!this.memberOverlayComponent) { - const overlayVM = new ViewModel(); - this.memberOverlayComponent = new MemberCardOverlayComponent(overlayContainer, overlayVM, this.eventBus); - this.memberOverlayComponent.mount(); - } - - // Convert node data to member data format - const memberData = { - ip: nodeData.ip, - hostname: nodeData.hostname, - status: this.normalizeStatus(nodeData.status), - latency: nodeData.latency, - labels: nodeData.resources || {} - }; - - this.memberOverlayComponent.show(memberData); - } - - // Normalize status from topology format to member card format - normalizeStatus(status) { - if (!status) return 'unknown'; - - const normalized = status.toLowerCase(); - switch (normalized) { - case 'active': - return 'active'; - case 'inactive': - return 'inactive'; - case 'dead': - return 'offline'; - default: - return 'unknown'; - } - } - - // Override render method to display the graph - render() { - logger.debug('TopologyGraphComponent: render called'); - if (!this.isInitialized) { - logger.debug('TopologyGraphComponent: Not initialized yet, skipping render'); - return; - } - const nodes = this.viewModel.get('nodes'); - const links = this.viewModel.get('links'); - if (nodes && nodes.length > 0) { - logger.debug('TopologyGraphComponent: Rendering graph with data'); - this.renderGraph(); - } else { - logger.debug('TopologyGraphComponent: No data available, showing loading state'); - this.handleLoadingState(true); - } - } - - unmount() { - // Clean up resize listener - if (this.resizeHandler) { - window.removeEventListener('resize', this.resizeHandler); - this.resizeHandler = null; - } - - // Clear resize timeout - if (this.resizeTimeout) { - clearTimeout(this.resizeTimeout); - this.resizeTimeout = null; - } - - // Call parent unmount - super.unmount(); - } - -} - -// Member Card Overlay Component for displaying member details in topology view -class MemberCardOverlayComponent extends Component { - constructor(container, viewModel, eventBus) { - super(container, viewModel, eventBus); - this.isVisible = false; - this.currentMember = null; - } - - mount() { - super.mount(); - this.setupEventListeners(); - } - - setupEventListeners() { - // Close overlay when clicking outside or pressing escape - this.addEventListener(this.container, 'click', (e) => { - if (!this.isVisible) return; - // Only close when clicking on the backdrop, not inside the dialog content - if (e.target === this.container) { - this.hide(); - } - }); - - this.addEventListener(document, 'keydown', (e) => { - if (e.key === 'Escape' && this.isVisible) { - this.hide(); - } - }); - } - - show(memberData) { - this.currentMember = memberData; - this.isVisible = true; - - const memberCardHTML = this.renderMemberCard(memberData); - this.setHTML('', memberCardHTML); - - // Add visible class for animation - setTimeout(() => { - this.container.classList.add('visible'); - }, 10); - - // Setup member card interactions - this.setupMemberCardInteractions(); - } - - - - - - hide() { - this.isVisible = false; - this.container.classList.remove('visible'); - this.currentMember = null; - } - - renderMemberCard(member) { - const statusClass = member.status === 'active' ? 'status-online' : - member.status === 'inactive' ? 'status-inactive' : 'status-offline'; - const statusText = member.status === 'active' ? 'Online' : - member.status === 'inactive' ? 'Inactive' : - member.status === 'offline' ? 'Offline' : 'Unknown'; - const statusIcon = member.status === 'active' ? '🟢' : - member.status === 'inactive' ? '🟠' : '🔴'; - - return ` -
-
-
-
-
-
- ${statusIcon} -
-
${member.hostname || 'Unknown Device'}
-
-
${member.ip || 'No IP'}
-
- Latency: - ${member.latency ? member.latency + 'ms' : 'N/A'} -
-
- -
- -
- -
-
-
-
Loading detailed information...
-
-
-
-
- `; - } - - setupMemberCardInteractions() { - // Close button - const closeBtn = this.findElement('.member-overlay-close'); - if (closeBtn) { - this.addEventListener(closeBtn, 'click', () => { - this.hide(); - }); - } - - // Setup member card expansion - automatically expand when shown - setTimeout(async () => { - const memberCard = this.findElement('.member-card'); - if (memberCard) { - const memberDetails = memberCard.querySelector('.member-details'); - const memberIp = memberCard.dataset.memberIp; - - // Automatically expand the card to show details - await this.expandCard(memberCard, memberIp, memberDetails); - } - }, 100); - } - - async expandCard(card, memberIp, memberDetails) { - try { - // Create node details view model and component - const nodeDetailsVM = new NodeDetailsViewModel(); - const nodeDetailsComponent = new NodeDetailsComponent(memberDetails, nodeDetailsVM, this.eventBus); - - // Load node details - await nodeDetailsVM.loadNodeDetails(memberIp); - - // Update the labels in the member header with the actual node status data - const nodeStatus = nodeDetailsVM.get('nodeStatus'); - if (nodeStatus && nodeStatus.labels) { - // Find the labels container in the header - const labelsContainer = document.querySelector('.member-overlay-header .member-labels'); - if (labelsContainer) { - // Update existing labels container and show it - labelsContainer.innerHTML = Object.entries(nodeStatus.labels) - .map(([key, value]) => `${key}: ${value}`) - .join(''); - labelsContainer.style.display = 'block'; - } - } - - // Mount the component - nodeDetailsComponent.mount(); - - // Update UI - card.classList.add('expanded'); - - } catch (error) { - logger.error('Failed to expand member card:', error); - // Still show the UI even if details fail to load - card.classList.add('expanded'); - const details = card.querySelector('.member-details'); - if (details) { - details.innerHTML = '
Failed to load node details
'; - } - } - } -} \ No newline at end of file From 9986b4acac1be0e7f851016638f326a525b81966 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Tue, 2 Sep 2025 13:25:08 +0200 Subject: [PATCH 18/18] UI: Improve cluster view error styling Add scoped styles for #cluster-members-container .error: better contrast, spacing, icon; override global centering for left-aligned layout. --- public/styles/main.css | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/public/styles/main.css b/public/styles/main.css index 41986c4..e35beef 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -3426,4 +3426,38 @@ html { #firmware-view .deploy-btn:hover:not(:disabled)::before { left: -100% !important; } +} + +/* Cluster view specific error styling */ +#cluster-members-container .error { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.9rem 1.1rem; + margin-top: 0.75rem; + border-radius: 12px; + background: linear-gradient(135deg, rgba(244, 67, 54, 0.15) 0%, rgba(244, 67, 54, 0.08) 100%); + border: 1px solid rgba(244, 67, 54, 0.35); + color: #ffcdd2; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); + height: auto; /* override global 100% height */ + justify-content: flex-start; /* override global centering */ + text-align: left; /* ensure left alignment */ +} + +#cluster-members-container .error::before { + content: '⚠️'; + font-size: 1.2rem; + line-height: 1; + flex-shrink: 0; +} + +#cluster-members-container .error strong { + color: #ffebee; + font-weight: 700; + margin-right: 0.25rem; +} + +#cluster-members-container .error br { + display: none; /* tighten layout by avoiding forced line-breaks */ } \ No newline at end of file