Files
spore-ui/public/scripts/components/ClusterMembersComponent.js
2025-10-14 21:41:15 +02:00

789 lines
25 KiB
JavaScript

// 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;
}
// Determine if we should use desktop drawer behavior
isDesktop() {
return this.drawer.isDesktop();
}
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 = `
<div class="error">
<strong>Error loading node details:</strong><br>
${this.escapeHtml(error.message)}
</div>
`;
});
}, 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();
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(`
<div class="loading">
<div>Loading cluster members...</div>
</div>
`);
// 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(`
<div class="loading">
<div>Loading cluster members...</div>
</div>
`);
}
// 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(`
<div class="empty-state">
<div class="empty-state-icon">${window.icon('cluster', { width: 24, height: 24 })}</div>
<div>No cluster members found</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">
The cluster might be empty or not yet discovered
</div>
</div>
`);
}
renderMembers(members) {
logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members');
const membersHTML = members.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 `
<div class="member-card" data-member-ip="${member.ip}">
<div class="member-header">
<div class="member-info">
<div class="member-row-1">
<div class="status-hostname-group">
<div class="member-status ${statusClass}">
${statusIcon}
</div>
<div class="member-hostname">${this.escapeHtml(member.hostname || 'Unknown Device')}</div>
</div>
<div class="member-ip">${this.escapeHtml(member.ip || 'No IP')}</div>
<div class="member-latency">
<span class="latency-label">Latency:</span>
<span class="latency-value">${member.latency ? member.latency + 'ms' : 'N/A'}</span>
</div>
</div>
${member.labels && Object.keys(member.labels).length ? `
<div class="member-row-2">
<div class="member-labels">
${Object.entries(member.labels).map(([key, value]) => `<span class=\"label-chip\">${this.escapeHtml(key)}: ${this.escapeHtml(value)}</span>`).join('')}
</div>
</div>
` : ''}
</div>
<div class="member-actions">
<button class="member-terminal-btn" title="Open Terminal" aria-label="Open Terminal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 17l6-6-6-6"></path>
<path d="M12 19h8"></path>
</svg>
</button>
<div class="expand-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
</div>
</div>
<div class="member-details">
<div class="loading-details">Loading detailed information...</div>
</div>
</div>
`;
}).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 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 = '<div class="loading-details">Loading detailed information...</div>';
// 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 = `
<div class="error">
<strong>Error loading node details:</strong><br>
${error.message}
</div>
`;
}
}
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;