From cc7fa0fa0065215f80002812ef98b689cfeb339f Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 14:00:33 +0200 Subject: [PATCH] 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