// 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 - now supports multiple active filters this.activeFilters = []; // Array of {labelKey, labelValue} objects 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 active filters filterMembers(members) { if (!this.activeFilters || this.activeFilters.length === 0) { return members; } return members.filter(member => { if (!member.labels || typeof member.labels !== 'object') { return false; } // All active filters must match (AND logic) return this.activeFilters.every(filter => { if (filter.labelKey && filter.labelValue) { // Both key and value specified - exact match return member.labels[filter.labelKey] === filter.labelValue; } else if (filter.labelKey && !filter.labelValue) { // Only key specified - member must have this key return member.labels.hasOwnProperty(filter.labelKey); } else if (!filter.labelKey && filter.labelValue) { // Only value specified - member must have this value for any key return Object.values(member.labels).includes(filter.labelValue); } return true; }); }); } // Get currently filtered members (public method for external access) getFilteredMembers() { return this.filterMembers(this.allMembers); } // Update filter dropdowns with current label data updateFilterDropdowns() { // Get currently filtered members to determine available options const filteredMembers = this.filterMembers(this.allMembers); // Extract available label keys from filtered members const availableLabelKeys = this.extractLabelKeys(filteredMembers); // Update label key dropdown const keySelect = document.getElementById('label-key-filter'); if (keySelect) { const currentKey = keySelect.value; keySelect.innerHTML = ''; availableLabelKeys.forEach(key => { const option = document.createElement('option'); option.value = key; option.textContent = key; keySelect.appendChild(option); }); // Restore selection if it still exists if (currentKey && availableLabelKeys.includes(currentKey)) { keySelect.value = currentKey; } else { keySelect.value = ''; } } // Update label value dropdown based on selected key this.updateValueDropdown(); // Update clear button state this.updateClearButtonState(); } // Update clear button state based on current active filters updateClearButtonState() { const clearBtn = document.getElementById('clear-filters-btn'); if (clearBtn) { const hasFilters = this.activeFilters && this.activeFilters.length > 0; clearBtn.disabled = !hasFilters; logger.debug('ClusterMembersComponent: Clear button state updated:', { hasFilters, disabled: clearBtn.disabled, activeFilters: this.activeFilters }); } } // 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 = ''; // Get currently filtered members to determine available values const filteredMembers = this.filterMembers(this.allMembers); // If a key is selected, show only values for that key from filtered members const selectedKey = document.getElementById('label-key-filter')?.value; if (selectedKey) { const labelValues = this.extractLabelValuesForKey(filteredMembers, selectedKey); 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 = ''; } } else { // If no key is selected, show all unique values from all keys in filtered members const allValues = new Set(); filteredMembers.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 = ''; } } } // Add a new filter and create a pill addFilter(labelKey, labelValue) { // Check if this filter already exists const existingFilter = this.activeFilters.find(filter => filter.labelKey === labelKey && filter.labelValue === labelValue ); if (existingFilter) { logger.debug('ClusterMembersComponent: Filter already exists, skipping'); return; } // Add the new filter this.activeFilters.push({ labelKey, labelValue }); // Create and add the pill this.createFilterPill(labelKey, labelValue); // Update dropdowns and apply filter this.updateFilterDropdowns(); this.applyFilter(); } // Remove a filter and its pill removeFilter(labelKey, labelValue) { // Remove from active filters this.activeFilters = this.activeFilters.filter(filter => !(filter.labelKey === labelKey && filter.labelValue === labelValue) ); // Remove the pill from DOM this.removeFilterPill(labelKey, labelValue); // Update dropdowns and apply filter this.updateFilterDropdowns(); this.applyFilter(); } // Create a filter pill element createFilterPill(labelKey, labelValue) { const pillsContainer = document.getElementById('filter-pills-container'); if (!pillsContainer) return; const pill = document.createElement('div'); pill.className = 'filter-pill'; pill.dataset.labelKey = labelKey; pill.dataset.labelValue = labelValue; // Create pill text const pillText = document.createElement('span'); pillText.className = 'filter-pill-text'; if (labelKey && labelValue) { pillText.textContent = `${labelKey}: ${labelValue}`; } else if (labelKey && !labelValue) { pillText.textContent = `${labelKey}: *`; } else if (!labelKey && labelValue) { pillText.textContent = `*: ${labelValue}`; } // Create remove button const removeBtn = document.createElement('button'); removeBtn.className = 'filter-pill-remove'; removeBtn.title = 'Remove filter'; removeBtn.innerHTML = ` `; // Add click handler for remove button removeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.removeFilter(labelKey, labelValue); }); // Assemble pill pill.appendChild(pillText); pill.appendChild(removeBtn); // Add to container pillsContainer.appendChild(pill); } // Remove a filter pill from DOM removeFilterPill(labelKey, labelValue) { const pillsContainer = document.getElementById('filter-pills-container'); if (!pillsContainer) return; const pill = pillsContainer.querySelector(`[data-label-key="${labelKey}"][data-label-value="${labelValue}"]`); if (pill) { pill.remove(); } } // Clear all filters and pills clearAllFilters() { this.activeFilters = []; // Clear all pills const pillsContainer = document.getElementById('filter-pills-container'); if (pillsContainer) { pillsContainer.innerHTML = ''; } // Reset dropdowns const keySelect = document.getElementById('label-key-filter'); const valueSelect = document.getElementById('label-value-filter'); if (keySelect) keySelect.value = ''; if (valueSelect) valueSelect.value = ''; // Update dropdowns and apply filter this.updateFilterDropdowns(); this.applyFilter(); } // 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) => { const selectedKey = e.target.value; const selectedValue = valueSelect ? valueSelect.value : ''; // If both key and value are selected, add as a filter pill if (selectedKey && selectedValue) { this.addFilter(selectedKey, selectedValue); // Reset dropdowns after adding filter keySelect.value = ''; valueSelect.value = ''; } else if (selectedKey && !selectedValue) { // Only key selected, update value dropdown this.updateValueDropdown(); } }); } if (valueSelect) { valueSelect.addEventListener('change', (e) => { const selectedValue = e.target.value; const selectedKey = keySelect ? keySelect.value : ''; // If both key and value are selected, add as a filter pill if (selectedKey && selectedValue) { this.addFilter(selectedKey, selectedValue); // Reset dropdowns after adding filter keySelect.value = ''; valueSelect.value = ''; } }); } if (clearBtn) { clearBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); logger.debug('ClusterMembersComponent: Clear filters button clicked'); this.clearAllFilters(); }); } 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)) { logger.debug('ClusterMembersComponent: Member changed, updating card for:', newMember.ip); this.updateMemberCard(newMember); } }); } // Check if a specific member has changed hasMemberChanged(newMember, prevMember) { // Check basic properties if (newMember.status !== prevMember.status || newMember.latency !== prevMember.latency || newMember.hostname !== prevMember.hostname) { return true; } // Check labels for changes const newLabels = newMember.labels || {}; const prevLabels = prevMember.labels || {}; // Compare label keys const newKeys = Object.keys(newLabels); const prevKeys = Object.keys(prevLabels); if (newKeys.length !== prevKeys.length) { return true; } // Compare label values for (const key of newKeys) { if (newLabels[key] !== prevLabels[key]) { return true; } } return false; } // 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'; } // Update labels (add/update/remove labels row as needed) const labels = (member && typeof member.labels === 'object') ? member.labels : {}; const hasLabels = labels && Object.keys(labels).length > 0; let labelsRow = card.querySelector('.member-row-2'); if (hasLabels) { const chipsHtml = Object.entries(labels) .map(([key, value]) => `${this.escapeHtml(String(key))}: ${this.escapeHtml(String(value))}`) .join(''); if (labelsRow) { const labelsContainer = labelsRow.querySelector('.member-labels'); if (labelsContainer) { labelsContainer.innerHTML = chipsHtml; } } else { // Create labels row below row-1 const memberInfo = card.querySelector('.member-info'); if (memberInfo) { labelsRow = document.createElement('div'); labelsRow.className = 'member-row-2'; const labelsContainer = document.createElement('div'); labelsContainer.className = 'member-labels'; labelsContainer.innerHTML = chipsHtml; labelsRow.appendChild(labelsContainer); // Insert after the first row if present, otherwise append const firstRow = memberInfo.querySelector('.member-row-1'); if (firstRow && firstRow.nextSibling) { memberInfo.insertBefore(labelsRow, firstRow.nextSibling); } else { memberInfo.appendChild(labelsRow); } } } } else if (labelsRow) { // No labels now: remove the labels row if it exists labelsRow.remove(); } } 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;