// 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); // Drawer state for desktop this.drawer = new DrawerComponent(); } // Determine if we should use desktop drawer behavior isDesktop() { return this.drawer.isDesktop(); } openDrawerForMember(memberIp) { // Get display name for drawer title let displayName = memberIp; try { const members = (this.viewModel && typeof this.viewModel.get === 'function') ? this.viewModel.get('members') : []; const member = Array.isArray(members) ? members.find(m => m && m.ip === memberIp) : null; const hostname = (member && member.hostname) ? member.hostname : ''; const ip = (member && member.ip) ? member.ip : memberIp; if (hostname && ip) { displayName = `${hostname} - ${ip}`; } else if (hostname) { displayName = hostname; } else if (ip) { displayName = ip; } } catch (_) { // no-op if anything goes wrong, default title remains } // Open drawer with content callback this.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => { // Load and mount NodeDetails into drawer const nodeDetailsVM = new NodeDetailsViewModel(); const nodeDetailsComponent = new NodeDetailsComponent(contentContainer, nodeDetailsVM, this.eventBus); setActiveComponent(nodeDetailsComponent); nodeDetailsVM.loadNodeDetails(memberIp).then(() => { nodeDetailsComponent.mount(); }).catch((error) => { logger.error('Failed to load node details for drawer:', error); contentContainer.innerHTML = `
Error loading node details:
${this.escapeHtml(error.message)}
`; }); }); } closeDrawer() { this.drawer.closeDrawer(); } mount() { logger.debug('ClusterMembersComponent: Starting mount...'); super.mount(); // Show loading state immediately when mounted logger.debug('ClusterMembersComponent: Showing initial loading state'); this.showLoadingState(); // Set up loading timeout safeguard this.setupLoadingTimeout(); logger.debug('ClusterMembersComponent: Mounted successfully'); } // Setup loading timeout safeguard to prevent getting stuck in loading state setupLoadingTimeout() { this.loadingTimeout = setTimeout(() => { const isLoading = this.viewModel.get('isLoading'); if (isLoading) { logger.warn('ClusterMembersComponent: Loading timeout reached, forcing render check'); this.forceRenderCheck(); } }, 10000); // 10 second timeout } // Force a render check when loading gets stuck forceRenderCheck() { logger.debug('ClusterMembersComponent: Force render check called'); const members = this.viewModel.get('members'); const error = this.viewModel.get('error'); const isLoading = this.viewModel.get('isLoading'); logger.debug('ClusterMembersComponent: Force render check state:', { members, error, isLoading }); if (error) { this.showErrorState(error); } else if (members && members.length > 0) { this.renderMembers(members); } else if (!isLoading) { this.showEmptyState(); } } setupEventListeners() { logger.debug('ClusterMembersComponent: Setting up event listeners...'); // Note: Refresh button is now handled by ClusterViewComponent // since it's in the cluster header, not in the members container } setupViewModelListeners() { logger.debug('ClusterMembersComponent: Setting up view model listeners...'); // Listen to cluster members changes with change detection this.subscribeToProperty('members', this.handleMembersUpdate.bind(this)); this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this)); this.subscribeToProperty('error', this.handleErrorUpdate.bind(this)); logger.debug('ClusterMembersComponent: View model listeners set up'); } // Handle members update with state preservation handleMembersUpdate(newMembers, previousMembers) { logger.debug('ClusterMembersComponent: Members updated:', { newMembers, previousMembers }); // Prevent multiple simultaneous renders if (this.renderInProgress) { logger.debug('ClusterMembersComponent: Render already in progress, skipping update'); return; } // Check if we're currently loading - if so, let the loading handler deal with it const isLoading = this.viewModel.get('isLoading'); if (isLoading) { logger.debug('ClusterMembersComponent: Currently loading, skipping members update (will be handled by loading completion)'); return; } // On first load (no previous members), always render if (!previousMembers || !Array.isArray(previousMembers) || previousMembers.length === 0) { logger.debug('ClusterMembersComponent: First load or no previous members, performing full render'); this.render(); return; } if (this.shouldPreserveState(newMembers, previousMembers)) { // Perform partial update to preserve UI state logger.debug('ClusterMembersComponent: Preserving state, performing partial update'); this.updateMembersPartially(newMembers, previousMembers); } else { // Full re-render if structure changed significantly logger.debug('ClusterMembersComponent: Structure changed, performing full re-render'); this.render(); } } // Handle loading state update handleLoadingUpdate(isLoading) { logger.debug('ClusterMembersComponent: Loading state changed:', isLoading); if (isLoading) { logger.debug('ClusterMembersComponent: Showing loading state'); this.renderLoading(`
Loading cluster members...
`); // Set up a loading completion check this.checkLoadingCompletion(); } else { logger.debug('ClusterMembersComponent: Loading completed, checking if we need to render'); // When loading completes, check if we have data to render this.handleLoadingCompletion(); } } // Check if loading has completed and handle accordingly handleLoadingCompletion() { const members = this.viewModel.get('members'); const error = this.viewModel.get('error'); const isLoading = this.viewModel.get('isLoading'); logger.debug('ClusterMembersComponent: Handling loading completion:', { members, error, isLoading }); if (error) { logger.debug('ClusterMembersComponent: Loading completed with error, showing error state'); this.showErrorState(error); } else if (members && members.length > 0) { logger.debug('ClusterMembersComponent: Loading completed with data, rendering members'); this.renderMembers(members); } else if (!isLoading) { logger.debug('ClusterMembersComponent: Loading completed but no data, showing empty state'); this.showEmptyState(); } } // Set up a check to ensure loading completion is handled checkLoadingCompletion() { // Clear any existing completion check if (this.loadingCompletionCheck) { clearTimeout(this.loadingCompletionCheck); } // Set up a completion check that runs after a short delay this.loadingCompletionCheck = setTimeout(() => { const isLoading = this.viewModel.get('isLoading'); if (!isLoading) { logger.debug('ClusterMembersComponent: Loading completion check triggered'); this.handleLoadingCompletion(); } }, 1000); // Check after 1 second } // Handle error state update handleErrorUpdate(error) { if (error) { this.showErrorState(error); } } // Check if we should preserve UI state during update shouldPreserveState(newMembers, previousMembers) { if (!previousMembers || !Array.isArray(previousMembers)) return false; if (!Array.isArray(newMembers)) return false; // If member count changed, we need to re-render if (newMembers.length !== previousMembers.length) return false; // Check if member IPs are the same (same nodes) const newIps = new Set(newMembers.map(m => m.ip)); const prevIps = new Set(previousMembers.map(m => m.ip)); // If IPs are the same, we can preserve state return newIps.size === prevIps.size && [...newIps].every(ip => prevIps.has(ip)); } // Check if we should skip rendering during view switches shouldSkipRender() { // Rely on lifecycle flags controlled by App if (!this.isMounted || this.isPaused) { logger.debug('ClusterMembersComponent: Not mounted or paused, skipping render'); return true; } return false; } // Update members partially to preserve UI state updateMembersPartially(newMembers, previousMembers) { logger.debug('ClusterMembersComponent: Performing partial update to preserve UI state'); // Build previous map by IP for stable diffs const prevByIp = new Map((previousMembers || []).map(m => [m.ip, m])); newMembers.forEach((newMember) => { const prevMember = prevByIp.get(newMember.ip); if (prevMember && this.hasMemberChanged(newMember, prevMember)) { this.updateMemberCard(newMember); } }); } // Check if a specific member has changed hasMemberChanged(newMember, prevMember) { return newMember.status !== prevMember.status || newMember.latency !== prevMember.latency || newMember.hostname !== prevMember.hostname; } // Update a specific member card without re-rendering the entire component updateMemberCard(member) { const card = this.findElement(`[data-member-ip="${member.ip}"]`); if (!card) return; // Update status const statusElement = card.querySelector('.member-status'); if (statusElement) { const statusClass = member.status === 'active' ? 'status-online' : 'status-offline'; const statusIcon = member.status === 'active' ? '🟢' : '🔴'; statusElement.className = `member-status ${statusClass}`; statusElement.innerHTML = `${statusIcon}`; } // Update latency const latencyElement = card.querySelector('.latency-value'); if (latencyElement) { latencyElement.textContent = member.latency ? member.latency + 'ms' : 'N/A'; } // Update hostname if changed const hostnameElement = card.querySelector('.member-hostname'); if (hostnameElement && member.hostname !== hostnameElement.textContent) { hostnameElement.textContent = member.hostname || 'Unknown Device'; } } render() { if (this.renderInProgress) { logger.debug('ClusterMembersComponent: Render already in progress, skipping'); return; } // Check if we should skip rendering during view switches if (this.shouldSkipRender()) { return; } this.renderInProgress = true; try { logger.debug('ClusterMembersComponent: render() called'); logger.debug('ClusterMembersComponent: Container element:', this.container); logger.debug('ClusterMembersComponent: Is mounted:', this.isMounted); const members = this.viewModel.get('members'); const isLoading = this.viewModel.get('isLoading'); const error = this.viewModel.get('error'); logger.debug('ClusterMembersComponent: render data:', { members, isLoading, error }); if (isLoading) { logger.debug('ClusterMembersComponent: Showing loading state'); this.showLoadingState(); return; } if (error) { logger.debug('ClusterMembersComponent: Showing error state'); this.showErrorState(error); return; } if (!members || members.length === 0) { logger.debug('ClusterMembersComponent: Showing empty state'); this.showEmptyState(); return; } logger.debug('ClusterMembersComponent: Rendering members:', members); this.renderMembers(members); } finally { this.renderInProgress = false; } } // Show loading state showLoadingState() { logger.debug('ClusterMembersComponent: showLoadingState() called'); this.renderLoading(`
Loading cluster members...
`); } // Show error state showErrorState(error) { logger.debug('ClusterMembersComponent: showErrorState() called with error:', error); this.renderError(`Error loading cluster members: ${error}`); } // Show empty state showEmptyState() { logger.debug('ClusterMembersComponent: showEmptyState() called'); this.renderEmpty(`
🌐
No cluster members found
The cluster might be empty or not yet discovered
`); } renderMembers(members) { logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members'); const membersHTML = members.map(member => { const statusClass = member.status === 'active' ? 'status-online' : 'status-offline'; const statusText = member.status === 'active' ? 'Online' : 'Offline'; const statusIcon = member.status === 'active' ? '🟢' : '🔴'; logger.debug('ClusterMembersComponent: Rendering member:', member); return `
${statusIcon}
${this.escapeHtml(member.hostname || 'Unknown Device')}
${this.escapeHtml(member.ip || 'No IP')}
Latency: ${member.latency ? member.latency + 'ms' : 'N/A'}
${member.labels && Object.keys(member.labels).length ? `
${Object.entries(member.labels).map(([key, value]) => `${this.escapeHtml(key)}: ${this.escapeHtml(value)}`).join('')}
` : ''}
Loading detailed information...
`; }).join(''); logger.debug('ClusterMembersComponent: Setting HTML, length:', membersHTML.length); this.setHTML('', membersHTML); logger.debug('ClusterMembersComponent: HTML set, setting up member cards...'); this.setupMemberCards(members); } setupMemberCards(members) { setTimeout(() => { this.findAllElements('.member-card').forEach((card, index) => { const expandIcon = card.querySelector('.expand-icon'); const memberDetails = card.querySelector('.member-details'); const memberIp = card.dataset.memberIp; // Ensure all cards start collapsed by default card.classList.remove('expanded'); if (expandIcon) { expandIcon.classList.remove('expanded'); } // Clear any previous content memberDetails.innerHTML = '
Loading detailed information...
'; // Make the entire card clickable this.addEventListener(card, 'click', async (e) => { if (e.target === expandIcon) return; // On desktop, open slide-in drawer instead of inline expand if (this.isDesktop()) { e.preventDefault(); e.stopPropagation(); this.openDrawerForMember(memberIp); return; } // Mobile/low-res: keep inline expand/collapse 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(); if (this.isDesktop()) { this.openDrawerForMember(memberIp); return; } const isExpanding = !card.classList.contains('expanded'); if (isExpanding) { await this.expandCard(card, memberIp, memberDetails); } else { this.collapseCard(card, expandIcon); } }); } }); }, 100); } async expandCard(card, memberIp, memberDetails) { try { // Create node details view model and component const nodeDetailsVM = new NodeDetailsViewModel(); const nodeDetailsComponent = new NodeDetailsComponent(memberDetails, nodeDetailsVM, this.eventBus); // Load node details await nodeDetailsVM.loadNodeDetails(memberIp); // Mount the component nodeDetailsComponent.mount(); // Update UI card.classList.add('expanded'); const expandIcon = card.querySelector('.expand-icon'); if (expandIcon) { expandIcon.classList.add('expanded'); } } catch (error) { logger.error('Failed to expand card:', error); memberDetails.innerHTML = `
Error loading node details:
${error.message}
`; } } collapseCard(card, expandIcon) { card.classList.remove('expanded'); if (expandIcon) { expandIcon.classList.remove('expanded'); } } setupTabs(container) { super.setupTabs(container, { onChange: (targetTab) => { const memberCard = container.closest('.member-card'); if (memberCard) { const memberIp = memberCard.dataset.memberIp; this.viewModel.storeActiveTab(memberIp, targetTab); } } }); } // Restore active tab state restoreActiveTab(container, activeTab) { const tabButtons = container.querySelectorAll('.tab-button'); const tabContents = container.querySelectorAll('.tab-content'); // Remove active class from all buttons and contents tabButtons.forEach(btn => btn.classList.remove('active')); tabContents.forEach(content => content.classList.remove('active')); // Add active class to the restored tab const activeButton = container.querySelector(`[data-tab="${activeTab}"]`); const activeContent = container.querySelector(`#${activeTab}-tab`); if (activeButton) activeButton.classList.add('active'); if (activeContent) activeContent.classList.add('active'); } // Note: handleRefresh method has been moved to ClusterViewComponent // since the refresh button is in the cluster header, not in the members container // Debug method to check component state debugState() { const members = this.viewModel.get('members'); const isLoading = this.viewModel.get('isLoading'); const error = this.viewModel.get('error'); const expandedCards = this.viewModel.get('expandedCards'); const activeTabs = this.viewModel.get('activeTabs'); logger.debug('ClusterMembersComponent: Debug State:', { isMounted: this.isMounted, container: this.container, members: members, membersCount: members?.length || 0, isLoading: isLoading, error: error, expandedCardsCount: expandedCards?.size || 0, activeTabsCount: activeTabs?.size || 0, loadingTimeout: this.loadingTimeout }); return { members, isLoading, error, expandedCards, activeTabs }; } // Manual refresh method that bypasses potential state conflicts async manualRefresh() { logger.debug('ClusterMembersComponent: Manual refresh called'); try { // Clear any existing loading state this.viewModel.set('isLoading', false); this.viewModel.set('error', null); // Force a fresh data load await this.viewModel.updateClusterMembers(); logger.debug('ClusterMembersComponent: Manual refresh completed'); } catch (error) { logger.error('ClusterMembersComponent: Manual refresh failed:', error); this.showErrorState(error.message); } } unmount() { if (!this.isMounted) return; this.isMounted = false; // Clear any pending timeouts if (this.loadingTimeout) { clearTimeout(this.loadingTimeout); this.loadingTimeout = null; } if (this.loadingCompletionCheck) { clearTimeout(this.loadingCompletionCheck); this.loadingCompletionCheck = null; } // Clear any pending render operations this.renderInProgress = false; this.cleanupEventListeners(); this.cleanupViewModelListeners(); logger.debug(`${this.constructor.name} unmounted`); } // Override pause method to handle timeouts and operations onPause() { logger.debug('ClusterMembersComponent: Pausing...'); // Clear any pending timeouts if (this.loadingTimeout) { clearTimeout(this.loadingTimeout); this.loadingTimeout = null; } if (this.loadingCompletionCheck) { clearTimeout(this.loadingCompletionCheck); this.loadingCompletionCheck = null; } // Mark as paused to prevent new operations this.isPaused = true; } // Override resume method to restore functionality onResume() { logger.debug('ClusterMembersComponent: Resuming...'); this.isPaused = false; // Re-setup loading timeout if needed if (!this.loadingTimeout) { this.setupLoadingTimeout(); } // Check if we need to handle any pending operations this.checkPendingOperations(); } // Check for any operations that need to be handled after resume checkPendingOperations() { const isLoading = this.viewModel.get('isLoading'); const members = this.viewModel.get('members'); // If we were loading and it completed while paused, handle the completion if (!isLoading && members && members.length > 0) { logger.debug('ClusterMembersComponent: Handling pending loading completion after resume'); this.handleLoadingCompletion(); } } // Override to determine if re-render is needed on resume shouldRenderOnResume() { // Don't re-render on resume - maintain current state return false; } } window.ClusterMembersComponent = ClusterMembersComponent;