// Cluster Members Component 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 (shared singleton) this.drawer = new DrawerComponent(); // Terminal panel container (shared with drawer) this.terminalPanelContainer = null; // Selection state for highlighting this.selectedMemberIp = null; // Filter state this.currentFilter = { labelKey: '', labelValue: '' }; this.allMembers = []; // Store unfiltered members } // Determine if we should use desktop drawer behavior isDesktop() { return this.drawer.isDesktop(); } // Extract all unique label keys from members extractLabelKeys(members) { const labelKeys = new Set(); members.forEach(member => { if (member.labels && typeof member.labels === 'object') { Object.keys(member.labels).forEach(key => { labelKeys.add(key); }); } }); return Array.from(labelKeys).sort(); } // Extract unique values for a specific label key from members extractLabelValuesForKey(members, labelKey) { const labelValues = new Set(); members.forEach(member => { if (member.labels && typeof member.labels === 'object' && member.labels[labelKey]) { labelValues.add(member.labels[labelKey]); } }); return Array.from(labelValues).sort(); } // Filter members based on current filter criteria filterMembers(members) { if (!this.currentFilter.labelKey && !this.currentFilter.labelValue) { return members; } return members.filter(member => { if (!member.labels || typeof member.labels !== 'object') { return false; } // If only label key is specified, show all members with that key if (this.currentFilter.labelKey && !this.currentFilter.labelValue) { return member.labels.hasOwnProperty(this.currentFilter.labelKey); } // If both key and value are specified, show members with exact match if (this.currentFilter.labelKey && this.currentFilter.labelValue) { return member.labels[this.currentFilter.labelKey] === this.currentFilter.labelValue; } // If only value is specified, show members with that value for any key if (!this.currentFilter.labelKey && this.currentFilter.labelValue) { return Object.values(member.labels).includes(this.currentFilter.labelValue); } return true; }); } // Update filter dropdowns with current label data updateFilterDropdowns() { // Always use unfiltered member list for dropdown options const labelKeys = this.extractLabelKeys(this.allMembers); // Update label key dropdown const keySelect = document.getElementById('label-key-filter'); if (keySelect) { const currentKey = keySelect.value; keySelect.innerHTML = ''; labelKeys.forEach(key => { const option = document.createElement('option'); option.value = key; option.textContent = key; keySelect.appendChild(option); }); // Restore selection if it still exists if (currentKey && labelKeys.includes(currentKey)) { keySelect.value = currentKey; } else { keySelect.value = ''; this.currentFilter.labelKey = ''; } } // Update label value dropdown based on selected key this.updateValueDropdown(); // Update clear button state this.updateClearButtonState(); } // Update clear button state based on current filter updateClearButtonState() { const clearBtn = document.getElementById('clear-filters-btn'); if (clearBtn) { const hasFilters = this.currentFilter.labelKey || this.currentFilter.labelValue; clearBtn.disabled = !hasFilters; logger.debug('ClusterMembersComponent: Clear button state updated:', { hasFilters, disabled: clearBtn.disabled, currentFilter: this.currentFilter }); } } // Update the value dropdown based on the currently selected key updateValueDropdown() { const valueSelect = document.getElementById('label-value-filter'); if (!valueSelect) return; const currentValue = valueSelect.value; valueSelect.innerHTML = ''; // If a key is selected, show only values for that key if (this.currentFilter.labelKey) { const labelValues = this.extractLabelValuesForKey(this.allMembers, this.currentFilter.labelKey); labelValues.forEach(value => { const option = document.createElement('option'); option.value = value; option.textContent = value; valueSelect.appendChild(option); }); // Restore selection if it still exists for this key if (currentValue && labelValues.includes(currentValue)) { valueSelect.value = currentValue; } else { valueSelect.value = ''; this.currentFilter.labelValue = ''; } } else { // If no key is selected, show all unique values from all keys const allValues = new Set(); this.allMembers.forEach(member => { if (member.labels && typeof member.labels === 'object') { Object.values(member.labels).forEach(value => { allValues.add(value); }); } }); const sortedValues = Array.from(allValues).sort(); sortedValues.forEach(value => { const option = document.createElement('option'); option.value = value; option.textContent = value; valueSelect.appendChild(option); }); // Restore selection if it still exists if (currentValue && sortedValues.includes(currentValue)) { valueSelect.value = currentValue; } else { valueSelect.value = ''; this.currentFilter.labelValue = ''; } } } // Apply filter and re-render applyFilter() { const filteredMembers = this.filterMembers(this.allMembers); this.renderFilteredMembers(filteredMembers); } // Set up filter event listeners setupFilterListeners() { logger.debug('ClusterMembersComponent: Setting up filter listeners...'); const keySelect = document.getElementById('label-key-filter'); const valueSelect = document.getElementById('label-value-filter'); const clearBtn = document.getElementById('clear-filters-btn'); logger.debug('ClusterMembersComponent: Filter elements found:', { keySelect: !!keySelect, valueSelect: !!valueSelect, clearBtn: !!clearBtn }); if (keySelect) { keySelect.addEventListener('change', (e) => { this.currentFilter.labelKey = e.target.value; // When key changes, reset value and update value dropdown this.currentFilter.labelValue = ''; this.updateValueDropdown(); this.updateClearButtonState(); this.applyFilter(); }); } if (valueSelect) { valueSelect.addEventListener('change', (e) => { this.currentFilter.labelValue = e.target.value; this.updateClearButtonState(); this.applyFilter(); }); } if (clearBtn) { clearBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); logger.debug('ClusterMembersComponent: Clear filters button clicked'); this.currentFilter = { labelKey: '', labelValue: '' }; if (keySelect) keySelect.value = ''; if (valueSelect) valueSelect.value = ''; this.updateClearButtonState(); this.applyFilter(); }); } else { logger.warn('ClusterMembersComponent: Clear filters button not found'); } } openDrawerForMember(memberIp) { // Set selected member and update highlighting this.setSelectedMember(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)}
`; }); }, null, () => { // Close callback - clear selection when drawer is closed this.clearSelectedMember(); }); } 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(); // Set up filter listeners this.setupFilterListeners(); 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 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.shouldSkipFullRender(newMembers, previousMembers)) { // Perform partial update logger.debug('ClusterMembersComponent: Skipping full render, 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 skip full re-render during update shouldSkipFullRender(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 skip full re-render 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 updateMembersPartially(newMembers, previousMembers) { logger.debug('ClusterMembersComponent: Performing partial update'); // 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) { let statusClass, statusIcon; if (member.status && member.status.toUpperCase() === 'ACTIVE') { statusClass = 'status-online'; statusIcon = window.icon('dotGreen', { width: 12, height: 12 }); } else if (member.status && member.status.toUpperCase() === 'INACTIVE') { statusClass = 'status-dead'; statusIcon = window.icon('dotRed', { width: 12, height: 12 }); } else { statusClass = 'status-offline'; statusIcon = window.icon('dotRed', { width: 12, height: 12 }); } 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(`
${window.icon('cluster', { width: 24, height: 24 })}
No cluster members found
The cluster might be empty or not yet discovered
`); } renderMembers(members) { logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members'); // Store all members for filtering this.allMembers = members; // Update filter dropdowns with current label data this.updateFilterDropdowns(); // Apply current filter to get filtered members const filteredMembers = this.filterMembers(members); const membersHTML = filteredMembers.map(member => { let statusClass, statusIcon; if (member.status && member.status.toUpperCase() === 'ACTIVE') { statusClass = 'status-online'; statusIcon = window.icon('dotGreen', { width: 12, height: 12 }); } else if (member.status && member.status.toUpperCase() === 'INACTIVE') { statusClass = 'status-dead'; statusIcon = window.icon('dotRed', { width: 12, height: 12 }); } else { statusClass = 'status-offline'; statusIcon = window.icon('dotRed', { width: 12, height: 12 }); } 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(filteredMembers); } // Apply filter and re-render applyFilter() { const filteredMembers = this.filterMembers(this.allMembers); this.renderFilteredMembers(filteredMembers); } // Render filtered members without updating dropdowns renderFilteredMembers(filteredMembers) { logger.debug('ClusterMembersComponent: renderFilteredMembers() called with', filteredMembers.length, 'members'); const membersHTML = filteredMembers.map(member => { let statusClass, statusIcon; if (member.status && member.status.toUpperCase() === 'ACTIVE') { statusClass = 'status-online'; statusIcon = window.icon('dotGreen', { width: 12, height: 12 }); } else if (member.status && member.status.toUpperCase() === 'INACTIVE') { statusClass = 'status-dead'; statusIcon = window.icon('dotRed', { width: 12, height: 12 }); } else { statusClass = 'status-offline'; statusIcon = window.icon('dotRed', { width: 12, height: 12 }); } 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(filteredMembers); } setupMemberCards(members) { setTimeout(() => { this.findAllElements('.member-card').forEach((card, index) => { const terminalBtn = card.querySelector('.member-terminal-btn'); 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); } }); } if (terminalBtn) { this.addEventListener(terminalBtn, 'click', async (e) => { e.stopPropagation(); e.preventDefault(); try { if (!window.TerminalPanel) return; this.ensureTerminalContainer(); const panel = window.TerminalPanel; const wasMinimized = panel.isMinimized; panel.open(this.terminalPanelContainer, memberIp); if (wasMinimized && panel.restore) { panel.restore(); } } catch (err) { console.error('Failed to open member terminal:', err); } }); } }); }, 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; } // Set selected member and update highlighting setSelectedMember(memberIp) { // Clear previous selection this.clearSelectedMember(); // Set new selection this.selectedMemberIp = memberIp; // Add selected class to the member card const card = this.findElement(`[data-member-ip="${memberIp}"]`); if (card) { card.classList.add('selected'); } } // Clear selected member highlighting clearSelectedMember() { if (this.selectedMemberIp) { const card = this.findElement(`[data-member-ip="${this.selectedMemberIp}"]`); if (card) { card.classList.remove('selected'); } this.selectedMemberIp = null; } } ensureTerminalContainer() { if (!this.terminalPanelContainer) { try { const drawer = this.drawer; if (drawer && drawer.ensureDrawer) { drawer.ensureDrawer(); this.terminalPanelContainer = drawer.terminalPanelContainer; } } catch (err) { console.error('Failed to ensure terminal container:', err); } } } } window.ClusterMembersComponent = ClusterMembersComponent;