// 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) { console.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); console.log('ClusterMembersComponent: Constructor called'); console.log('ClusterMembersComponent: Container:', container); console.log('ClusterMembersComponent: Container ID:', container?.id); console.log('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) { console.log('ClusterMembersComponent: Performing initial render check'); this.render(); } }, 200); } mount() { console.log('ClusterMembersComponent: Starting mount...'); super.mount(); // Show loading state immediately when mounted console.log('ClusterMembersComponent: Showing initial loading state'); this.showLoadingState(); // Set up loading timeout safeguard this.setupLoadingTimeout(); console.log('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) { console.warn('ClusterMembersComponent: Loading timeout reached, forcing render check'); this.forceRenderCheck(); } }, 10000); // 10 second timeout } // Force a render check when loading gets stuck forceRenderCheck() { console.log('ClusterMembersComponent: Force render check called'); const members = this.viewModel.get('members'); const error = this.viewModel.get('error'); const isLoading = this.viewModel.get('isLoading'); console.log('ClusterMembersComponent: Force render check state:', { members, error, isLoading }); if (error) { this.showErrorState(error); } else if (members && members.length > 0) { this.renderMembers(members); } else if (!isLoading) { this.showEmptyState(); } } setupEventListeners() { console.log('ClusterMembersComponent: Setting up event listeners...'); // Note: Refresh button is now handled by ClusterViewComponent // since it's in the cluster header, not in the members container } setupViewModelListeners() { console.log('ClusterMembersComponent: Setting up view model listeners...'); // Listen to cluster members changes with change detection this.subscribeToProperty('members', this.handleMembersUpdate.bind(this)); this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this)); this.subscribeToProperty('error', this.handleErrorUpdate.bind(this)); console.log('ClusterMembersComponent: View model listeners set up'); } // Handle members update with state preservation handleMembersUpdate(newMembers, previousMembers) { console.log('ClusterMembersComponent: Members updated:', { newMembers, previousMembers }); // Prevent multiple simultaneous renders if (this.renderInProgress) { console.log('ClusterMembersComponent: Render already in progress, skipping update'); return; } // Check if we're currently loading - if so, let the loading handler deal with it const isLoading = this.viewModel.get('isLoading'); if (isLoading) { console.log('ClusterMembersComponent: Currently loading, skipping members update (will be handled by loading completion)'); return; } // On first load (no previous members), always render if (!previousMembers || !Array.isArray(previousMembers) || previousMembers.length === 0) { console.log('ClusterMembersComponent: First load or no previous members, performing full render'); this.render(); return; } if (this.shouldPreserveState(newMembers, previousMembers)) { // Perform partial update to preserve UI state console.log('ClusterMembersComponent: Preserving state, performing partial update'); this.updateMembersPartially(newMembers, previousMembers); } else { // Full re-render if structure changed significantly console.log('ClusterMembersComponent: Structure changed, performing full re-render'); this.render(); } } // Handle loading state update handleLoadingUpdate(isLoading) { console.log('ClusterMembersComponent: Loading state changed:', isLoading); if (isLoading) { console.log('ClusterMembersComponent: Showing loading state'); this.renderLoading(`\n
\n
Loading cluster members...
\n
\n `); // Set up a loading completion check this.checkLoadingCompletion(); } else { console.log('ClusterMembersComponent: Loading completed, checking if we need to render'); // 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'); console.log('ClusterMembersComponent: Handling loading completion:', { members, error, isLoading }); if (error) { console.log('ClusterMembersComponent: Loading completed with error, showing error state'); this.showErrorState(error); } else if (members && members.length > 0) { console.log('ClusterMembersComponent: Loading completed with data, rendering members'); this.renderMembers(members); } else if (!isLoading) { console.log('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) { console.log('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() { // Skip rendering if we're in the middle of a view switch const isViewSwitching = document.querySelectorAll('.view-content.active').length === 0; if (isViewSwitching) { console.log('ClusterMembersComponent: View switching in progress, skipping render'); return true; } // Skip rendering if the component is not visible const isVisible = this.container.style.display !== 'none' && this.container.style.opacity !== '0' && this.container.classList.contains('active'); if (!isVisible) { console.log('ClusterMembersComponent: Component not visible, skipping render'); return true; } return false; } // Update members partially to preserve UI state updateMembersPartially(newMembers, previousMembers) { console.log('ClusterMembersComponent: Performing partial update to preserve UI state'); // Update only the data that changed, preserving expanded states and active tabs newMembers.forEach((newMember, index) => { const prevMember = previousMembers[index]; if (prevMember && this.hasMemberChanged(newMember, prevMember)) { this.updateMemberCard(newMember, index); } }); } // 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, index) { 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) { console.log('ClusterMembersComponent: Render already in progress, skipping'); return; } // Check if we should skip rendering during view switches if (this.shouldSkipRender()) { return; } this.renderInProgress = true; try { console.log('ClusterMembersComponent: render() called'); console.log('ClusterMembersComponent: Container element:', this.container); console.log('ClusterMembersComponent: Is mounted:', this.isMounted); const members = this.viewModel.get('members'); const isLoading = this.viewModel.get('isLoading'); const error = this.viewModel.get('error'); console.log('ClusterMembersComponent: render data:', { members, isLoading, error }); if (isLoading) { console.log('ClusterMembersComponent: Showing loading state'); this.showLoadingState(); return; } if (error) { console.log('ClusterMembersComponent: Showing error state'); this.showErrorState(error); return; } if (!members || members.length === 0) { console.log('ClusterMembersComponent: Showing empty state'); this.showEmptyState(); return; } console.log('ClusterMembersComponent: Rendering members:', members); this.renderMembers(members); } finally { this.renderInProgress = false; } } // Show loading state showLoadingState() { console.log('ClusterMembersComponent: showLoadingState() called'); this.renderLoading(`
Loading cluster members...
`); } // Show error state showErrorState(error) { console.log('ClusterMembersComponent: showErrorState() called with error:', error); this.renderError(`Error loading cluster members: ${error}`); } // Show empty state showEmptyState() { console.log('ClusterMembersComponent: showEmptyState() called'); this.renderEmpty(`
🌐
No cluster members found
The cluster might be empty or not yet discovered
`); } renderMembers(members) { console.log('ClusterMembersComponent: renderMembers() called with', members.length, 'members'); const membersHTML = members.map(member => { const statusClass = member.status === 'active' ? 'status-online' : 'status-offline'; const statusText = member.status === 'active' ? 'Online' : 'Offline'; const statusIcon = member.status === 'active' ? '🟢' : '🔴'; console.log('ClusterMembersComponent: Rendering member:', member); return `
${statusIcon}
${member.hostname || 'Unknown Device'}
${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]) => `${key}: ${value}`).join('')}
` : ''}
Loading detailed information...
`; }).join(''); console.log('ClusterMembersComponent: Setting HTML, length:', membersHTML.length); this.setHTML('', membersHTML); console.log('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) { console.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) { const tabButtons = container.querySelectorAll('.tab-button'); const tabContents = container.querySelectorAll('.tab-content'); tabButtons.forEach(button => { this.addEventListener(button, 'click', (e) => { e.stopPropagation(); const targetTab = button.dataset.tab; // Use base helper to set active tab this.setActiveTab(targetTab, container); // Store active tab state const memberCard = container.closest('.member-card'); if (memberCard) { const memberIp = memberCard.dataset.memberIp; this.viewModel.storeActiveTab(memberIp, targetTab); } }); }); // Also prevent event propagation on tab content areas tabContents.forEach(content => { this.addEventListener(content, 'click', (e) => { e.stopPropagation(); }); }); } // 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'); console.log('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() { console.log('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(); console.log('ClusterMembersComponent: Manual refresh completed'); } catch (error) { console.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(); console.log(`${this.constructor.name} unmounted`); } // Override pause method to handle timeouts and operations onPause() { console.log('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() { console.log('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) { console.log('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) { // Always start with 'status' tab, don't restore previous state const activeTab = 'status'; console.log('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(); 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() { console.log('NodeDetailsComponent: Setting up tabs'); const tabButtons = this.findAllElements('.tab-button'); const tabContents = this.findAllElements('.tab-content'); tabButtons.forEach(button => { this.addEventListener(button, 'click', (e) => { e.stopPropagation(); const targetTab = button.dataset.tab; console.log('NodeDetailsComponent: Tab clicked:', targetTab); // Update tab UI locally, don't store in view model this.setActiveTab(targetTab); }); }); // Also prevent event propagation on tab content areas tabContents.forEach(content => { this.addEventListener(content, 'click', (e) => { e.stopPropagation(); }); }); } // Update active tab without full re-render updateActiveTab(newTab, previousTab = null) { this.setActiveTab(newTab); console.log(`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
`; console.log('Firmware upload successful:', result); } catch (error) { console.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); console.log('FirmwareComponent: Constructor called'); console.log('FirmwareComponent: Container:', container); console.log('FirmwareComponent: Container ID:', container?.id); // Check if the dropdown exists in the container if (container) { const dropdown = container.querySelector('#specific-node-select'); console.log('FirmwareComponent: Dropdown found in constructor:', !!dropdown); if (dropdown) { console.log('FirmwareComponent: Dropdown tagName:', dropdown.tagName); console.log('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'); console.log('FirmwareComponent: setupEventListeners - specificNodeSelect found:', !!specificNodeSelect); if (specificNodeSelect) { console.log('FirmwareComponent: specificNodeSelect element:', specificNodeSelect); console.log('FirmwareComponent: specificNodeSelect tagName:', specificNodeSelect.tagName); console.log('FirmwareComponent: specificNodeSelect id:', specificNodeSelect.id); // Store the bound handler as an instance property this._boundNodeSelectHandler = this.handleNodeSelect.bind(this); this.addEventListener(specificNodeSelect, 'change', this._boundNodeSelectHandler); console.log('FirmwareComponent: Event listener added to specificNodeSelect'); } else { console.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(); console.log('FirmwareComponent: Mounting...'); // Check if the dropdown exists when mounted const dropdown = this.findElement('#specific-node-select'); console.log('FirmwareComponent: Mount - dropdown found:', !!dropdown); if (dropdown) { console.log('FirmwareComponent: Mount - dropdown tagName:', dropdown.tagName); console.log('FirmwareComponent: Mount - dropdown id:', dropdown.id); console.log('FirmwareComponent: Mount - dropdown innerHTML:', dropdown.innerHTML); } // Initialize target visibility and label list on first mount try { this.updateTargetVisibility(); this.populateLabelSelect(); this.updateAffectedNodesPreview(); } catch (e) { console.warn('FirmwareComponent: Initialization after mount failed:', e); } console.log('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; console.log('FirmwareComponent: handleNodeSelect called with nodeIp:', nodeIp); console.log('Event:', event); console.log('Event target:', event.target); console.log('Event target value:', event.target.value); 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) { console.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) { console.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) { console.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) { console.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) { console.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'); console.log('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) { console.warn('FirmwareComponent: populateNodeSelect - select element not found'); return; } if (select.tagName !== 'SELECT') { console.warn('FirmwareComponent: populateNodeSelect - element is not a SELECT:', select.tagName); return; } console.log('FirmwareComponent: populateNodeSelect called'); console.log('FirmwareComponent: Select element:', select); console.log('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); console.log('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); console.log('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); console.log('ClusterViewComponent: Constructor called'); console.log('ClusterViewComponent: Container:', container); console.log('ClusterViewComponent: Container ID:', container?.id); // Find elements for sub-components const primaryNodeContainer = this.findElement('.primary-node-info'); const clusterMembersContainer = this.findElement('#cluster-members-container'); console.log('ClusterViewComponent: Primary node container:', primaryNodeContainer); console.log('ClusterViewComponent: Cluster members container:', clusterMembersContainer); console.log('ClusterViewComponent: Cluster members container ID:', clusterMembersContainer?.id); console.log('ClusterViewComponent: Cluster members container innerHTML:', clusterMembersContainer?.innerHTML); // Create sub-components this.primaryNodeComponent = new PrimaryNodeComponent( primaryNodeContainer, viewModel, eventBus ); this.clusterMembersComponent = new ClusterMembersComponent( clusterMembersContainer, viewModel, eventBus ); console.log('ClusterViewComponent: Sub-components created'); // Track if we've already loaded data to prevent unnecessary reloads this.dataLoaded = false; } mount() { console.log('ClusterViewComponent: Mounting...'); super.mount(); console.log('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) { console.log('ClusterViewComponent: Starting initial data load...'); // Initial data load - ensure it happens after mounting setTimeout(() => { this.viewModel.updateClusterMembers().then(() => { this.dataLoaded = true; }).catch(error => { console.error('ClusterViewComponent: Failed to load initial data:', error); }); }, 100); } else { console.log('ClusterViewComponent: Data already loaded, skipping initial load'); } // Set up periodic updates // this.setupPeriodicUpdates(); // Disabled automatic refresh console.log('ClusterViewComponent: Mounted successfully'); } setupRefreshButton() { console.log('ClusterViewComponent: Setting up refresh button...'); const refreshBtn = this.findElement('.refresh-btn'); console.log('ClusterViewComponent: Found refresh button:', !!refreshBtn, refreshBtn); if (refreshBtn) { console.log('ClusterViewComponent: Adding click event listener to refresh button'); this.addEventListener(refreshBtn, 'click', this.handleRefresh.bind(this)); console.log('ClusterViewComponent: Event listener added successfully'); } else { console.error('ClusterViewComponent: Refresh button not found!'); console.log('ClusterViewComponent: Container HTML:', this.container.innerHTML); console.log('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button')); } } async handleRefresh() { console.log('ClusterViewComponent: Refresh button clicked, performing full refresh...'); // Get the refresh button and show loading state const refreshBtn = this.findElement('.refresh-btn'); console.log('ClusterViewComponent: Found refresh button for loading state:', !!refreshBtn); if (refreshBtn) { const originalText = refreshBtn.innerHTML; console.log('ClusterViewComponent: Original button text:', originalText); refreshBtn.innerHTML = ` Refreshing... `; refreshBtn.disabled = true; try { console.log('ClusterViewComponent: Starting cluster members update...'); // Always perform a full refresh when user clicks refresh button await this.viewModel.updateClusterMembers(); console.log('ClusterViewComponent: Cluster members update completed successfully'); } catch (error) { console.error('ClusterViewComponent: Error during refresh:', error); // Show error state if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) { this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed'); } } finally { console.log('ClusterViewComponent: Restoring button state...'); // Restore button state refreshBtn.innerHTML = originalText; refreshBtn.disabled = false; } } else { console.warn('ClusterViewComponent: Refresh button not found, using fallback refresh'); // Fallback if button not found try { await this.viewModel.updateClusterMembers(); } catch (error) { console.error('ClusterViewComponent: Fallback refresh failed:', error); if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) { this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed'); } } } } unmount() { console.log('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(); console.log('ClusterViewComponent: Unmounted'); } // Override pause method to handle sub-components onPause() { console.log('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() { console.log('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); console.log('FirmwareViewComponent: Constructor called'); console.log('FirmwareViewComponent: Container:', container); const firmwareContainer = this.findElement('#firmware-container'); console.log('FirmwareViewComponent: Firmware container found:', !!firmwareContainer); this.firmwareComponent = new FirmwareComponent( firmwareContainer, viewModel, eventBus ); console.log('FirmwareViewComponent: FirmwareComponent created'); } mount() { super.mount(); console.log('FirmwareViewComponent: Mounting...'); // Mount sub-component this.firmwareComponent.mount(); // Update available nodes this.updateAvailableNodes(); console.log('FirmwareViewComponent: Mounted successfully'); } unmount() { // Unmount sub-component if (this.firmwareComponent) { this.firmwareComponent.unmount(); } super.unmount(); } // Override pause method to handle sub-components onPause() { console.log('FirmwareViewComponent: Pausing...'); // Pause sub-component if (this.firmwareComponent && this.firmwareComponent.isMounted) { this.firmwareComponent.pause(); } } // Override resume method to handle sub-components onResume() { console.log('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 { console.log('FirmwareViewComponent: updateAvailableNodes called'); const response = await window.apiClient.getClusterMembers(); const nodes = response.members || []; console.log('FirmwareViewComponent: Got nodes:', nodes); this.viewModel.updateAvailableNodes(nodes); console.log('FirmwareViewComponent: Available nodes updated in view model'); } catch (error) { console.error('Failed to update available nodes:', error); } } } // Cluster Status Component for header badge class ClusterStatusComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); } setupViewModelListeners() { // Subscribe to properties that affect cluster status this.subscribeToProperty('totalNodes', this.render.bind(this)); this.subscribeToProperty('clientInitialized', this.render.bind(this)); this.subscribeToProperty('error', this.render.bind(this)); } render() { const totalNodes = this.viewModel.get('totalNodes'); const clientInitialized = this.viewModel.get('clientInitialized'); const error = this.viewModel.get('error'); let statusText, statusIcon, statusClass; if (error) { statusText = 'Cluster Error'; statusIcon = '❌'; statusClass = 'cluster-status-error'; } else if (totalNodes === 0) { statusText = 'Cluster Offline'; statusIcon = '🔴'; statusClass = 'cluster-status-offline'; } else if (clientInitialized) { statusText = 'Cluster Online'; statusIcon = '🟢'; statusClass = 'cluster-status-online'; } else { statusText = 'Cluster Connecting'; statusIcon = '🟡'; statusClass = 'cluster-status-connecting'; } // Update the cluster status badge using the container passed to this component if (this.container) { this.container.innerHTML = `${statusIcon} ${statusText}`; // Remove all existing status classes this.container.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error'); // Add the appropriate status class this.container.classList.add(statusClass); } } } // Topology Graph Component with D3.js force-directed visualization class TopologyGraphComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); console.log('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); console.log('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; console.log('TopologyGraphComponent: Starting mount...'); console.log('TopologyGraphComponent: isInitialized =', this.isInitialized); // Call initialize if not already done if (!this.isInitialized) { console.log('TopologyGraphComponent: Initializing during mount...'); this.initialize().then(() => { console.log('TopologyGraphComponent: Initialization completed, calling completeMount...'); // Complete mount after initialization this.completeMount(); }).catch(error => { console.error('TopologyGraphComponent: Initialization failed during mount:', error); // Still complete mount to prevent blocking this.completeMount(); }); } else { console.log('TopologyGraphComponent: Already initialized, calling completeMount directly...'); this.completeMount(); } } completeMount() { console.log('TopologyGraphComponent: completeMount called'); this.isMounted = true; console.log('TopologyGraphComponent: Setting up event listeners...'); this.setupEventListeners(); console.log('TopologyGraphComponent: Setting up view model listeners...'); this.setupViewModelListeners(); console.log('TopologyGraphComponent: Calling render...'); this.render(); console.log('TopologyGraphComponent: Mounted successfully'); } setupEventListeners() { console.log('TopologyGraphComponent: setupEventListeners called'); console.log('TopologyGraphComponent: Container:', this.container); console.log('TopologyGraphComponent: Container ID:', this.container?.id); // Add resize listener to update dimensions when window is resized this.resizeHandler = this.handleResize.bind(this); window.addEventListener('resize', this.resizeHandler); // Refresh button removed from HTML, so no need to set up event listeners console.log('TopologyGraphComponent: No event listeners needed (refresh button removed)'); } setupViewModelListeners() { console.log('TopologyGraphComponent: setupViewModelListeners called'); console.log('TopologyGraphComponent: isInitialized =', this.isInitialized); if (this.isInitialized) { // Component is already initialized, set up subscriptions immediately console.log('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 console.log('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() { console.log('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) { console.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 console.log('TopologyGraphComponent: SVG setup completed'); } // Ensure component is initialized async ensureInitialized() { if (!this.isInitialized) { console.log('TopologyGraphComponent: Ensuring initialization...'); await this.initialize(); } return this.isInitialized; } renderGraph() { try { // Check if component is initialized if (!this.isInitialized) { console.log('TopologyGraphComponent: Component not yet initialized, ensuring initialization...'); this.ensureInitialized().then(() => { // Re-render after initialization this.renderGraph(); }).catch(error => { console.error('TopologyGraphComponent: Failed to initialize:', error); }); return; } const nodes = this.viewModel.get('nodes'); const links = this.viewModel.get('links'); // Check if SVG is initialized if (!this.svg) { console.log('TopologyGraphComponent: SVG not initialized yet, setting up SVG first'); this.setupSVG(); } if (!nodes || nodes.length === 0) { this.showNoData(); return; } console.log('TopologyGraphComponent: Rendering graph with', nodes.length, 'nodes and', links.length, 'links'); // Get the main SVG group (the one created in setupSVG) let svgGroup = this.svg.select('g'); if (!svgGroup || svgGroup.empty()) { console.log('TopologyGraphComponent: Creating new SVG group'); 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) { console.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() { console.log('TopologyGraphComponent: handleRefresh called'); if (!this.isInitialized) { console.log('TopologyGraphComponent: Component not yet initialized, ensuring initialization...'); this.ensureInitialized().then(() => { // Refresh after initialization this.viewModel.updateNetworkTopology(); }).catch(error => { console.error('TopologyGraphComponent: Failed to initialize for refresh:', error); }); return; } console.log('TopologyGraphComponent: Calling updateNetworkTopology...'); this.viewModel.updateNetworkTopology(); } handleLoadingState(isLoading) { console.log('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() { console.log('TopologyGraphComponent: render called'); if (!this.isInitialized) { console.log('TopologyGraphComponent: Not initialized yet, skipping render'); return; } const nodes = this.viewModel.get('nodes'); const links = this.viewModel.get('links'); if (nodes && nodes.length > 0) { console.log('TopologyGraphComponent: Rendering graph with data'); this.renderGraph(); } else { console.log('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 `

Member Details

${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) { const labelsContainer = card.querySelector('.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'; } else { // Create new labels container if it doesn't exist const memberInfo = card.querySelector('.member-info'); if (memberInfo) { const labelsDiv = document.createElement('div'); labelsDiv.className = 'member-labels'; labelsDiv.innerHTML = Object.entries(nodeStatus.labels) .map(([key, value]) => `${key}: ${value}`) .join(''); // Insert after latency const latencyDiv = memberInfo.querySelector('.member-latency'); if (latencyDiv) { latencyDiv.parentNode.insertBefore(labelsDiv, latencyDiv.nextSibling); } } } } // Mount the component nodeDetailsComponent.mount(); // Update UI card.classList.add('expanded'); } catch (error) { console.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
'; } } } }