diff --git a/public/scripts/components.js b/public/scripts/components.js deleted file mode 100644 index dea26ac..0000000 --- a/public/scripts/components.js +++ /dev/null @@ -1,3125 +0,0 @@ -// SPORE UI Components - -// Primary Node Component -class PrimaryNodeComponent extends Component { - constructor(container, viewModel, eventBus) { - super(container, viewModel, eventBus); - } - - setupEventListeners() { - const refreshBtn = this.findElement('.primary-node-refresh'); - if (refreshBtn) { - this.addEventListener(refreshBtn, 'click', this.handleRandomSelection.bind(this)); - } - } - - setupViewModelListeners() { - // Listen to primary node changes - this.subscribeToProperty('primaryNode', this.render.bind(this)); - this.subscribeToProperty('clientInitialized', this.render.bind(this)); - this.subscribeToProperty('totalNodes', this.render.bind(this)); - this.subscribeToProperty('onlineNodes', this.render.bind(this)); - this.subscribeToProperty('error', this.render.bind(this)); - } - - render() { - const primaryNode = this.viewModel.get('primaryNode'); - const clientInitialized = this.viewModel.get('clientInitialized'); - const totalNodes = this.viewModel.get('totalNodes'); - const onlineNodes = this.viewModel.get('onlineNodes'); - const error = this.viewModel.get('error'); - - if (error) { - this.setText('#primary-node-ip', '❌ Discovery Failed'); - this.setClass('#primary-node-ip', 'error', true); - this.setClass('#primary-node-ip', 'discovering', false); - this.setClass('#primary-node-ip', 'selecting', false); - return; - } - - if (!primaryNode) { - this.setText('#primary-node-ip', '🔍 No Nodes Found'); - this.setClass('#primary-node-ip', 'error', true); - this.setClass('#primary-node-ip', 'discovering', false); - this.setClass('#primary-node-ip', 'selecting', false); - return; - } - - const status = clientInitialized ? '✅' : '⚠️'; - const nodeCount = (onlineNodes && onlineNodes > 0) - ? ` (${onlineNodes}/${totalNodes} online)` - : (totalNodes > 1 ? ` (${totalNodes} nodes)` : ''); - - this.setText('#primary-node-ip', `${status} ${primaryNode}${nodeCount}`); - this.setClass('#primary-node-ip', 'error', false); - this.setClass('#primary-node-ip', 'discovering', false); - this.setClass('#primary-node-ip', 'selecting', false); - } - - async handleRandomSelection() { - try { - // Show selecting state - this.setText('#primary-node-ip', '🎲 Selecting...'); - this.setClass('#primary-node-ip', 'selecting', true); - this.setClass('#primary-node-ip', 'discovering', false); - this.setClass('#primary-node-ip', 'error', false); - - await this.viewModel.selectRandomPrimaryNode(); - - // Show success briefly - this.setText('#primary-node-ip', '🎯 Selection Complete'); - - // Update display after delay - setTimeout(() => { - this.viewModel.updatePrimaryNodeDisplay(); - }, 1500); - - } catch (error) { - logger.error('Failed to select random primary node:', error); - this.setText('#primary-node-ip', '❌ Selection Failed'); - this.setClass('#primary-node-ip', 'error', true); - this.setClass('#primary-node-ip', 'selecting', false); - this.setClass('#primary-node-ip', 'discovering', false); - - // Revert to normal display after error - setTimeout(() => { - this.viewModel.updatePrimaryNodeDisplay(); - }, 2000); - } - } -} - -// Cluster Members Component with enhanced state preservation -class ClusterMembersComponent extends Component { - constructor(container, viewModel, eventBus) { - super(container, viewModel, eventBus); - - logger.debug('ClusterMembersComponent: Constructor called'); - logger.debug('ClusterMembersComponent: Container:', container); - logger.debug('ClusterMembersComponent: Container ID:', container?.id); - logger.debug('ClusterMembersComponent: Container innerHTML:', container?.innerHTML); - - // Track if we're in the middle of a render operation - this.renderInProgress = false; - this.lastRenderData = null; - - // Ensure initial render happens even if no data - setTimeout(() => { - if (this.isMounted && !this.renderInProgress) { - logger.debug('ClusterMembersComponent: Performing initial render check'); - this.render(); - } - }, 200); - } - - mount() { - logger.debug('ClusterMembersComponent: Starting mount...'); - super.mount(); - - // Show loading state immediately when mounted - logger.debug('ClusterMembersComponent: Showing initial loading state'); - this.showLoadingState(); - - // Set up loading timeout safeguard - this.setupLoadingTimeout(); - - logger.debug('ClusterMembersComponent: Mounted successfully'); - } - - // Setup loading timeout safeguard to prevent getting stuck in loading state - setupLoadingTimeout() { - this.loadingTimeout = setTimeout(() => { - const isLoading = this.viewModel.get('isLoading'); - if (isLoading) { - logger.warn('ClusterMembersComponent: Loading timeout reached, forcing render check'); - this.forceRenderCheck(); - } - }, 10000); // 10 second timeout - } - - // Force a render check when loading gets stuck - forceRenderCheck() { - logger.debug('ClusterMembersComponent: Force render check called'); - const members = this.viewModel.get('members'); - const error = this.viewModel.get('error'); - const isLoading = this.viewModel.get('isLoading'); - - logger.debug('ClusterMembersComponent: Force render check state:', { members, error, isLoading }); - - if (error) { - this.showErrorState(error); - } else if (members && members.length > 0) { - this.renderMembers(members); - } else if (!isLoading) { - this.showEmptyState(); - } - } - - setupEventListeners() { - logger.debug('ClusterMembersComponent: Setting up event listeners...'); - // Note: Refresh button is now handled by ClusterViewComponent - // since it's in the cluster header, not in the members container - } - - setupViewModelListeners() { - logger.debug('ClusterMembersComponent: Setting up view model listeners...'); - // Listen to cluster members changes with change detection - this.subscribeToProperty('members', this.handleMembersUpdate.bind(this)); - this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this)); - this.subscribeToProperty('error', this.handleErrorUpdate.bind(this)); - logger.debug('ClusterMembersComponent: View model listeners set up'); - } - - // Handle members update with state preservation - handleMembersUpdate(newMembers, previousMembers) { - logger.debug('ClusterMembersComponent: Members updated:', { newMembers, previousMembers }); - - // Prevent multiple simultaneous renders - if (this.renderInProgress) { - logger.debug('ClusterMembersComponent: Render already in progress, skipping update'); - return; - } - - // Check if we're currently loading - if so, let the loading handler deal with it - const isLoading = this.viewModel.get('isLoading'); - if (isLoading) { - logger.debug('ClusterMembersComponent: Currently loading, skipping members update (will be handled by loading completion)'); - return; - } - - // On first load (no previous members), always render - if (!previousMembers || !Array.isArray(previousMembers) || previousMembers.length === 0) { - logger.debug('ClusterMembersComponent: First load or no previous members, performing full render'); - this.render(); - return; - } - - if (this.shouldPreserveState(newMembers, previousMembers)) { - // Perform partial update to preserve UI state - logger.debug('ClusterMembersComponent: Preserving state, performing partial update'); - this.updateMembersPartially(newMembers, previousMembers); - } else { - // Full re-render if structure changed significantly - logger.debug('ClusterMembersComponent: Structure changed, performing full re-render'); - this.render(); - } - } - - // Handle loading state update - handleLoadingUpdate(isLoading) { - logger.debug('ClusterMembersComponent: Loading state changed:', isLoading); - - if (isLoading) { - logger.debug('ClusterMembersComponent: Showing loading state'); - this.renderLoading(`\n
\n
Loading cluster members...
\n
\n `); - - // Set up a loading completion check - this.checkLoadingCompletion(); - } else { - logger.debug('ClusterMembersComponent: Loading completed, checking if we need to render'); - // When loading completes, check if we have data to render - this.handleLoadingCompletion(); - } - } - - // Check if loading has completed and handle accordingly - handleLoadingCompletion() { - const members = this.viewModel.get('members'); - const error = this.viewModel.get('error'); - const isLoading = this.viewModel.get('isLoading'); - - logger.debug('ClusterMembersComponent: Handling loading completion:', { members, error, isLoading }); - - if (error) { - logger.debug('ClusterMembersComponent: Loading completed with error, showing error state'); - this.showErrorState(error); - } else if (members && members.length > 0) { - logger.debug('ClusterMembersComponent: Loading completed with data, rendering members'); - this.renderMembers(members); - } else if (!isLoading) { - logger.debug('ClusterMembersComponent: Loading completed but no data, showing empty state'); - this.showEmptyState(); - } - } - - // Set up a check to ensure loading completion is handled - checkLoadingCompletion() { - // Clear any existing completion check - if (this.loadingCompletionCheck) { - clearTimeout(this.loadingCompletionCheck); - } - - // Set up a completion check that runs after a short delay - this.loadingCompletionCheck = setTimeout(() => { - const isLoading = this.viewModel.get('isLoading'); - if (!isLoading) { - logger.debug('ClusterMembersComponent: Loading completion check triggered'); - this.handleLoadingCompletion(); - } - }, 1000); // Check after 1 second - } - - // Handle error state update - handleErrorUpdate(error) { - if (error) { - this.showErrorState(error); - } - } - - // Check if we should preserve UI state during update - shouldPreserveState(newMembers, previousMembers) { - if (!previousMembers || !Array.isArray(previousMembers)) return false; - if (!Array.isArray(newMembers)) return false; - - // If member count changed, we need to re-render - if (newMembers.length !== previousMembers.length) return false; - - // Check if member IPs are the same (same nodes) - const newIps = new Set(newMembers.map(m => m.ip)); - const prevIps = new Set(previousMembers.map(m => m.ip)); - - // If IPs are the same, we can preserve state - return newIps.size === prevIps.size && - [...newIps].every(ip => prevIps.has(ip)); - } - - // Check if we should skip rendering during view switches - shouldSkipRender() { - // Rely on lifecycle flags controlled by App - if (!this.isMounted || this.isPaused) { - logger.debug('ClusterMembersComponent: Not mounted or paused, skipping render'); - return true; - } - return false; - } - - // Update members partially to preserve UI state - updateMembersPartially(newMembers, previousMembers) { - logger.debug('ClusterMembersComponent: Performing partial update to preserve UI state'); - - // Build previous map by IP for stable diffs - const prevByIp = new Map((previousMembers || []).map(m => [m.ip, m])); - newMembers.forEach((newMember) => { - const prevMember = prevByIp.get(newMember.ip); - if (prevMember && this.hasMemberChanged(newMember, prevMember)) { - this.updateMemberCard(newMember); - } - }); - } - - // Check if a specific member has changed - hasMemberChanged(newMember, prevMember) { - return newMember.status !== prevMember.status || - newMember.latency !== prevMember.latency || - newMember.hostname !== prevMember.hostname; - } - - // Update a specific member card without re-rendering the entire component - updateMemberCard(member) { - const card = this.findElement(`[data-member-ip="${member.ip}"]`); - if (!card) return; - - // Update status - const statusElement = card.querySelector('.member-status'); - if (statusElement) { - const statusClass = member.status === 'active' ? 'status-online' : 'status-offline'; - const statusIcon = member.status === 'active' ? '🟢' : '🔴'; - - statusElement.className = `member-status ${statusClass}`; - statusElement.innerHTML = `${statusIcon}`; - } - - // Update latency - const latencyElement = card.querySelector('.latency-value'); - if (latencyElement) { - latencyElement.textContent = member.latency ? member.latency + 'ms' : 'N/A'; - } - - // Update hostname if changed - const hostnameElement = card.querySelector('.member-hostname'); - if (hostnameElement && member.hostname !== hostnameElement.textContent) { - hostnameElement.textContent = member.hostname || 'Unknown Device'; - } - } - - render() { - if (this.renderInProgress) { - logger.debug('ClusterMembersComponent: Render already in progress, skipping'); - return; - } - - // Check if we should skip rendering during view switches - if (this.shouldSkipRender()) { - return; - } - - this.renderInProgress = true; - - try { - logger.debug('ClusterMembersComponent: render() called'); - logger.debug('ClusterMembersComponent: Container element:', this.container); - logger.debug('ClusterMembersComponent: Is mounted:', this.isMounted); - - const members = this.viewModel.get('members'); - const isLoading = this.viewModel.get('isLoading'); - const error = this.viewModel.get('error'); - - logger.debug('ClusterMembersComponent: render data:', { members, isLoading, error }); - - if (isLoading) { - logger.debug('ClusterMembersComponent: Showing loading state'); - this.showLoadingState(); - return; - } - - if (error) { - logger.debug('ClusterMembersComponent: Showing error state'); - this.showErrorState(error); - return; - } - - if (!members || members.length === 0) { - logger.debug('ClusterMembersComponent: Showing empty state'); - this.showEmptyState(); - return; - } - - logger.debug('ClusterMembersComponent: Rendering members:', members); - this.renderMembers(members); - - } finally { - this.renderInProgress = false; - } - } - - // Show loading state - showLoadingState() { - logger.debug('ClusterMembersComponent: showLoadingState() called'); - this.renderLoading(` -
-
Loading cluster members...
-
- `); - } - - // Show error state - showErrorState(error) { - logger.debug('ClusterMembersComponent: showErrorState() called with error:', error); - this.renderError(`Error loading cluster members: ${error}`); - } - - // Show empty state - showEmptyState() { - logger.debug('ClusterMembersComponent: showEmptyState() called'); - this.renderEmpty(` -
-
🌐
-
No cluster members found
-
- The cluster might be empty or not yet discovered -
-
- `); - } - - renderMembers(members) { - logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members'); - - const membersHTML = members.map(member => { - const statusClass = member.status === 'active' ? 'status-online' : 'status-offline'; - const statusText = member.status === 'active' ? 'Online' : 'Offline'; - const statusIcon = member.status === 'active' ? '🟢' : '🔴'; - - logger.debug('ClusterMembersComponent: Rendering member:', member); - - return ` -
-
-
-
-
-
- ${statusIcon} -
-
${this.escapeHtml(member.hostname || 'Unknown Device')}
-
-
${this.escapeHtml(member.ip || 'No IP')}
-
- Latency: - ${member.latency ? member.latency + 'ms' : 'N/A'} -
-
- ${member.labels && Object.keys(member.labels).length ? ` -
-
- ${Object.entries(member.labels).map(([key, value]) => `${this.escapeHtml(key)}: ${this.escapeHtml(value)}`).join('')} -
-
- ` : ''} -
-
- - - -
-
-
-
Loading detailed information...
-
-
- `; - }).join(''); - - logger.debug('ClusterMembersComponent: Setting HTML, length:', membersHTML.length); - this.setHTML('', membersHTML); - logger.debug('ClusterMembersComponent: HTML set, setting up member cards...'); - this.setupMemberCards(members); - } - - setupMemberCards(members) { - setTimeout(() => { - this.findAllElements('.member-card').forEach((card, index) => { - const expandIcon = card.querySelector('.expand-icon'); - const memberDetails = card.querySelector('.member-details'); - const memberIp = card.dataset.memberIp; - - // Ensure all cards start collapsed by default - card.classList.remove('expanded'); - if (expandIcon) { - expandIcon.classList.remove('expanded'); - } - - // Clear any previous content - memberDetails.innerHTML = '
Loading detailed information...
'; - - // Make the entire card clickable - this.addEventListener(card, 'click', async (e) => { - if (e.target === expandIcon) return; - - const isExpanding = !card.classList.contains('expanded'); - - if (isExpanding) { - await this.expandCard(card, memberIp, memberDetails); - } else { - this.collapseCard(card, expandIcon); - } - }); - - // Keep the expand icon click handler for visual feedback - if (expandIcon) { - this.addEventListener(expandIcon, 'click', async (e) => { - e.stopPropagation(); - - const isExpanding = !card.classList.contains('expanded'); - - if (isExpanding) { - await this.expandCard(card, memberIp, memberDetails); - } else { - this.collapseCard(card, expandIcon); - } - }); - } - }); - }, 100); - } - - async expandCard(card, memberIp, memberDetails) { - try { - // Create node details view model and component - const nodeDetailsVM = new NodeDetailsViewModel(); - const nodeDetailsComponent = new NodeDetailsComponent(memberDetails, nodeDetailsVM, this.eventBus); - - // Load node details - await nodeDetailsVM.loadNodeDetails(memberIp); - - // Mount the component - nodeDetailsComponent.mount(); - - // Update UI - card.classList.add('expanded'); - const expandIcon = card.querySelector('.expand-icon'); - if (expandIcon) { - expandIcon.classList.add('expanded'); - } - - } catch (error) { - logger.error('Failed to expand card:', error); - memberDetails.innerHTML = ` -
- Error loading node details:
- ${error.message} -
- `; - } - } - - collapseCard(card, expandIcon) { - card.classList.remove('expanded'); - if (expandIcon) { - expandIcon.classList.remove('expanded'); - } - } - - setupTabs(container) { - super.setupTabs(container, { - onChange: (targetTab) => { - const memberCard = container.closest('.member-card'); - if (memberCard) { - const memberIp = memberCard.dataset.memberIp; - this.viewModel.storeActiveTab(memberIp, targetTab); - } - } - }); - } - - // Restore active tab state - restoreActiveTab(container, activeTab) { - const tabButtons = container.querySelectorAll('.tab-button'); - const tabContents = container.querySelectorAll('.tab-content'); - - // Remove active class from all buttons and contents - tabButtons.forEach(btn => btn.classList.remove('active')); - tabContents.forEach(content => content.classList.remove('active')); - - // Add active class to the restored tab - const activeButton = container.querySelector(`[data-tab="${activeTab}"]`); - const activeContent = container.querySelector(`#${activeTab}-tab`); - - if (activeButton) activeButton.classList.add('active'); - if (activeContent) activeContent.classList.add('active'); - } - - // Note: handleRefresh method has been moved to ClusterViewComponent - // since the refresh button is in the cluster header, not in the members container - - // Debug method to check component state - debugState() { - const members = this.viewModel.get('members'); - const isLoading = this.viewModel.get('isLoading'); - const error = this.viewModel.get('error'); - const expandedCards = this.viewModel.get('expandedCards'); - const activeTabs = this.viewModel.get('activeTabs'); - - logger.debug('ClusterMembersComponent: Debug State:', { - isMounted: this.isMounted, - container: this.container, - members: members, - membersCount: members?.length || 0, - isLoading: isLoading, - error: error, - expandedCardsCount: expandedCards?.size || 0, - activeTabsCount: activeTabs?.size || 0, - loadingTimeout: this.loadingTimeout - }); - - return { members, isLoading, error, expandedCards, activeTabs }; - } - - // Manual refresh method that bypasses potential state conflicts - async manualRefresh() { - logger.debug('ClusterMembersComponent: Manual refresh called'); - - try { - // Clear any existing loading state - this.viewModel.set('isLoading', false); - this.viewModel.set('error', null); - - // Force a fresh data load - await this.viewModel.updateClusterMembers(); - - logger.debug('ClusterMembersComponent: Manual refresh completed'); - } catch (error) { - logger.error('ClusterMembersComponent: Manual refresh failed:', error); - this.showErrorState(error.message); - } - } - - unmount() { - if (!this.isMounted) return; - - this.isMounted = false; - - // Clear any pending timeouts - if (this.loadingTimeout) { - clearTimeout(this.loadingTimeout); - this.loadingTimeout = null; - } - - if (this.loadingCompletionCheck) { - clearTimeout(this.loadingCompletionCheck); - this.loadingCompletionCheck = null; - } - - // Clear any pending render operations - this.renderInProgress = false; - - this.cleanupEventListeners(); - this.cleanupViewModelListeners(); - - logger.debug(`${this.constructor.name} unmounted`); - } - - // Override pause method to handle timeouts and operations - onPause() { - logger.debug('ClusterMembersComponent: Pausing...'); - - // Clear any pending timeouts - if (this.loadingTimeout) { - clearTimeout(this.loadingTimeout); - this.loadingTimeout = null; - } - - if (this.loadingCompletionCheck) { - clearTimeout(this.loadingCompletionCheck); - this.loadingCompletionCheck = null; - } - - // Mark as paused to prevent new operations - this.isPaused = true; - } - - // Override resume method to restore functionality - onResume() { - logger.debug('ClusterMembersComponent: Resuming...'); - - this.isPaused = false; - - // Re-setup loading timeout if needed - if (!this.loadingTimeout) { - this.setupLoadingTimeout(); - } - - // Check if we need to handle any pending operations - this.checkPendingOperations(); - } - - // Check for any operations that need to be handled after resume - checkPendingOperations() { - const isLoading = this.viewModel.get('isLoading'); - const members = this.viewModel.get('members'); - - // If we were loading and it completed while paused, handle the completion - if (!isLoading && members && members.length > 0) { - logger.debug('ClusterMembersComponent: Handling pending loading completion after resume'); - this.handleLoadingCompletion(); - } - } - - // Override to determine if re-render is needed on resume - shouldRenderOnResume() { - // Don't re-render on resume - maintain current state - return false; - } -} - -// Node Details Component with enhanced state preservation -class NodeDetailsComponent extends Component { - constructor(container, viewModel, eventBus) { - super(container, viewModel, eventBus); - } - - setupViewModelListeners() { - this.subscribeToProperty('nodeStatus', this.handleNodeStatusUpdate.bind(this)); - this.subscribeToProperty('tasks', this.handleTasksUpdate.bind(this)); - this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this)); - this.subscribeToProperty('error', this.handleErrorUpdate.bind(this)); - this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this)); - this.subscribeToProperty('capabilities', this.handleCapabilitiesUpdate.bind(this)); - } - - // Handle node status update with state preservation - handleNodeStatusUpdate(newStatus, previousStatus) { - if (newStatus && !this.viewModel.get('isLoading')) { - this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('capabilities')); - } - } - - // Handle tasks update with state preservation - handleTasksUpdate(newTasks, previousTasks) { - const nodeStatus = this.viewModel.get('nodeStatus'); - if (nodeStatus && !this.viewModel.get('isLoading')) { - this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('capabilities')); - } - } - - // Handle loading state update - handleLoadingUpdate(isLoading) { - if (isLoading) { - this.renderLoading('
Loading detailed information...
'); - } - } - - // Handle error state update - handleErrorUpdate(error) { - if (error) { - this.renderError(`Error loading node details: ${error}`); - } - } - - // Handle active tab update - handleActiveTabUpdate(newTab, previousTab) { - // Update tab UI without full re-render - this.updateActiveTab(newTab, previousTab); - } - - // Handle capabilities update with state preservation - handleCapabilitiesUpdate(newCapabilities, previousCapabilities) { - const nodeStatus = this.viewModel.get('nodeStatus'); - const tasks = this.viewModel.get('tasks'); - if (nodeStatus && !this.viewModel.get('isLoading')) { - this.renderNodeDetails(nodeStatus, tasks, newCapabilities); - } - } - - render() { - const nodeStatus = this.viewModel.get('nodeStatus'); - const tasks = this.viewModel.get('tasks'); - const isLoading = this.viewModel.get('isLoading'); - const error = this.viewModel.get('error'); - const capabilities = this.viewModel.get('capabilities'); - - if (isLoading) { - this.renderLoading('
Loading detailed information...
'); - return; - } - - if (error) { - this.renderError(`Error loading node details: ${error}`); - return; - } - - if (!nodeStatus) { - this.renderEmpty('
No node status available
'); - return; - } - - this.renderNodeDetails(nodeStatus, tasks, capabilities); - } - - renderNodeDetails(nodeStatus, tasks, capabilities) { - // Use persisted active tab from the view model, default to 'status' - const activeTab = (this.viewModel && typeof this.viewModel.get === 'function' && this.viewModel.get('activeTab')) || 'status'; - logger.debug('NodeDetailsComponent: Rendering with activeTab:', activeTab); - - const html = ` -
-
- - - - - -
- -
-
- Free Heap: - ${Math.round(nodeStatus.freeHeap / 1024)}KB -
-
- Chip ID: - ${nodeStatus.chipId} -
-
- SDK Version: - ${nodeStatus.sdkVersion} -
-
- CPU Frequency: - ${nodeStatus.cpuFreqMHz}MHz -
-
- Flash Size: - ${Math.round(nodeStatus.flashChipSize / 1024)}KB -
-
- -
- ${nodeStatus.api ? nodeStatus.api.map(endpoint => - `
${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}
` - ).join('') : '
No API endpoints available
'} -
- -
- ${this.renderCapabilitiesTab(capabilities)} -
- -
- ${this.renderTasksTab(tasks)} -
- -
- ${this.renderFirmwareTab()} -
-
- `; - - this.setHTML('', html); - this.setupTabs(); - // Restore last active tab from view model if available - const restored = this.viewModel && typeof this.viewModel.get === 'function' ? this.viewModel.get('activeTab') : null; - if (restored) { - this.setActiveTab(restored); - } - this.setupFirmwareUpload(); - } - - renderCapabilitiesTab(capabilities) { - if (!capabilities || !Array.isArray(capabilities.endpoints) || capabilities.endpoints.length === 0) { - return ` -
-
🧩 No capabilities reported
-
This node did not return any capabilities
-
- `; - } - - // Sort endpoints by URI (name), then by method for stable ordering - const endpoints = [...capabilities.endpoints].sort((a, b) => { - const aUri = String(a.uri || '').toLowerCase(); - const bUri = String(b.uri || '').toLowerCase(); - if (aUri < bUri) return -1; - if (aUri > bUri) return 1; - const aMethod = String(a.method || '').toLowerCase(); - const bMethod = String(b.method || '').toLowerCase(); - return aMethod.localeCompare(bMethod); - }); - - const total = endpoints.length; - - // Preserve selection based on a stable key of method+uri if available - const selectedKey = String(this.getUIState('capSelectedKey') || ''); - let selectedIndex = endpoints.findIndex(ep => `${ep.method} ${ep.uri}` === selectedKey); - if (selectedIndex === -1) { - selectedIndex = Number(this.getUIState('capSelectedIndex')); - if (Number.isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= total) { - selectedIndex = 0; - } - } - - // Compute padding for aligned display in dropdown - const maxMethodLen = endpoints.reduce((m, ep) => Math.max(m, String(ep.method || '').length), 0); - - const selectorOptions = endpoints.map((ep, idx) => { - const method = String(ep.method || ''); - const uri = String(ep.uri || ''); - const padCount = Math.max(1, (maxMethodLen - method.length) + 2); - const spacer = ' '.repeat(padCount); - return ``; - }).join(''); - - const items = endpoints.map((ep, idx) => { - const formId = `cap-form-${idx}`; - const resultId = `cap-result-${idx}`; - const params = Array.isArray(ep.params) && ep.params.length > 0 - ? `
${ep.params.map((p, pidx) => ` - - `).join('')}
` - : '
No parameters
'; - return ` -
-
- ${ep.method} - ${ep.uri} - -
-
- ${params} -
- -
- `; - }).join(''); - - // Attach events after render in setupCapabilitiesEvents() - setTimeout(() => this.setupCapabilitiesEvents(), 0); - - return ` -
- - -
-
${items}
- `; - } - - setupCapabilitiesEvents() { - const selector = this.findElement('#capability-select'); - if (selector) { - this.addEventListener(selector, 'change', (e) => { - const selected = Number(e.target.value); - const items = Array.from(this.findAllElements('.capability-item')); - items.forEach((el, idx) => { - el.style.display = (idx === selected) ? '' : 'none'; - }); - this.setUIState('capSelectedIndex', selected); - const opt = e.target.selectedOptions && e.target.selectedOptions[0]; - if (opt) { - const method = opt.dataset.method || ''; - const uri = opt.dataset.uri || ''; - this.setUIState('capSelectedKey', `${method} ${uri}`); - } - }); - } - - const buttons = this.findAllElements('.cap-call-btn'); - buttons.forEach(btn => { - this.addEventListener(btn, 'click', async (e) => { - e.stopPropagation(); - const method = btn.dataset.method || 'GET'; - const uri = btn.dataset.uri || ''; - const formId = btn.dataset.formId; - const resultId = btn.dataset.resultId; - - const formEl = this.findElement(`#${formId}`); - const resultEl = this.findElement(`#${resultId}`); - if (!formEl || !resultEl) return; - - const inputs = Array.from(formEl.querySelectorAll('.param-input')); - const params = inputs.map(input => ({ - name: input.dataset.paramName, - location: input.dataset.paramLocation || 'body', - type: input.dataset.paramType || 'string', - required: input.dataset.paramRequired === '1', - value: input.value - })); - - // Required validation - const missing = params.filter(p => p.required && (!p.value || String(p.value).trim() === '')); - if (missing.length > 0) { - resultEl.style.display = 'block'; - resultEl.innerHTML = ` -
-
❌ Missing required fields: ${missing.map(m => this.escapeHtml(m.name)).join(', ')}
-
- `; - return; - } - - // Show loading state - resultEl.style.display = 'block'; - resultEl.innerHTML = '
Calling endpoint...
'; - - try { - const response = await this.viewModel.callCapability(method, uri, params); - const pretty = typeof response?.data === 'object' ? JSON.stringify(response.data, null, 2) : String(response?.data ?? ''); - resultEl.innerHTML = ` -
-
✅ Success
-
${this.escapeHtml(pretty)}
-
- `; - } catch (err) { - resultEl.innerHTML = ` -
-
❌ Error: ${this.escapeHtml(err.message || 'Request failed')}
-
- `; - } - }); - }); - } - - escapeHtml(str) { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>'); - } - - renderTasksTab(tasks) { - const summary = this.viewModel.get('tasksSummary'); - if (tasks && tasks.length > 0) { - const summaryHTML = summary ? ` -
-
-
📋
-
-
Tasks Overview
-
System task management and monitoring
-
-
-
-
-
${summary.totalTasks ?? tasks.length}
-
Total
-
-
-
${summary.activeTasks ?? tasks.filter(t => t.running).length}
-
Active
-
-
-
${(summary.totalTasks ?? tasks.length) - (summary.activeTasks ?? tasks.filter(t => t.running).length)}
-
Stopped
-
-
-
- ` : ''; - const tasksHTML = tasks.map(task => ` -
-
- ${task.name || 'Unknown Task'} - - ${task.running ? '🟢 Running' : '🔴 Stopped'} - -
-
- Interval: ${task.interval}ms - ${task.enabled ? '🟢 Enabled' : '🔴 Disabled'} -
-
- `).join(''); - - return ` - ${summaryHTML} - ${tasksHTML} - `; - } else { - const total = summary?.totalTasks ?? 0; - const active = summary?.activeTasks ?? 0; - return ` -
-
-
📋
-
-
Tasks Overview
-
${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
-
-
-
-
-
${total}
-
Total
-
-
-
${active}
-
Active
-
-
-
${total - active}
-
Stopped
-
-
-
-
-
📋 No active tasks found
-
- ${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'} -
-
- `; - } - } - - renderFirmwareTab() { - return ` -
-

Firmware Update

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

📤 Firmware Upload Progress

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

🎯 Affected Nodes (${nodes.length})

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