// SPORE UI Components
// Primary Node Component
class PrimaryNodeComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
}
setupEventListeners() {
const refreshBtn = this.findElement('.primary-node-refresh');
if (refreshBtn) {
this.addEventListener(refreshBtn, 'click', this.handleRandomSelection.bind(this));
}
}
setupViewModelListeners() {
// Listen to primary node changes
this.subscribeToProperty('primaryNode', this.render.bind(this));
this.subscribeToProperty('clientInitialized', this.render.bind(this));
this.subscribeToProperty('totalNodes', this.render.bind(this));
this.subscribeToProperty('error', this.render.bind(this));
}
render() {
const primaryNode = this.viewModel.get('primaryNode');
const clientInitialized = this.viewModel.get('clientInitialized');
const totalNodes = this.viewModel.get('totalNodes');
const error = this.viewModel.get('error');
if (error) {
this.setText('#primary-node-ip', '❌ Discovery Failed');
this.setClass('#primary-node-ip', 'error', true);
this.setClass('#primary-node-ip', 'discovering', false);
this.setClass('#primary-node-ip', 'selecting', false);
return;
}
if (!primaryNode) {
this.setText('#primary-node-ip', '🔍 No Nodes Found');
this.setClass('#primary-node-ip', 'error', true);
this.setClass('#primary-node-ip', 'discovering', false);
this.setClass('#primary-node-ip', 'selecting', false);
return;
}
const status = clientInitialized ? '✅' : '⚠️';
const nodeCount = totalNodes > 1 ? ` (${totalNodes} nodes)` : '';
this.setText('#primary-node-ip', `${status} ${primaryNode}${nodeCount}`);
this.setClass('#primary-node-ip', 'error', false);
this.setClass('#primary-node-ip', 'discovering', false);
this.setClass('#primary-node-ip', 'selecting', false);
}
async handleRandomSelection() {
try {
// Show selecting state
this.setText('#primary-node-ip', '🎲 Selecting...');
this.setClass('#primary-node-ip', 'selecting', true);
this.setClass('#primary-node-ip', 'discovering', false);
this.setClass('#primary-node-ip', 'error', false);
await this.viewModel.selectRandomPrimaryNode();
// Show success briefly
this.setText('#primary-node-ip', '🎯 Selection Complete');
// Update display after delay
setTimeout(() => {
this.viewModel.updatePrimaryNodeDisplay();
}, 1500);
} catch (error) {
console.error('Failed to select random primary node:', error);
this.setText('#primary-node-ip', '❌ Selection Failed');
this.setClass('#primary-node-ip', 'error', true);
this.setClass('#primary-node-ip', 'selecting', false);
this.setClass('#primary-node-ip', 'discovering', false);
// Revert to normal display after error
setTimeout(() => {
this.viewModel.updatePrimaryNodeDisplay();
}, 2000);
}
}
}
// Cluster Members Component with enhanced state preservation
class ClusterMembersComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
console.log('ClusterMembersComponent: Constructor called');
console.log('ClusterMembersComponent: Container:', container);
console.log('ClusterMembersComponent: Container ID:', container?.id);
console.log('ClusterMembersComponent: Container innerHTML:', container?.innerHTML);
// Track if we're in the middle of a render operation
this.renderInProgress = false;
this.lastRenderData = null;
// Ensure initial render happens even if no data
setTimeout(() => {
if (this.isMounted && !this.renderInProgress) {
console.log('ClusterMembersComponent: Performing initial render check');
this.render();
}
}, 200);
}
mount() {
console.log('ClusterMembersComponent: Starting mount...');
super.mount();
// Show loading state immediately when mounted
console.log('ClusterMembersComponent: Showing initial loading state');
this.showLoadingState();
// Set up loading timeout safeguard
this.setupLoadingTimeout();
console.log('ClusterMembersComponent: Mounted successfully');
}
// Setup loading timeout safeguard to prevent getting stuck in loading state
setupLoadingTimeout() {
this.loadingTimeout = setTimeout(() => {
const isLoading = this.viewModel.get('isLoading');
if (isLoading) {
console.warn('ClusterMembersComponent: Loading timeout reached, forcing render check');
this.forceRenderCheck();
}
}, 10000); // 10 second timeout
}
// Force a render check when loading gets stuck
forceRenderCheck() {
console.log('ClusterMembersComponent: Force render check called');
const members = this.viewModel.get('members');
const error = this.viewModel.get('error');
const isLoading = this.viewModel.get('isLoading');
console.log('ClusterMembersComponent: Force render check state:', { members, error, isLoading });
if (error) {
this.showErrorState(error);
} else if (members && members.length > 0) {
this.renderMembers(members);
} else if (!isLoading) {
this.showEmptyState();
}
}
setupEventListeners() {
console.log('ClusterMembersComponent: Setting up event listeners...');
// Note: Refresh button is now handled by ClusterViewComponent
// since it's in the cluster header, not in the members container
}
setupViewModelListeners() {
console.log('ClusterMembersComponent: Setting up view model listeners...');
// Listen to cluster members changes with change detection
this.subscribeToProperty('members', this.handleMembersUpdate.bind(this));
this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this));
this.subscribeToProperty('error', this.handleErrorUpdate.bind(this));
console.log('ClusterMembersComponent: View model listeners set up');
}
// Handle members update with state preservation
handleMembersUpdate(newMembers, previousMembers) {
console.log('ClusterMembersComponent: Members updated:', { newMembers, previousMembers });
// Prevent multiple simultaneous renders
if (this.renderInProgress) {
console.log('ClusterMembersComponent: Render already in progress, skipping update');
return;
}
// Check if we're currently loading - if so, let the loading handler deal with it
const isLoading = this.viewModel.get('isLoading');
if (isLoading) {
console.log('ClusterMembersComponent: Currently loading, skipping members update (will be handled by loading completion)');
return;
}
// On first load (no previous members), always render
if (!previousMembers || !Array.isArray(previousMembers) || previousMembers.length === 0) {
console.log('ClusterMembersComponent: First load or no previous members, performing full render');
this.render();
return;
}
if (this.shouldPreserveState(newMembers, previousMembers)) {
// Perform partial update to preserve UI state
console.log('ClusterMembersComponent: Preserving state, performing partial update');
this.updateMembersPartially(newMembers, previousMembers);
} else {
// Full re-render if structure changed significantly
console.log('ClusterMembersComponent: Structure changed, performing full re-render');
this.render();
}
}
// Handle loading state update
handleLoadingUpdate(isLoading) {
console.log('ClusterMembersComponent: Loading state changed:', isLoading);
if (isLoading) {
console.log('ClusterMembersComponent: Showing loading state');
this.renderLoading(`\n
\n
Loading cluster members...
\n
\n `);
// Set up a loading completion check
this.checkLoadingCompletion();
} else {
console.log('ClusterMembersComponent: Loading completed, checking if we need to render');
// When loading completes, check if we have data to render
this.handleLoadingCompletion();
}
}
// Check if loading has completed and handle accordingly
handleLoadingCompletion() {
const members = this.viewModel.get('members');
const error = this.viewModel.get('error');
const isLoading = this.viewModel.get('isLoading');
console.log('ClusterMembersComponent: Handling loading completion:', { members, error, isLoading });
if (error) {
console.log('ClusterMembersComponent: Loading completed with error, showing error state');
this.showErrorState(error);
} else if (members && members.length > 0) {
console.log('ClusterMembersComponent: Loading completed with data, rendering members');
this.renderMembers(members);
} else if (!isLoading) {
console.log('ClusterMembersComponent: Loading completed but no data, showing empty state');
this.showEmptyState();
}
}
// Set up a check to ensure loading completion is handled
checkLoadingCompletion() {
// Clear any existing completion check
if (this.loadingCompletionCheck) {
clearTimeout(this.loadingCompletionCheck);
}
// Set up a completion check that runs after a short delay
this.loadingCompletionCheck = setTimeout(() => {
const isLoading = this.viewModel.get('isLoading');
if (!isLoading) {
console.log('ClusterMembersComponent: Loading completion check triggered');
this.handleLoadingCompletion();
}
}, 1000); // Check after 1 second
}
// Handle error state update
handleErrorUpdate(error) {
if (error) {
this.showErrorState(error);
}
}
// Check if we should preserve UI state during update
shouldPreserveState(newMembers, previousMembers) {
if (!previousMembers || !Array.isArray(previousMembers)) return false;
if (!Array.isArray(newMembers)) return false;
// If member count changed, we need to re-render
if (newMembers.length !== previousMembers.length) return false;
// Check if member IPs are the same (same nodes)
const newIps = new Set(newMembers.map(m => m.ip));
const prevIps = new Set(previousMembers.map(m => m.ip));
// If IPs are the same, we can preserve state
return newIps.size === prevIps.size &&
[...newIps].every(ip => prevIps.has(ip));
}
// Check if we should skip rendering during view switches
shouldSkipRender() {
// Skip rendering if we're in the middle of a view switch
const isViewSwitching = document.querySelectorAll('.view-content.active').length === 0;
if (isViewSwitching) {
console.log('ClusterMembersComponent: View switching in progress, skipping render');
return true;
}
// Skip rendering if the component is not visible
const isVisible = this.container.style.display !== 'none' &&
this.container.style.opacity !== '0' &&
this.container.classList.contains('active');
if (!isVisible) {
console.log('ClusterMembersComponent: Component not visible, skipping render');
return true;
}
return false;
}
// Update members partially to preserve UI state
updateMembersPartially(newMembers, previousMembers) {
console.log('ClusterMembersComponent: Performing partial update to preserve UI state');
// Update only the data that changed, preserving expanded states and active tabs
newMembers.forEach((newMember, index) => {
const prevMember = previousMembers[index];
if (prevMember && this.hasMemberChanged(newMember, prevMember)) {
this.updateMemberCard(newMember, index);
}
});
}
// Check if a specific member has changed
hasMemberChanged(newMember, prevMember) {
return newMember.status !== prevMember.status ||
newMember.latency !== prevMember.latency ||
newMember.hostname !== prevMember.hostname;
}
// Update a specific member card without re-rendering the entire component
updateMemberCard(member, index) {
const card = this.findElement(`[data-member-ip="${member.ip}"]`);
if (!card) return;
// Update status
const statusElement = card.querySelector('.member-status');
if (statusElement) {
const statusClass = member.status === 'active' ? 'status-online' : 'status-offline';
const statusText = member.status === 'active' ? 'Online' : 'Offline';
const statusIcon = member.status === 'active' ? '🟢' : '🔴';
statusElement.className = `member-status ${statusClass}`;
statusElement.innerHTML = `${statusIcon} ${statusText}`;
}
// 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-name');
if (hostnameElement && member.hostname !== hostnameElement.textContent) {
hostnameElement.textContent = member.hostname || 'Unknown Device';
}
}
render() {
if (this.renderInProgress) {
console.log('ClusterMembersComponent: Render already in progress, skipping');
return;
}
// Check if we should skip rendering during view switches
if (this.shouldSkipRender()) {
return;
}
this.renderInProgress = true;
try {
console.log('ClusterMembersComponent: render() called');
console.log('ClusterMembersComponent: Container element:', this.container);
console.log('ClusterMembersComponent: Is mounted:', this.isMounted);
const members = this.viewModel.get('members');
const isLoading = this.viewModel.get('isLoading');
const error = this.viewModel.get('error');
console.log('ClusterMembersComponent: render data:', { members, isLoading, error });
if (isLoading) {
console.log('ClusterMembersComponent: Showing loading state');
this.showLoadingState();
return;
}
if (error) {
console.log('ClusterMembersComponent: Showing error state');
this.showErrorState(error);
return;
}
if (!members || members.length === 0) {
console.log('ClusterMembersComponent: Showing empty state');
this.showEmptyState();
return;
}
console.log('ClusterMembersComponent: Rendering members:', members);
this.renderMembers(members);
} finally {
this.renderInProgress = false;
}
}
// Show loading state
showLoadingState() {
console.log('ClusterMembersComponent: showLoadingState() called');
this.renderLoading(`
Loading cluster members...
`);
}
// Show error state
showErrorState(error) {
console.log('ClusterMembersComponent: showErrorState() called with error:', error);
this.renderError(`Error loading cluster members: ${error}`);
}
// Show empty state
showEmptyState() {
console.log('ClusterMembersComponent: showEmptyState() called');
this.renderEmpty(`
${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
`;
}
}
renderFirmwareTab() {
return `
Firmware Update
Select a .bin or .hex file to upload
`;
}
setupTabs() {
console.log('NodeDetailsComponent: Setting up tabs');
const tabButtons = this.findAllElements('.tab-button');
const tabContents = this.findAllElements('.tab-content');
tabButtons.forEach(button => {
this.addEventListener(button, 'click', (e) => {
e.stopPropagation();
const targetTab = button.dataset.tab;
console.log('NodeDetailsComponent: Tab clicked:', targetTab);
// Update tab UI locally, don't store in view model
this.setActiveTab(targetTab);
});
});
// Also prevent event propagation on tab content areas
tabContents.forEach(content => {
this.addEventListener(content, 'click', (e) => {
e.stopPropagation();
});
});
}
// Update active tab without full re-render
updateActiveTab(newTab, previousTab = null) {
this.setActiveTab(newTab);
console.log(`NodeDetailsComponent: Active tab updated to '${newTab}'`);
}
setupFirmwareUpload() {
const uploadBtn = this.findElement('.upload-btn[data-action="select-file"]');
if (uploadBtn) {
this.addEventListener(uploadBtn, 'click', (e) => {
e.stopPropagation();
const fileInput = this.findElement('#firmware-file');
if (fileInput) {
fileInput.click();
}
});
// Set up file input change handler
const fileInput = this.findElement('#firmware-file');
if (fileInput) {
this.addEventListener(fileInput, 'change', async (e) => {
e.stopPropagation();
const file = e.target.files[0];
if (file) {
await this.uploadFirmware(file);
}
});
}
}
}
async uploadFirmware(file) {
const uploadStatus = this.findElement('#upload-status');
const uploadBtn = this.findElement('.upload-btn');
const originalText = uploadBtn.textContent;
try {
// Show upload status
uploadStatus.style.display = 'block';
uploadStatus.innerHTML = `
📤 Uploading ${file.name}...
Size: ${(file.size / 1024).toFixed(1)}KB
`;
// Disable upload button
uploadBtn.disabled = true;
uploadBtn.textContent = '⏳ Uploading...';
// Get the member IP from the card
const memberCard = this.container.closest('.member-card');
const memberIp = memberCard.dataset.memberIp;
if (!memberIp) {
throw new Error('Could not determine target node IP address');
}
// Upload firmware
const result = await this.viewModel.uploadFirmware(file, memberIp);
// Show success
uploadStatus.innerHTML = `