Files
spore-ui/public/components.js

3130 lines
126 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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('onlineNodes', 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 onlineNodes = this.viewModel.get('onlineNodes');
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 = (onlineNodes && onlineNodes > 0)
? ` (${onlineNodes}/${totalNodes} online)`
: (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 <div class="loading">\n <div>Loading cluster members...</div>\n </div>\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(`
<div class="loading">
<div>Loading cluster members...</div>
</div>
`);
}
// 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(`
<div class="empty-state">
<div class="empty-state-icon">🌐</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) {
console.log('ClusterMembersComponent: renderMembers() called with', members.length, 'members');
const membersHTML = members.map(member => {
const statusClass = member.status === 'active' ? 'status-online' : 'status-offline';
const statusText = member.status === 'active' ? 'Online' : 'Offline';
const statusIcon = member.status === 'active' ? '🟢' : '🔴';
console.log('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-name">${member.hostname || 'Unknown Device'}</div>
<div class="member-ip">${member.ip || 'No IP'}</div>
<div class="member-status ${statusClass}">
${statusIcon} ${statusText}
</div>
<div class="member-latency">
<span class="latency-label">Latency:</span>
<span class="latency-value">${member.latency ? member.latency + 'ms' : 'N/A'}</span>
</div>
${member.labels && Object.keys(member.labels).length ? `
<div class="member-labels">
${Object.entries(member.labels).map(([key, value]) => `<span class=\"label-chip\">${key}: ${value}</span>`).join('')}
</div>
` : ''}
</div>
<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 class="member-details">
<div class="loading-details">Loading detailed information...</div>
</div>
</div>
`;
}).join('');
console.log('ClusterMembersComponent: Setting HTML, length:', membersHTML.length);
this.setHTML('', membersHTML);
console.log('ClusterMembersComponent: HTML set, setting up member cards...');
this.setupMemberCards(members);
}
setupMemberCards(members) {
setTimeout(() => {
this.findAllElements('.member-card').forEach((card, index) => {
const expandIcon = card.querySelector('.expand-icon');
const memberDetails = card.querySelector('.member-details');
const memberIp = card.dataset.memberIp;
// Ensure all cards start collapsed by default
card.classList.remove('expanded');
if (expandIcon) {
expandIcon.classList.remove('expanded');
}
// Clear any previous content
memberDetails.innerHTML = '<div class="loading-details">Loading detailed information...</div>';
// Make the entire card clickable
this.addEventListener(card, 'click', async (e) => {
if (e.target === expandIcon) return;
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();
const isExpanding = !card.classList.contains('expanded');
if (isExpanding) {
await this.expandCard(card, memberIp, memberDetails);
} else {
this.collapseCard(card, expandIcon);
}
});
}
});
}, 100);
}
async expandCard(card, memberIp, memberDetails) {
try {
// Create node details view model and component
const nodeDetailsVM = new NodeDetailsViewModel();
const nodeDetailsComponent = new NodeDetailsComponent(memberDetails, nodeDetailsVM, this.eventBus);
// Load node details
await nodeDetailsVM.loadNodeDetails(memberIp);
// Mount the component
nodeDetailsComponent.mount();
// Update UI
card.classList.add('expanded');
const expandIcon = card.querySelector('.expand-icon');
if (expandIcon) {
expandIcon.classList.add('expanded');
}
} catch (error) {
console.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) {
const tabButtons = container.querySelectorAll('.tab-button');
const tabContents = container.querySelectorAll('.tab-content');
tabButtons.forEach(button => {
this.addEventListener(button, 'click', (e) => {
e.stopPropagation();
const targetTab = button.dataset.tab;
// Use base helper to set active tab
this.setActiveTab(targetTab, container);
// Store active tab state
const memberCard = container.closest('.member-card');
if (memberCard) {
const memberIp = memberCard.dataset.memberIp;
this.viewModel.storeActiveTab(memberIp, targetTab);
}
});
});
// Also prevent event propagation on tab content areas
tabContents.forEach(content => {
this.addEventListener(content, 'click', (e) => {
e.stopPropagation();
});
});
}
// 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');
console.log('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() {
console.log('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();
console.log('ClusterMembersComponent: Manual refresh completed');
} catch (error) {
console.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();
console.log(`${this.constructor.name} unmounted`);
}
// Override pause method to handle timeouts and operations
onPause() {
console.log('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() {
console.log('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) {
console.log('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;
}
}
// Node Details Component with enhanced state preservation
class NodeDetailsComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
}
setupViewModelListeners() {
this.subscribeToProperty('nodeStatus', this.handleNodeStatusUpdate.bind(this));
this.subscribeToProperty('tasks', this.handleTasksUpdate.bind(this));
this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this));
this.subscribeToProperty('error', this.handleErrorUpdate.bind(this));
this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this));
this.subscribeToProperty('capabilities', this.handleCapabilitiesUpdate.bind(this));
}
// Handle node status update with state preservation
handleNodeStatusUpdate(newStatus, previousStatus) {
if (newStatus && !this.viewModel.get('isLoading')) {
this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('capabilities'));
}
}
// Handle tasks update with state preservation
handleTasksUpdate(newTasks, previousTasks) {
const nodeStatus = this.viewModel.get('nodeStatus');
if (nodeStatus && !this.viewModel.get('isLoading')) {
this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('capabilities'));
}
}
// Handle loading state update
handleLoadingUpdate(isLoading) {
if (isLoading) {
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
}
}
// Handle error state update
handleErrorUpdate(error) {
if (error) {
this.renderError(`Error loading node details: ${error}`);
}
}
// Handle active tab update
handleActiveTabUpdate(newTab, previousTab) {
// Update tab UI without full re-render
this.updateActiveTab(newTab, previousTab);
}
// Handle capabilities update with state preservation
handleCapabilitiesUpdate(newCapabilities, previousCapabilities) {
const nodeStatus = this.viewModel.get('nodeStatus');
const tasks = this.viewModel.get('tasks');
if (nodeStatus && !this.viewModel.get('isLoading')) {
this.renderNodeDetails(nodeStatus, tasks, newCapabilities);
}
}
render() {
const nodeStatus = this.viewModel.get('nodeStatus');
const tasks = this.viewModel.get('tasks');
const isLoading = this.viewModel.get('isLoading');
const error = this.viewModel.get('error');
const capabilities = this.viewModel.get('capabilities');
if (isLoading) {
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
return;
}
if (error) {
this.renderError(`Error loading node details: ${error}`);
return;
}
if (!nodeStatus) {
this.renderEmpty('<div class="loading-details">No node status available</div>');
return;
}
this.renderNodeDetails(nodeStatus, tasks, capabilities);
}
renderNodeDetails(nodeStatus, tasks, capabilities) {
// Always start with 'status' tab, don't restore previous state
const activeTab = 'status';
console.log('NodeDetailsComponent: Rendering with activeTab:', activeTab);
const html = `
<div class="tabs-container">
<div class="tabs-header">
<button class="tab-button ${activeTab === 'status' ? 'active' : ''}" data-tab="status">Status</button>
<button class="tab-button ${activeTab === 'endpoints' ? 'active' : ''}" data-tab="endpoints">Endpoints</button>
<button class="tab-button ${activeTab === 'capabilities' ? 'active' : ''}" data-tab="capabilities">Capabilities</button>
<button class="tab-button ${activeTab === 'tasks' ? 'active' : ''}" data-tab="tasks">Tasks</button>
<button class="tab-button ${activeTab === 'firmware' ? 'active' : ''}" data-tab="firmware">Firmware</button>
</div>
<div class="tab-content ${activeTab === 'status' ? 'active' : ''}" id="status-tab">
<div class="detail-row">
<span class="detail-label">Free Heap:</span>
<span class="detail-value">${Math.round(nodeStatus.freeHeap / 1024)}KB</span>
</div>
<div class="detail-row">
<span class="detail-label">Chip ID:</span>
<span class="detail-value">${nodeStatus.chipId}</span>
</div>
<div class="detail-row">
<span class="detail-label">SDK Version:</span>
<span class="detail-value">${nodeStatus.sdkVersion}</span>
</div>
<div class="detail-row">
<span class="detail-label">CPU Frequency:</span>
<span class="detail-value">${nodeStatus.cpuFreqMHz}MHz</span>
</div>
<div class="detail-row">
<span class="detail-label">Flash Size:</span>
<span class="detail-value">${Math.round(nodeStatus.flashChipSize / 1024)}KB</span>
</div>
</div>
<div class="tab-content ${activeTab === 'endpoints' ? 'active' : ''}" id="endpoints-tab">
${nodeStatus.api ? nodeStatus.api.map(endpoint =>
`<div class="endpoint-item">${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}</div>`
).join('') : '<div class="endpoint-item">No API endpoints available</div>'}
</div>
<div class="tab-content ${activeTab === 'capabilities' ? 'active' : ''}" id="capabilities-tab">
${this.renderCapabilitiesTab(capabilities)}
</div>
<div class="tab-content ${activeTab === 'tasks' ? 'active' : ''}" id="tasks-tab">
${this.renderTasksTab(tasks)}
</div>
<div class="tab-content ${activeTab === 'firmware' ? 'active' : ''}" id="firmware-tab">
${this.renderFirmwareTab()}
</div>
</div>
`;
this.setHTML('', html);
this.setupTabs();
this.setupFirmwareUpload();
}
renderCapabilitiesTab(capabilities) {
if (!capabilities || !Array.isArray(capabilities.endpoints) || capabilities.endpoints.length === 0) {
return `
<div class="no-capabilities">
<div>🧩 No capabilities reported</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">This node did not return any capabilities</div>
</div>
`;
}
// Sort endpoints by URI (name), then by method for stable ordering
const endpoints = [...capabilities.endpoints].sort((a, b) => {
const aUri = String(a.uri || '').toLowerCase();
const bUri = String(b.uri || '').toLowerCase();
if (aUri < bUri) return -1;
if (aUri > bUri) return 1;
const aMethod = String(a.method || '').toLowerCase();
const bMethod = String(b.method || '').toLowerCase();
return aMethod.localeCompare(bMethod);
});
const total = endpoints.length;
// Preserve selection based on a stable key of method+uri if available
const selectedKey = String(this.getUIState('capSelectedKey') || '');
let selectedIndex = endpoints.findIndex(ep => `${ep.method} ${ep.uri}` === selectedKey);
if (selectedIndex === -1) {
selectedIndex = Number(this.getUIState('capSelectedIndex'));
if (Number.isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= total) {
selectedIndex = 0;
}
}
// Compute padding for aligned display in dropdown
const maxMethodLen = endpoints.reduce((m, ep) => Math.max(m, String(ep.method || '').length), 0);
const selectorOptions = endpoints.map((ep, idx) => {
const method = String(ep.method || '');
const uri = String(ep.uri || '');
const padCount = Math.max(1, (maxMethodLen - method.length) + 2);
const spacer = '&nbsp;'.repeat(padCount);
return `<option value="${idx}" data-method="${method}" data-uri="${uri}" ${idx === selectedIndex ? 'selected' : ''}>${method}${spacer}${uri}</option>`;
}).join('');
const items = endpoints.map((ep, idx) => {
const formId = `cap-form-${idx}`;
const resultId = `cap-result-${idx}`;
const params = Array.isArray(ep.params) && ep.params.length > 0
? `<div class="capability-params">${ep.params.map((p, pidx) => `
<label class="capability-param" for="${formId}-field-${pidx}">
<span class="param-name">${p.name}${p.required ? ' *' : ''}</span>
${ (Array.isArray(p.values) && p.values.length > 1)
? `<select id="${formId}-field-${pidx}" data-param-name="${p.name}" data-param-location="${p.location || 'body'}" data-param-type="${p.type || 'string'}" data-param-required="${p.required ? '1' : '0'}" class="param-input">${p.values.map(v => `<option value="${v}">${v}</option>`).join('')}</select>`
: `<input id="${formId}-field-${pidx}" data-param-name="${p.name}" data-param-location="${p.location || 'body'}" data-param-type="${p.type || 'string'}" data-param-required="${p.required ? '1' : '0'}" class="param-input" type="text" placeholder="${p.location || 'body'}${p.type || 'string'}" value="${(Array.isArray(p.values) && p.values.length === 1) ? p.values[0] : ''}">`
}
</label>
`).join('')}</div>`
: '<div class="capability-params none">No parameters</div>';
return `
<div class="capability-item" data-cap-index="${idx}" style="display:${idx === selectedIndex ? '' : 'none'};">
<div class="capability-header">
<span class="cap-method">${ep.method}</span>
<span class="cap-uri">${ep.uri}</span>
<button class="cap-call-btn" data-action="call-capability" data-method="${ep.method}" data-uri="${ep.uri}" data-form-id="${formId}" data-result-id="${resultId}">Call</button>
</div>
<form id="${formId}" class="capability-form" onsubmit="return false;">
${params}
</form>
<div id="${resultId}" class="capability-result" style="display:none;"></div>
</div>
`;
}).join('');
// Attach events after render in setupCapabilitiesEvents()
setTimeout(() => this.setupCapabilitiesEvents(), 0);
return `
<div class="capability-selector">
<label class="param-name" for="capability-select">Capability</label>
<select id="capability-select" class="param-input" style="font-family: monospace;">${selectorOptions}</select>
</div>
<div class="capabilities-list">${items}</div>
`;
}
setupCapabilitiesEvents() {
const selector = this.findElement('#capability-select');
if (selector) {
this.addEventListener(selector, 'change', (e) => {
const selected = Number(e.target.value);
const items = Array.from(this.findAllElements('.capability-item'));
items.forEach((el, idx) => {
el.style.display = (idx === selected) ? '' : 'none';
});
this.setUIState('capSelectedIndex', selected);
const opt = e.target.selectedOptions && e.target.selectedOptions[0];
if (opt) {
const method = opt.dataset.method || '';
const uri = opt.dataset.uri || '';
this.setUIState('capSelectedKey', `${method} ${uri}`);
}
});
}
const buttons = this.findAllElements('.cap-call-btn');
buttons.forEach(btn => {
this.addEventListener(btn, 'click', async (e) => {
e.stopPropagation();
const method = btn.dataset.method || 'GET';
const uri = btn.dataset.uri || '';
const formId = btn.dataset.formId;
const resultId = btn.dataset.resultId;
const formEl = this.findElement(`#${formId}`);
const resultEl = this.findElement(`#${resultId}`);
if (!formEl || !resultEl) return;
const inputs = Array.from(formEl.querySelectorAll('.param-input'));
const params = inputs.map(input => ({
name: input.dataset.paramName,
location: input.dataset.paramLocation || 'body',
type: input.dataset.paramType || 'string',
required: input.dataset.paramRequired === '1',
value: input.value
}));
// Required validation
const missing = params.filter(p => p.required && (!p.value || String(p.value).trim() === ''));
if (missing.length > 0) {
resultEl.style.display = 'block';
resultEl.innerHTML = `
<div class="cap-call-error">
<div>❌ Missing required fields: ${missing.map(m => this.escapeHtml(m.name)).join(', ')}</div>
</div>
`;
return;
}
// Show loading state
resultEl.style.display = 'block';
resultEl.innerHTML = '<div class="loading">Calling endpoint...</div>';
try {
const response = await this.viewModel.callCapability(method, uri, params);
const pretty = typeof response?.data === 'object' ? JSON.stringify(response.data, null, 2) : String(response?.data ?? '');
resultEl.innerHTML = `
<div class="cap-call-success">
<div>✅ Success</div>
<pre class="cap-result-pre">${this.escapeHtml(pretty)}</pre>
</div>
`;
} catch (err) {
resultEl.innerHTML = `
<div class="cap-call-error">
<div>❌ Error: ${this.escapeHtml(err.message || 'Request failed')}</div>
</div>
`;
}
});
});
}
escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
renderTasksTab(tasks) {
const summary = this.viewModel.get('tasksSummary');
if (tasks && tasks.length > 0) {
const summaryHTML = summary ? `
<div class="tasks-summary">
<span>Total: ${summary.totalTasks ?? tasks.length}</span>
<span style="margin-left: 0.75rem;">Active: ${summary.activeTasks ?? tasks.filter(t => t.running).length}</span>
</div>
` : '';
const tasksHTML = tasks.map(task => `
<div class="task-item">
<div class="task-header">
<span class="task-name">${task.name || 'Unknown Task'}</span>
<span class="task-status ${task.running ? 'running' : 'stopped'}">
${task.running ? '🟢 Running' : '🔴 Stopped'}
</span>
</div>
<div class="task-details">
<span class="task-interval">Interval: ${task.interval}ms</span>
<span class="task-enabled">${task.enabled ? '🟢 Enabled' : '🔴 Disabled'}</span>
</div>
</div>
`).join('');
return `
${summaryHTML}
${tasksHTML}
`;
} else {
const total = summary?.totalTasks ?? 0;
const active = summary?.activeTasks ?? 0;
return `
<div class="no-tasks">
<div>📋 No active tasks found</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">
${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
</div>
</div>
`;
}
}
renderFirmwareTab() {
return `
<div class="firmware-upload">
<h4>Firmware Update</h4>
<div class="upload-area">
<input type="file" id="firmware-file" accept=".bin,.hex" style="display: none;">
<button class="upload-btn" data-action="select-file">
📁 Choose Firmware File
</button>
<div class="upload-info">Select a .bin or .hex file to upload</div>
<div id="upload-status" style="display: none;"></div>
</div>
</div>
`;
}
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 = `
<div class="upload-progress">
<div>📤 Uploading ${file.name}...</div>
<div style="font-size: 0.8rem; opacity: 0.7;">Size: ${(file.size / 1024).toFixed(1)}KB</div>
</div>
`;
// 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 = `
<div class="upload-success">
<div>✅ Firmware uploaded successfully!</div>
<div style="font-size: 0.8rem; margin-top: 0.5rem; opacity: 0.7;">Node: ${memberIp}</div>
<div style="font-size: 0.8rem; margin-top: 0.5rem; opacity: 0.7;">Size: ${(file.size / 1024).toFixed(1)}KB</div>
</div>
`;
console.log('Firmware upload successful:', result);
} catch (error) {
console.error('Firmware upload failed:', error);
// Show error
uploadStatus.innerHTML = `
<div class="upload-error">
<div>❌ Upload failed: ${error.message}</div>
</div>
`;
} finally {
// Re-enable upload button
uploadBtn.disabled = false;
uploadBtn.textContent = originalText;
// Clear file input
const fileInput = this.findElement('#firmware-file');
if (fileInput) {
fileInput.value = '';
}
}
}
}
// Firmware Component
class FirmwareComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
console.log('FirmwareComponent: Constructor called');
console.log('FirmwareComponent: Container:', container);
console.log('FirmwareComponent: Container ID:', container?.id);
// Check if the dropdown exists in the container
if (container) {
const dropdown = container.querySelector('#specific-node-select');
console.log('FirmwareComponent: Dropdown found in constructor:', !!dropdown);
if (dropdown) {
console.log('FirmwareComponent: Dropdown tagName:', dropdown.tagName);
console.log('FirmwareComponent: Dropdown id:', dropdown.id);
}
}
}
setupEventListeners() {
// Setup global firmware file input
const globalFirmwareFile = this.findElement('#global-firmware-file');
if (globalFirmwareFile) {
this.addEventListener(globalFirmwareFile, 'change', this.handleFileSelect.bind(this));
}
// Setup target selection
const targetRadios = this.findAllElements('input[name="target-type"]');
targetRadios.forEach(radio => {
this.addEventListener(radio, 'change', this.handleTargetChange.bind(this));
});
// Setup specific node select change handler
const specificNodeSelect = this.findElement('#specific-node-select');
console.log('FirmwareComponent: setupEventListeners - specificNodeSelect found:', !!specificNodeSelect);
if (specificNodeSelect) {
console.log('FirmwareComponent: specificNodeSelect element:', specificNodeSelect);
console.log('FirmwareComponent: specificNodeSelect tagName:', specificNodeSelect.tagName);
console.log('FirmwareComponent: specificNodeSelect id:', specificNodeSelect.id);
// Store the bound handler as an instance property
this._boundNodeSelectHandler = this.handleNodeSelect.bind(this);
this.addEventListener(specificNodeSelect, 'change', this._boundNodeSelectHandler);
console.log('FirmwareComponent: Event listener added to specificNodeSelect');
} else {
console.warn('FirmwareComponent: specificNodeSelect element not found during setupEventListeners');
}
// Setup label select change handler (single-select add-to-chips)
const labelSelect = this.findElement('#label-select');
if (labelSelect) {
this._boundLabelSelectHandler = (e) => {
const value = e.target.value;
if (!value) return;
const current = this.viewModel.get('selectedLabels') || [];
if (!current.includes(value)) {
this.viewModel.setSelectedLabels([...current, value]);
}
// Reset select back to placeholder
e.target.value = '';
this.renderSelectedLabelChips();
this.updateAffectedNodesPreview();
this.updateDeployButton();
};
this.addEventListener(labelSelect, 'change', this._boundLabelSelectHandler);
}
// Setup deploy button
const deployBtn = this.findElement('#deploy-btn');
if (deployBtn) {
this.addEventListener(deployBtn, 'click', this.handleDeploy.bind(this));
}
}
setupViewModelListeners() {
this.subscribeToProperty('selectedFile', () => {
this.updateFileInfo();
this.updateDeployButton();
});
this.subscribeToProperty('targetType', () => {
this.updateTargetVisibility();
this.updateDeployButton();
this.updateAffectedNodesPreview();
});
this.subscribeToProperty('specificNode', this.updateDeployButton.bind(this));
this.subscribeToProperty('availableNodes', () => {
this.populateNodeSelect();
this.populateLabelSelect();
this.updateDeployButton();
this.updateAffectedNodesPreview();
});
this.subscribeToProperty('uploadProgress', this.updateUploadProgress.bind(this));
this.subscribeToProperty('uploadResults', this.updateUploadResults.bind(this));
this.subscribeToProperty('isUploading', this.updateUploadState.bind(this));
this.subscribeToProperty('selectedLabels', () => {
this.populateLabelSelect();
this.updateAffectedNodesPreview();
this.updateDeployButton();
});
}
mount() {
super.mount();
console.log('FirmwareComponent: Mounting...');
// Check if the dropdown exists when mounted
const dropdown = this.findElement('#specific-node-select');
console.log('FirmwareComponent: Mount - dropdown found:', !!dropdown);
if (dropdown) {
console.log('FirmwareComponent: Mount - dropdown tagName:', dropdown.tagName);
console.log('FirmwareComponent: Mount - dropdown id:', dropdown.id);
console.log('FirmwareComponent: Mount - dropdown innerHTML:', dropdown.innerHTML);
}
// Initialize target visibility and label list on first mount
try {
this.updateTargetVisibility();
this.populateLabelSelect();
this.updateAffectedNodesPreview();
} catch (e) {
console.warn('FirmwareComponent: Initialization after mount failed:', e);
}
console.log('FirmwareComponent: Mounted successfully');
}
render() {
// Initial render is handled by the HTML template
this.updateDeployButton();
}
handleFileSelect(event) {
const file = event.target.files[0];
this.viewModel.setSelectedFile(file);
}
handleTargetChange(event) {
const targetType = event.target.value;
this.viewModel.setTargetType(targetType);
}
handleNodeSelect(event) {
const nodeIp = event.target.value;
console.log('FirmwareComponent: handleNodeSelect called with nodeIp:', nodeIp);
console.log('Event:', event);
console.log('Event target:', event.target);
console.log('Event target value:', event.target.value);
this.viewModel.setSpecificNode(nodeIp);
// Also update the deploy button state
this.updateDeployButton();
}
async handleDeploy() {
const file = this.viewModel.get('selectedFile');
const targetType = this.viewModel.get('targetType');
const specificNode = this.viewModel.get('specificNode');
if (!file) {
alert('Please select a firmware file first.');
return;
}
if (targetType === 'specific' && !specificNode) {
alert('Please select a specific node to update.');
return;
}
try {
this.viewModel.startUpload();
if (targetType === 'all') {
await this.uploadToAllNodes(file);
} else if (targetType === 'specific') {
await this.uploadToSpecificNode(file, specificNode);
} else if (targetType === 'labels') {
await this.uploadToLabelFilteredNodes(file);
}
// Reset interface after successful upload
this.viewModel.resetUploadState();
} catch (error) {
console.error('Firmware deployment failed:', error);
alert(`Deployment failed: ${error.message}`);
} finally {
this.viewModel.completeUpload();
}
}
async uploadToAllNodes(file) {
try {
// Get current cluster members
const response = await window.apiClient.getClusterMembers();
const nodes = response.members || [];
if (nodes.length === 0) {
alert('No nodes available for firmware update.');
return;
}
const confirmed = confirm(`Upload firmware to all ${nodes.length} nodes? This will update: ${nodes.map(n => n.hostname || n.ip).join(', ')}`);
if (!confirmed) return;
// Show upload progress area
this.showUploadProgress(file, nodes);
// Start batch upload
const results = await this.performBatchUpload(file, nodes);
// Display results
this.displayUploadResults(results);
} catch (error) {
console.error('Failed to upload firmware to all nodes:', error);
throw error;
}
}
async uploadToSpecificNode(file, nodeIp) {
try {
const confirmed = confirm(`Upload firmware to node ${nodeIp}?`);
if (!confirmed) return;
// Show upload progress area
this.showUploadProgress(file, [{ ip: nodeIp, hostname: nodeIp }]);
// Update progress to show starting
this.updateNodeProgress(1, 1, nodeIp, 'Uploading...');
// Perform single node upload
const result = await this.performSingleUpload(file, nodeIp);
// Update progress to show completion
this.updateNodeProgress(1, 1, nodeIp, 'Completed');
this.updateOverallProgress(1, 1);
// Display results
this.displayUploadResults([result]);
} catch (error) {
console.error(`Failed to upload firmware to node ${nodeIp}:`, error);
// Update progress to show failure
this.updateNodeProgress(1, 1, nodeIp, 'Failed');
this.updateOverallProgress(0, 1);
// Display error results
const errorResult = {
nodeIp: nodeIp,
hostname: nodeIp,
success: false,
error: error.message,
timestamp: new Date().toISOString()
};
this.displayUploadResults([errorResult]);
throw error;
}
}
async uploadToLabelFilteredNodes(file) {
try {
const nodes = this.viewModel.getAffectedNodesByLabels();
if (!nodes || nodes.length === 0) {
alert('No nodes match the selected labels.');
return;
}
const labels = this.viewModel.get('selectedLabels') || [];
const confirmed = confirm(`Upload firmware to ${nodes.length} node(s) matching labels (${labels.join(', ')})?`);
if (!confirmed) return;
// Show upload progress area
this.showUploadProgress(file, nodes);
// Start batch upload
const results = await this.performBatchUpload(file, nodes);
// Display results
this.displayUploadResults(results);
} catch (error) {
console.error('Failed to upload firmware to label-filtered nodes:', error);
throw error;
}
}
async performBatchUpload(file, nodes) {
const results = [];
const totalNodes = nodes.length;
let successfulUploads = 0;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const nodeIp = node.ip;
try {
// Update progress
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Uploading...');
// Upload to this node
const result = await this.performSingleUpload(file, nodeIp);
results.push(result);
successfulUploads++;
// Update progress
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Completed');
this.updateOverallProgress(successfulUploads, totalNodes);
} catch (error) {
console.error(`Failed to upload to node ${nodeIp}:`, error);
const errorResult = {
nodeIp: nodeIp,
hostname: node.hostname || nodeIp,
success: false,
error: error.message,
timestamp: new Date().toISOString()
};
results.push(errorResult);
// Update progress
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Failed');
this.updateOverallProgress(successfulUploads, totalNodes);
}
// Small delay between uploads
if (i < nodes.length - 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
return results;
}
async performSingleUpload(file, nodeIp) {
try {
const result = await window.apiClient.uploadFirmware(file, nodeIp);
return {
nodeIp: nodeIp,
hostname: nodeIp,
success: true,
result: result,
timestamp: new Date().toISOString()
};
} catch (error) {
throw new Error(`Upload to ${nodeIp} failed: ${error.message}`);
}
}
showUploadProgress(file, nodes) {
const container = this.findElement('#firmware-nodes-list');
const progressHTML = `
<div class="firmware-upload-progress" id="firmware-upload-progress">
<div class="progress-header">
<h3>📤 Firmware Upload Progress</h3>
<div class="progress-info">
<span>File: ${file.name}</span>
<span>Size: ${(file.size / 1024).toFixed(1)}KB</span>
<span>Targets: ${nodes.length} node(s)</span>
</div>
<div class="overall-progress">
<div class="progress-bar-container">
<div class="progress-bar" id="overall-progress-bar" style="width: 0%; background-color: #fbbf24;"></div>
</div>
<span class="progress-text">0/${nodes.length} Successful (0%)</span>
</div>
<div class="progress-summary" id="progress-summary">
<span>Status: Preparing upload...</span>
</div>
</div>
<div class="progress-list" id="progress-list">
${nodes.map(node => `
<div class="progress-item" data-node-ip="${node.ip}">
<div class="progress-node-info">
<span class="node-name">${node.hostname || node.ip}</span>
<span class="node-ip">${node.ip}</span>
</div>
<div class="progress-status">Pending...</div>
<div class="progress-time" id="time-${node.ip}"></div>
</div>
`).join('')}
</div>
</div>
`;
container.innerHTML = progressHTML;
// Initialize progress for single-node uploads
if (nodes.length === 1) {
const node = nodes[0];
this.updateNodeProgress(1, 1, node.ip, 'Pending...');
}
}
updateNodeProgress(current, total, nodeIp, status) {
const progressItem = this.findElement(`[data-node-ip="${nodeIp}"]`);
if (progressItem) {
const statusElement = progressItem.querySelector('.progress-status');
const timeElement = progressItem.querySelector('.progress-time');
if (statusElement) {
statusElement.textContent = status;
// Add status-specific styling
statusElement.className = 'progress-status';
if (status === 'Completed') {
statusElement.classList.add('success');
if (timeElement) {
timeElement.textContent = new Date().toLocaleTimeString();
}
} else if (status === 'Failed') {
statusElement.classList.add('error');
if (timeElement) {
timeElement.textContent = new Date().toLocaleTimeString();
}
} else if (status === 'Uploading...') {
statusElement.classList.add('uploading');
if (timeElement) {
timeElement.textContent = 'Started: ' + new Date().toLocaleTimeString();
}
}
}
}
}
updateOverallProgress(successfulUploads, totalNodes) {
const progressBar = this.findElement('#overall-progress-bar');
const progressText = this.findElement('.progress-text');
if (progressBar && progressText) {
const successPercentage = Math.round((successfulUploads / totalNodes) * 100);
progressBar.style.width = `${successPercentage}%`;
progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`;
// Update progress bar color based on completion
if (successPercentage === 100) {
progressBar.style.backgroundColor = '#4ade80';
} else if (successPercentage > 50) {
progressBar.style.backgroundColor = '#60a5fa';
} else {
progressBar.style.backgroundColor = '#fbbf24';
}
// Update progress summary for single-node uploads
const progressSummary = this.findElement('#progress-summary');
if (progressSummary && totalNodes === 1) {
if (successfulUploads === 1) {
progressSummary.innerHTML = '<span>Status: Upload completed successfully</span>';
} else if (successfulUploads === 0) {
progressSummary.innerHTML = '<span>Status: Upload failed</span>';
}
}
}
}
displayUploadResults(results) {
const progressHeader = this.findElement('.progress-header h3');
const progressSummary = this.findElement('#progress-summary');
if (progressHeader && progressSummary) {
const successCount = results.filter(r => r.success).length;
const totalCount = results.length;
const successRate = Math.round((successCount / totalCount) * 100);
if (totalCount === 1) {
// Single node upload
if (successCount === 1) {
progressHeader.textContent = `📤 Firmware Upload Complete`;
progressSummary.innerHTML = `<span>✅ Upload to ${results[0].hostname || results[0].nodeIp} completed successfully at ${new Date().toLocaleTimeString()}</span>`;
} else {
progressHeader.textContent = `📤 Firmware Upload Failed`;
progressSummary.innerHTML = `<span>❌ Upload to ${results[0].hostname || results[0].nodeIp} failed at ${new Date().toLocaleTimeString()}</span>`;
}
} else if (successCount === totalCount) {
// Multi-node upload - all successful
progressHeader.textContent = `📤 Firmware Upload Complete (${successCount}/${totalCount} Successful)`;
progressSummary.innerHTML = `<span>✅ All uploads completed successfully at ${new Date().toLocaleTimeString()}</span>`;
} else {
// Multi-node upload - some failed
progressHeader.textContent = `📤 Firmware Upload Results (${successCount}/${totalCount} Successful)`;
progressSummary.innerHTML = `<span>⚠️ Upload completed with ${totalCount - successCount} failure(s) at ${new Date().toLocaleTimeString()}</span>`;
}
}
}
updateFileInfo() {
const file = this.viewModel.get('selectedFile');
const fileInfo = this.findElement('#file-info');
const deployBtn = this.findElement('#deploy-btn');
if (file) {
fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`;
fileInfo.classList.add('has-file');
} else {
fileInfo.textContent = 'No file selected';
fileInfo.classList.remove('has-file');
}
this.updateDeployButton();
}
updateTargetVisibility() {
const targetType = this.viewModel.get('targetType');
const specificNodeSelect = this.findElement('#specific-node-select');
const labelSelect = this.findElement('#label-select');
console.log('FirmwareComponent: updateTargetVisibility called with targetType:', targetType);
if (targetType === 'specific') {
if (specificNodeSelect) { specificNodeSelect.style.display = 'inline-block'; }
if (labelSelect) { labelSelect.style.display = 'none'; }
this.populateNodeSelect();
} else if (targetType === 'labels') {
if (specificNodeSelect) { specificNodeSelect.style.display = 'none'; }
if (labelSelect) {
labelSelect.style.display = 'inline-block';
this.populateLabelSelect();
}
} else {
if (specificNodeSelect) { specificNodeSelect.style.display = 'none'; }
if (labelSelect) { labelSelect.style.display = 'none'; }
}
this.updateDeployButton();
}
// Note: handleNodeSelect is already defined above and handles the actual node selection
// This duplicate method was causing the issue - removing it
updateDeployButton() {
const deployBtn = this.findElement('#deploy-btn');
if (deployBtn) {
deployBtn.disabled = !this.viewModel.isDeployEnabled();
}
}
populateNodeSelect() {
const select = this.findElement('#specific-node-select');
if (!select) {
console.warn('FirmwareComponent: populateNodeSelect - select element not found');
return;
}
if (select.tagName !== 'SELECT') {
console.warn('FirmwareComponent: populateNodeSelect - element is not a SELECT:', select.tagName);
return;
}
console.log('FirmwareComponent: populateNodeSelect called');
console.log('FirmwareComponent: Select element:', select);
console.log('FirmwareComponent: Available nodes:', this.viewModel.get('availableNodes'));
// Clear existing options
select.innerHTML = '<option value="">Select a node...</option>';
// Get available nodes from the view model
const availableNodes = this.viewModel.get('availableNodes');
if (!availableNodes || availableNodes.length === 0) {
// No nodes available
const option = document.createElement('option');
option.value = "";
option.textContent = "No nodes available";
option.disabled = true;
select.appendChild(option);
return;
}
availableNodes.forEach(node => {
const option = document.createElement('option');
option.value = node.ip;
option.textContent = `${node.hostname} (${node.ip})`;
select.appendChild(option);
});
// Ensure event listener is still bound after repopulating
this.ensureNodeSelectListener(select);
console.log('FirmwareComponent: Node select populated with', availableNodes.length, 'nodes');
}
// Ensure the node select change listener is properly bound
ensureNodeSelectListener(select) {
if (!select) return;
// Store the bound handler as an instance property to avoid binding issues
if (!this._boundNodeSelectHandler) {
this._boundNodeSelectHandler = this.handleNodeSelect.bind(this);
}
// Remove any existing listeners and add the bound one
select.removeEventListener('change', this._boundNodeSelectHandler);
select.addEventListener('change', this._boundNodeSelectHandler);
console.log('FirmwareComponent: Node select event listener ensured');
}
updateUploadProgress() {
// This will be implemented when we add upload progress tracking
}
updateUploadResults() {
// This will be implemented when we add upload results display
}
updateUploadState() {
const isUploading = this.viewModel.get('isUploading');
const deployBtn = this.findElement('#deploy-btn');
if (deployBtn) {
deployBtn.disabled = isUploading;
if (isUploading) {
deployBtn.classList.add('loading');
deployBtn.textContent = '⏳ Deploying...';
} else {
deployBtn.classList.remove('loading');
deployBtn.textContent = '🚀 Deploy';
}
}
this.updateDeployButton();
}
populateLabelSelect() {
const select = this.findElement('#label-select');
if (!select) return;
const labels = this.viewModel.get('availableLabels') || [];
const selected = new Set(this.viewModel.get('selectedLabels') || []);
const options = ['<option value="">Select a label...</option>']
.concat(labels.filter(l => !selected.has(l)).map(l => `<option value="${l}">${l}</option>`));
select.innerHTML = options.join('');
// Ensure change listener remains bound
if (this._boundLabelSelectHandler) {
select.removeEventListener('change', this._boundLabelSelectHandler);
select.addEventListener('change', this._boundLabelSelectHandler);
}
this.renderSelectedLabelChips();
}
renderSelectedLabelChips() {
const container = this.findElement('#selected-labels-container');
if (!container) return;
const selected = this.viewModel.get('selectedLabels') || [];
if (selected.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = selected.map(l => `
<span class="label-chip removable" data-label="${l}">
${l}
<button class="chip-remove" data-label="${l}" title="Remove">×</button>
</span>
`).join('');
Array.from(container.querySelectorAll('.chip-remove')).forEach(btn => {
this.addEventListener(btn, 'click', (e) => {
e.stopPropagation();
const label = btn.getAttribute('data-label');
const current = this.viewModel.get('selectedLabels') || [];
this.viewModel.setSelectedLabels(current.filter(x => x !== label));
this.populateLabelSelect();
this.updateAffectedNodesPreview();
this.updateDeployButton();
});
});
}
updateAffectedNodesPreview() {
const container = this.findElement('#firmware-nodes-list');
if (!container) return;
if (this.viewModel.get('targetType') !== 'labels') {
container.innerHTML = '';
return;
}
const nodes = this.viewModel.getAffectedNodesByLabels();
if (!nodes.length) {
container.innerHTML = `<div class="empty-state"><div>No nodes match the selected labels</div></div>`;
return;
}
const html = `
<div class="affected-nodes">
<div class="progress-header"><h3>🎯 Affected Nodes (${nodes.length})</h3></div>
<div class="progress-list">
${nodes.map(n => `
<div class="progress-item" data-node-ip="${n.ip}">
<div class="progress-node-info"><span class="node-name">${n.hostname || n.ip}</span><span class="node-ip">${n.ip}</span></div>
</div>
`).join('')}
</div>
</div>`;
container.innerHTML = html;
}
}
// Cluster View Component
class ClusterViewComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
console.log('ClusterViewComponent: Constructor called');
console.log('ClusterViewComponent: Container:', container);
console.log('ClusterViewComponent: Container ID:', container?.id);
// Find elements for sub-components
const primaryNodeContainer = this.findElement('.primary-node-info');
const clusterMembersContainer = this.findElement('#cluster-members-container');
console.log('ClusterViewComponent: Primary node container:', primaryNodeContainer);
console.log('ClusterViewComponent: Cluster members container:', clusterMembersContainer);
console.log('ClusterViewComponent: Cluster members container ID:', clusterMembersContainer?.id);
console.log('ClusterViewComponent: Cluster members container innerHTML:', clusterMembersContainer?.innerHTML);
// Create sub-components
this.primaryNodeComponent = new PrimaryNodeComponent(
primaryNodeContainer,
viewModel,
eventBus
);
this.clusterMembersComponent = new ClusterMembersComponent(
clusterMembersContainer,
viewModel,
eventBus
);
console.log('ClusterViewComponent: Sub-components created');
// Track if we've already loaded data to prevent unnecessary reloads
this.dataLoaded = false;
}
mount() {
console.log('ClusterViewComponent: Mounting...');
super.mount();
console.log('ClusterViewComponent: Mounting sub-components...');
// Mount sub-components
this.primaryNodeComponent.mount();
this.clusterMembersComponent.mount();
// Set up refresh button event listener (since it's in the cluster header, not in the members container)
this.setupRefreshButton();
// Only load data if we haven't already or if the view model is empty
const members = this.viewModel.get('members');
const shouldLoadData = !this.dataLoaded || !members || members.length === 0;
if (shouldLoadData) {
console.log('ClusterViewComponent: Starting initial data load...');
// Initial data load - ensure it happens after mounting
setTimeout(() => {
this.viewModel.updateClusterMembers().then(() => {
this.dataLoaded = true;
}).catch(error => {
console.error('ClusterViewComponent: Failed to load initial data:', error);
});
}, 100);
} else {
console.log('ClusterViewComponent: Data already loaded, skipping initial load');
}
// Set up periodic updates
// this.setupPeriodicUpdates(); // Disabled automatic refresh
console.log('ClusterViewComponent: Mounted successfully');
}
setupRefreshButton() {
console.log('ClusterViewComponent: Setting up refresh button...');
const refreshBtn = this.findElement('.refresh-btn');
console.log('ClusterViewComponent: Found refresh button:', !!refreshBtn, refreshBtn);
if (refreshBtn) {
console.log('ClusterViewComponent: Adding click event listener to refresh button');
this.addEventListener(refreshBtn, 'click', this.handleRefresh.bind(this));
console.log('ClusterViewComponent: Event listener added successfully');
} else {
console.error('ClusterViewComponent: Refresh button not found!');
console.log('ClusterViewComponent: Container HTML:', this.container.innerHTML);
console.log('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button'));
}
}
async handleRefresh() {
console.log('ClusterViewComponent: Refresh button clicked, performing full refresh...');
// Get the refresh button and show loading state
const refreshBtn = this.findElement('.refresh-btn');
console.log('ClusterViewComponent: Found refresh button for loading state:', !!refreshBtn);
if (refreshBtn) {
const originalText = refreshBtn.innerHTML;
console.log('ClusterViewComponent: Original button text:', originalText);
refreshBtn.innerHTML = `
<svg class="refresh-icon spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
Refreshing...
`;
refreshBtn.disabled = true;
try {
console.log('ClusterViewComponent: Starting cluster members update...');
// Always perform a full refresh when user clicks refresh button
await this.viewModel.updateClusterMembers();
console.log('ClusterViewComponent: Cluster members update completed successfully');
} catch (error) {
console.error('ClusterViewComponent: Error during refresh:', error);
// Show error state
if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) {
this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed');
}
} finally {
console.log('ClusterViewComponent: Restoring button state...');
// Restore button state
refreshBtn.innerHTML = originalText;
refreshBtn.disabled = false;
}
} else {
console.warn('ClusterViewComponent: Refresh button not found, using fallback refresh');
// Fallback if button not found
try {
await this.viewModel.updateClusterMembers();
} catch (error) {
console.error('ClusterViewComponent: Fallback refresh failed:', error);
if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) {
this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed');
}
}
}
}
unmount() {
console.log('ClusterViewComponent: Unmounting...');
// Unmount sub-components
if (this.primaryNodeComponent) {
this.primaryNodeComponent.unmount();
}
if (this.clusterMembersComponent) {
this.clusterMembersComponent.unmount();
}
// Clear intervals
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
super.unmount();
console.log('ClusterViewComponent: Unmounted');
}
// Override pause method to handle sub-components
onPause() {
console.log('ClusterViewComponent: Pausing...');
// Pause sub-components
if (this.primaryNodeComponent && this.primaryNodeComponent.isMounted) {
this.primaryNodeComponent.pause();
}
if (this.clusterMembersComponent && this.clusterMembersComponent.isMounted) {
this.clusterMembersComponent.pause();
}
// Clear any active intervals
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
}
// Override resume method to handle sub-components
onResume() {
console.log('ClusterViewComponent: Resuming...');
// Resume sub-components
if (this.primaryNodeComponent && this.primaryNodeComponent.isMounted) {
this.primaryNodeComponent.resume();
}
if (this.clusterMembersComponent && this.clusterMembersComponent.isMounted) {
this.clusterMembersComponent.resume();
}
// Restart periodic updates if needed
// this.setupPeriodicUpdates(); // Disabled automatic refresh
}
// Override to determine if re-render is needed on resume
shouldRenderOnResume() {
// Don't re-render on resume - the component should maintain its state
return false;
}
setupPeriodicUpdates() {
// Update primary node display every 10 seconds
this.updateInterval = setInterval(() => {
this.viewModel.updatePrimaryNodeDisplay();
}, 10000);
}
}
// Firmware View Component
class FirmwareViewComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
console.log('FirmwareViewComponent: Constructor called');
console.log('FirmwareViewComponent: Container:', container);
const firmwareContainer = this.findElement('#firmware-container');
console.log('FirmwareViewComponent: Firmware container found:', !!firmwareContainer);
this.firmwareComponent = new FirmwareComponent(
firmwareContainer,
viewModel,
eventBus
);
console.log('FirmwareViewComponent: FirmwareComponent created');
}
mount() {
super.mount();
console.log('FirmwareViewComponent: Mounting...');
// Mount sub-component
this.firmwareComponent.mount();
// Update available nodes
this.updateAvailableNodes();
console.log('FirmwareViewComponent: Mounted successfully');
}
unmount() {
// Unmount sub-component
if (this.firmwareComponent) {
this.firmwareComponent.unmount();
}
super.unmount();
}
// Override pause method to handle sub-components
onPause() {
console.log('FirmwareViewComponent: Pausing...');
// Pause sub-component
if (this.firmwareComponent && this.firmwareComponent.isMounted) {
this.firmwareComponent.pause();
}
}
// Override resume method to handle sub-components
onResume() {
console.log('FirmwareViewComponent: Resuming...');
// Resume sub-component
if (this.firmwareComponent && this.firmwareComponent.isMounted) {
this.firmwareComponent.resume();
}
}
// Override to determine if re-render is needed on resume
shouldRenderOnResume() {
// Don't re-render on resume - maintain current state
return false;
}
async updateAvailableNodes() {
try {
console.log('FirmwareViewComponent: updateAvailableNodes called');
const response = await window.apiClient.getClusterMembers();
const nodes = response.members || [];
console.log('FirmwareViewComponent: Got nodes:', nodes);
this.viewModel.updateAvailableNodes(nodes);
console.log('FirmwareViewComponent: Available nodes updated in view model');
} catch (error) {
console.error('Failed to update available nodes:', error);
}
}
}
// Cluster Status Component for header badge
class ClusterStatusComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
}
setupViewModelListeners() {
// Subscribe to properties that affect cluster status
this.subscribeToProperty('totalNodes', this.render.bind(this));
this.subscribeToProperty('clientInitialized', this.render.bind(this));
this.subscribeToProperty('error', this.render.bind(this));
}
render() {
const totalNodes = this.viewModel.get('totalNodes');
const clientInitialized = this.viewModel.get('clientInitialized');
const error = this.viewModel.get('error');
let statusText, statusIcon, statusClass;
if (error) {
statusText = 'Cluster Error';
statusIcon = '❌';
statusClass = 'cluster-status-error';
} else if (totalNodes === 0) {
statusText = 'Cluster Offline';
statusIcon = '🔴';
statusClass = 'cluster-status-offline';
} else if (clientInitialized) {
statusText = 'Cluster Online';
statusIcon = '🟢';
statusClass = 'cluster-status-online';
} else {
statusText = 'Cluster Connecting';
statusIcon = '🟡';
statusClass = 'cluster-status-connecting';
}
// Update the cluster status badge using the container passed to this component
if (this.container) {
this.container.innerHTML = `${statusIcon} ${statusText}`;
// Remove all existing status classes
this.container.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error');
// Add the appropriate status class
this.container.classList.add(statusClass);
}
}
}
// Topology Graph Component with D3.js force-directed visualization
class TopologyGraphComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
console.log('TopologyGraphComponent: Constructor called');
this.svg = null;
this.simulation = null;
this.zoom = null;
this.width = 0; // Will be set dynamically based on container size
this.height = 0; // Will be set dynamically based on container size
this.isInitialized = false;
}
updateDimensions(container) {
// Get the container's actual dimensions
const rect = container.getBoundingClientRect();
this.width = rect.width || 1400; // Fallback to 1400 if width is 0
this.height = rect.height || 1000; // Fallback to 1000 if height is 0
// Ensure minimum dimensions
this.width = Math.max(this.width, 800);
this.height = Math.max(this.height, 600);
console.log('TopologyGraphComponent: Updated dimensions to', this.width, 'x', this.height);
}
handleResize() {
// Debounce resize events to avoid excessive updates
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
}
this.resizeTimeout = setTimeout(() => {
const container = this.findElement('#topology-graph-container');
if (container && this.svg) {
this.updateDimensions(container);
// Update SVG viewBox and force center
this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`);
if (this.simulation) {
this.simulation.force('center', d3.forceCenter(this.width / 2, this.height / 2));
this.simulation.alpha(0.3).restart();
}
}
}, 250); // 250ms debounce
}
// Override mount to ensure proper initialization
mount() {
if (this.isMounted) return;
console.log('TopologyGraphComponent: Starting mount...');
console.log('TopologyGraphComponent: isInitialized =', this.isInitialized);
// Call initialize if not already done
if (!this.isInitialized) {
console.log('TopologyGraphComponent: Initializing during mount...');
this.initialize().then(() => {
console.log('TopologyGraphComponent: Initialization completed, calling completeMount...');
// Complete mount after initialization
this.completeMount();
}).catch(error => {
console.error('TopologyGraphComponent: Initialization failed during mount:', error);
// Still complete mount to prevent blocking
this.completeMount();
});
} else {
console.log('TopologyGraphComponent: Already initialized, calling completeMount directly...');
this.completeMount();
}
}
completeMount() {
console.log('TopologyGraphComponent: completeMount called');
this.isMounted = true;
console.log('TopologyGraphComponent: Setting up event listeners...');
this.setupEventListeners();
console.log('TopologyGraphComponent: Setting up view model listeners...');
this.setupViewModelListeners();
console.log('TopologyGraphComponent: Calling render...');
this.render();
console.log('TopologyGraphComponent: Mounted successfully');
}
setupEventListeners() {
console.log('TopologyGraphComponent: setupEventListeners called');
console.log('TopologyGraphComponent: Container:', this.container);
console.log('TopologyGraphComponent: Container ID:', this.container?.id);
// Add resize listener to update dimensions when window is resized
this.resizeHandler = this.handleResize.bind(this);
window.addEventListener('resize', this.resizeHandler);
// Refresh button removed from HTML, so no need to set up event listeners
console.log('TopologyGraphComponent: No event listeners needed (refresh button removed)');
}
setupViewModelListeners() {
console.log('TopologyGraphComponent: setupViewModelListeners called');
console.log('TopologyGraphComponent: isInitialized =', this.isInitialized);
if (this.isInitialized) {
// Component is already initialized, set up subscriptions immediately
console.log('TopologyGraphComponent: Setting up property subscriptions immediately');
this.subscribeToProperty('nodes', this.renderGraph.bind(this));
this.subscribeToProperty('links', this.renderGraph.bind(this));
this.subscribeToProperty('isLoading', this.handleLoadingState.bind(this));
this.subscribeToProperty('error', this.handleError.bind(this));
this.subscribeToProperty('selectedNode', this.updateSelection.bind(this));
} else {
// Component not yet initialized, store for later
console.log('TopologyGraphComponent: Component not initialized, storing pending subscriptions');
this._pendingSubscriptions = [
['nodes', this.renderGraph.bind(this)],
['links', this.renderGraph.bind(this)],
['isLoading', this.handleLoadingState.bind(this)],
['error', this.handleError.bind(this)],
['selectedNode', this.updateSelection.bind(this)]
];
}
}
async initialize() {
console.log('TopologyGraphComponent: Initializing...');
// Wait for DOM to be ready
if (document.readyState === 'loading') {
await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', resolve);
});
}
// Set up the SVG container
this.setupSVG();
// Mark as initialized
this.isInitialized = true;
// Now set up the actual property listeners after initialization
if (this._pendingSubscriptions) {
this._pendingSubscriptions.forEach(([property, callback]) => {
this.subscribeToProperty(property, callback);
});
this._pendingSubscriptions = null;
}
// Initial data load
await this.viewModel.updateNetworkTopology();
}
setupSVG() {
const container = this.findElement('#topology-graph-container');
if (!container) {
console.error('TopologyGraphComponent: Graph container not found');
return;
}
// Calculate dynamic dimensions based on container size
this.updateDimensions(container);
// Clear existing content
container.innerHTML = '';
// Create SVG element
this.svg = d3.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', `0 0 ${this.width} ${this.height}`)
.style('border', '1px solid rgba(255, 255, 255, 0.1)')
.style('background', 'rgba(0, 0, 0, 0.2)')
.style('border-radius', '12px');
// Add zoom behavior
this.zoom = d3.zoom()
.scaleExtent([0.5, 5]) // Changed from [0.3, 4] to allow more zoom in and start more zoomed in
.on('zoom', (event) => {
this.svg.select('g').attr('transform', event.transform);
});
this.svg.call(this.zoom);
// Create main group for zoom and apply initial zoom
const mainGroup = this.svg.append('g');
// Apply initial zoom to show the graph more zoomed in
mainGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); // Better centered positioning
console.log('TopologyGraphComponent: SVG setup completed');
}
// Ensure component is initialized
async ensureInitialized() {
if (!this.isInitialized) {
console.log('TopologyGraphComponent: Ensuring initialization...');
await this.initialize();
}
return this.isInitialized;
}
renderGraph() {
try {
// Check if component is initialized
if (!this.isInitialized) {
console.log('TopologyGraphComponent: Component not yet initialized, ensuring initialization...');
this.ensureInitialized().then(() => {
// Re-render after initialization
this.renderGraph();
}).catch(error => {
console.error('TopologyGraphComponent: Failed to initialize:', error);
});
return;
}
const nodes = this.viewModel.get('nodes');
const links = this.viewModel.get('links');
// Check if SVG is initialized
if (!this.svg) {
console.log('TopologyGraphComponent: SVG not initialized yet, setting up SVG first');
this.setupSVG();
}
if (!nodes || nodes.length === 0) {
this.showNoData();
return;
}
console.log('TopologyGraphComponent: Rendering graph with', nodes.length, 'nodes and', links.length, 'links');
// Get the main SVG group (the one created in setupSVG)
let svgGroup = this.svg.select('g');
if (!svgGroup || svgGroup.empty()) {
console.log('TopologyGraphComponent: Creating new SVG group');
svgGroup = this.svg.append('g');
// Apply initial zoom to show the graph more zoomed in
svgGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); // Better centered positioning
}
// Clear existing graph elements but preserve the main group and its transform
svgGroup.selectAll('.graph-element').remove();
// Create links with better styling (no arrows needed)
const link = svgGroup.append('g')
.attr('class', 'graph-element')
.selectAll('line')
.data(links)
.enter().append('line')
.attr('stroke', d => this.getLinkColor(d.latency))
.attr('stroke-opacity', 0.7) // Reduced from 0.8 for subtlety
.attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8))) // Much thinner: reduced from max 6 to max 3
.attr('marker-end', null); // Remove arrows
// Create nodes
const node = svgGroup.append('g')
.attr('class', 'graph-element')
.selectAll('g')
.data(nodes)
.enter().append('g')
.attr('class', 'node')
.call(this.drag(this.simulation));
// Add circles to nodes with size based on status
node.append('circle')
.attr('r', d => this.getNodeRadius(d.status))
.attr('fill', d => this.getNodeColor(d.status))
.attr('stroke', '#fff')
.attr('stroke-width', 2);
// Add status indicator
node.append('circle')
.attr('r', 3)
.attr('fill', d => this.getStatusIndicatorColor(d.status))
.attr('cx', -8)
.attr('cy', -8);
// Add labels to nodes
node.append('text')
.text(d => d.hostname.length > 15 ? d.hostname.substring(0, 15) + '...' : d.hostname)
.attr('x', 15)
.attr('y', 4)
.attr('font-size', '13px') // Increased from 12px for better readability
.attr('fill', '#ecf0f1') // Light text for dark theme
.attr('font-weight', '500');
// Add IP address labels
node.append('text')
.text(d => d.ip)
.attr('x', 15)
.attr('y', 20)
.attr('font-size', '11px') // Increased from 10px for better readability
.attr('fill', 'rgba(255, 255, 255, 0.7)'); // Semi-transparent white
// Add status labels
node.append('text')
.text(d => d.status)
.attr('x', 15)
.attr('y', 35)
.attr('font-size', '11px') // Increased from 10px for better readability
.attr('fill', d => this.getNodeColor(d.status))
.attr('font-weight', '600');
// Add latency labels on links with better positioning
const linkLabels = svgGroup.append('g')
.attr('class', 'graph-element')
.selectAll('text')
.data(links)
.enter().append('text')
.attr('font-size', '12px') // Increased from 11px for better readability
.attr('fill', '#ecf0f1') // Light text for dark theme
.attr('font-weight', '600') // Made slightly bolder for better readability
.attr('text-anchor', 'middle')
.style('text-shadow', '0 1px 2px rgba(0, 0, 0, 0.8)') // Add shadow for better contrast
.text(d => `${d.latency}ms`);
// Remove the background boxes for link labels - they look out of place
// Set up force simulation with better parameters (only if not already exists)
if (!this.simulation) {
this.simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(300)) // Increased from 200 for more spacing
.force('charge', d3.forceManyBody().strength(-800)) // Increased from -600 for stronger repulsion
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
.force('collision', d3.forceCollide().radius(80)); // Increased from 60 for more separation
// Update positions on simulation tick
this.simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
// Update link labels
linkLabels
.attr('x', d => (d.source.x + d.target.x) / 2)
.attr('y', d => (d.source.y + d.target.y) / 2 - 5);
// Remove the background update code since we removed the backgrounds
node
.attr('transform', d => `translate(${d.x},${d.y})`);
});
} else {
// Update existing simulation with new data
this.simulation.nodes(nodes);
this.simulation.force('link').links(links);
this.simulation.alpha(0.3).restart();
}
// Add click handlers for node selection and member card overlay
node.on('click', (event, d) => {
this.viewModel.selectNode(d.id);
this.updateSelection(d.id);
// Show member card overlay
this.showMemberCardOverlay(d);
});
// Add hover effects
node.on('mouseover', (event, d) => {
d3.select(event.currentTarget).select('circle')
.attr('r', d => this.getNodeRadius(d.status) + 4)
.attr('stroke-width', 3);
});
node.on('mouseout', (event, d) => {
d3.select(event.currentTarget).select('circle')
.attr('r', d => this.getNodeRadius(d.status))
.attr('stroke-width', 2);
});
// Add tooltip for links
link.on('mouseover', (event, d) => {
d3.select(event.currentTarget)
.attr('stroke-width', d => Math.max(2, Math.min(4, d.latency / 6))) // Reduced from max 10 to max 4
.attr('stroke-opacity', 0.9); // Reduced from 1 for subtlety
});
link.on('mouseout', (event, d) => {
d3.select(event.currentTarget)
.attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8))) // Reduced from max 8 to max 3
.attr('stroke-opacity', 0.7); // Reduced from 0.7 for consistency
});
// Add legend
this.addLegend(svgGroup);
} catch (error) {
console.error('Failed to render graph:', error);
}
}
addLegend(svgGroup) {
const legend = svgGroup.append('g')
.attr('class', 'graph-element')
.attr('transform', `translate(120, 120)`) // Increased from (80, 80) for more space from edges
.style('opacity', '0'); // Hide the legend but keep it in the code
// Add background for better visibility
legend.append('rect')
.attr('width', 320) // Increased from 280 for more space
.attr('height', 120) // Increased from 100 for more space
.attr('fill', 'rgba(0, 0, 0, 0.7)')
.attr('rx', 8)
.attr('stroke', 'rgba(255, 255, 255, 0.2)')
.attr('stroke-width', 1);
// Node status legend
const nodeLegend = legend.append('g')
.attr('transform', 'translate(20, 20)'); // Increased from (15, 15) for more internal padding
nodeLegend.append('text')
.text('Node Status:')
.attr('x', 0)
.attr('y', 0)
.attr('font-size', '14px') // Increased from 13px for better readability
.attr('font-weight', '600')
.attr('fill', '#ecf0f1');
const statuses = [
{ status: 'ACTIVE', color: '#10b981', y: 20 },
{ status: 'INACTIVE', color: '#f59e0b', y: 40 },
{ status: 'DEAD', color: '#ef4444', y: 60 }
];
statuses.forEach(item => {
nodeLegend.append('circle')
.attr('r', 6)
.attr('cx', 0)
.attr('cy', item.y)
.attr('fill', item.color);
nodeLegend.append('text')
.text(item.status)
.attr('x', 15)
.attr('y', item.y + 4)
.attr('font-size', '12px') // Increased from 11px for better readability
.attr('fill', '#ecf0f1');
});
// Link latency legend
const linkLegend = legend.append('g')
.attr('transform', 'translate(150, 20)'); // Adjusted position for better spacing
linkLegend.append('text')
.text('Link Latency:')
.attr('x', 0)
.attr('y', 0)
.attr('font-size', '14px') // Increased from 13px for better readability
.attr('font-weight', '600')
.attr('fill', '#ecf0f1');
const latencies = [
{ range: '≤30ms', color: '#10b981', y: 20 },
{ range: '31-50ms', color: '#f59e0b', y: 40 },
{ range: '>50ms', color: '#ef4444', y: 60 }
];
latencies.forEach(item => {
linkLegend.append('line')
.attr('x1', 0)
.attr('y1', item.y)
.attr('x2', 20)
.attr('y2', item.y)
.attr('stroke', item.color)
.attr('stroke-width', 2); // Reduced from 3 to match the thinner graph lines
linkLegend.append('text')
.text(item.range)
.attr('x', 25)
.attr('y', item.y + 4)
.attr('font-size', '12px') // Increased from 11px for better readability
.attr('fill', '#ecf0f1');
});
}
getNodeRadius(status) {
switch (status?.toUpperCase()) {
case 'ACTIVE':
return 10;
case 'INACTIVE':
return 8;
case 'DEAD':
return 6;
default:
return 8;
}
}
getStatusIndicatorColor(status) {
switch (status?.toUpperCase()) {
case 'ACTIVE':
return '#10b981'; // Green
case 'INACTIVE':
return '#f59e0b'; // Orange
case 'DEAD':
return '#ef4444'; // Red
default:
return '#6b7280'; // Gray
}
}
getLinkColor(latency) {
if (latency <= 30) return '#10b981'; // Green for low latency (≤30ms)
if (latency <= 50) return '#f59e0b'; // Orange for medium latency (31-50ms)
return '#ef4444'; // Red for high latency (>50ms)
}
getNodeColor(status) {
switch (status?.toUpperCase()) {
case 'ACTIVE':
return '#10b981'; // Green
case 'INACTIVE':
return '#f59e0b'; // Orange
case 'DEAD':
return '#ef4444'; // Red
default:
return '#6b7280'; // Gray
}
}
drag(simulation) {
return d3.drag()
.on('start', function(event, d) {
if (!event.active && simulation && simulation.alphaTarget) {
simulation.alphaTarget(0.3).restart();
}
d.fx = d.x;
d.fy = d.y;
})
.on('drag', function(event, d) {
d.fx = event.x;
d.fy = event.y;
})
.on('end', function(event, d) {
if (!event.active && simulation && simulation.alphaTarget) {
simulation.alphaTarget(0);
}
d.fx = null;
d.fy = null;
});
}
updateSelection(selectedNodeId) {
// Update visual selection
if (!this.svg || !this.isInitialized) {
return;
}
this.svg.selectAll('.node').select('circle')
.attr('stroke-width', d => d.id === selectedNodeId ? 4 : 2)
.attr('stroke', d => d.id === selectedNodeId ? '#2196F3' : '#fff');
}
handleRefresh() {
console.log('TopologyGraphComponent: handleRefresh called');
if (!this.isInitialized) {
console.log('TopologyGraphComponent: Component not yet initialized, ensuring initialization...');
this.ensureInitialized().then(() => {
// Refresh after initialization
this.viewModel.updateNetworkTopology();
}).catch(error => {
console.error('TopologyGraphComponent: Failed to initialize for refresh:', error);
});
return;
}
console.log('TopologyGraphComponent: Calling updateNetworkTopology...');
this.viewModel.updateNetworkTopology();
}
handleLoadingState(isLoading) {
console.log('TopologyGraphComponent: handleLoadingState called with:', isLoading);
const container = this.findElement('#topology-graph-container');
if (isLoading) {
container.innerHTML = '<div class="loading"><div>Loading network topology...</div></div>';
}
}
handleError() {
const error = this.viewModel.get('error');
if (error) {
const container = this.findElement('#topology-graph-container');
container.innerHTML = `<div class="error"><div>Error: ${error}</div></div>`;
}
}
showNoData() {
const container = this.findElement('#topology-graph-container');
container.innerHTML = '<div class="no-data"><div>No cluster members found</div></div>';
}
showMemberCardOverlay(nodeData) {
// Create overlay container if it doesn't exist
let overlayContainer = document.getElementById('member-card-overlay');
if (!overlayContainer) {
overlayContainer = document.createElement('div');
overlayContainer.id = 'member-card-overlay';
overlayContainer.className = 'member-card-overlay';
document.body.appendChild(overlayContainer);
}
// Create and show the overlay component
if (!this.memberOverlayComponent) {
const overlayVM = new ViewModel();
this.memberOverlayComponent = new MemberCardOverlayComponent(overlayContainer, overlayVM, this.eventBus);
this.memberOverlayComponent.mount();
}
// Convert node data to member data format
const memberData = {
ip: nodeData.ip,
hostname: nodeData.hostname,
status: this.normalizeStatus(nodeData.status),
latency: nodeData.latency,
labels: nodeData.resources || {}
};
this.memberOverlayComponent.show(memberData);
}
// Normalize status from topology format to member card format
normalizeStatus(status) {
if (!status) return 'unknown';
const normalized = status.toLowerCase();
switch (normalized) {
case 'active':
return 'active';
case 'inactive':
return 'inactive';
case 'dead':
return 'offline';
default:
return 'unknown';
}
}
// Override render method to display the graph
render() {
console.log('TopologyGraphComponent: render called');
if (!this.isInitialized) {
console.log('TopologyGraphComponent: Not initialized yet, skipping render');
return;
}
const nodes = this.viewModel.get('nodes');
const links = this.viewModel.get('links');
if (nodes && nodes.length > 0) {
console.log('TopologyGraphComponent: Rendering graph with data');
this.renderGraph();
} else {
console.log('TopologyGraphComponent: No data available, showing loading state');
this.handleLoadingState(true);
}
}
unmount() {
// Clean up resize listener
if (this.resizeHandler) {
window.removeEventListener('resize', this.resizeHandler);
this.resizeHandler = null;
}
// Clear resize timeout
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = null;
}
// Call parent unmount
super.unmount();
}
}
// Member Card Overlay Component for displaying member details in topology view
class MemberCardOverlayComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
this.isVisible = false;
this.currentMember = null;
}
mount() {
super.mount();
this.setupEventListeners();
}
setupEventListeners() {
// Close overlay when clicking outside or pressing escape
this.addEventListener(this.container, 'click', (e) => {
if (!this.isVisible) return;
// Only close when clicking on the backdrop, not inside the dialog content
if (e.target === this.container) {
this.hide();
}
});
this.addEventListener(document, 'keydown', (e) => {
if (e.key === 'Escape' && this.isVisible) {
this.hide();
}
});
}
show(memberData) {
this.currentMember = memberData;
this.isVisible = true;
const memberCardHTML = this.renderMemberCard(memberData);
this.setHTML('', memberCardHTML);
// Add visible class for animation
setTimeout(() => {
this.container.classList.add('visible');
}, 10);
// Setup member card interactions
this.setupMemberCardInteractions();
}
hide() {
this.isVisible = false;
this.container.classList.remove('visible');
this.currentMember = null;
}
renderMemberCard(member) {
const statusClass = member.status === 'active' ? 'status-online' :
member.status === 'inactive' ? 'status-inactive' : 'status-offline';
const statusText = member.status === 'active' ? 'Online' :
member.status === 'inactive' ? 'Inactive' :
member.status === 'offline' ? 'Offline' : 'Unknown';
const statusIcon = member.status === 'active' ? '🟢' :
member.status === 'inactive' ? '🟠' : '🔴';
return `
<div class="member-overlay-content">
<div class="member-overlay-header">
<div class="member-overlay-title">
<h3>Member Details</h3>
</div>
<button class="member-overlay-close" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<div class="member-overlay-body">
<div class="member-card expanded" data-member-ip="${member.ip}">
<div class="member-header">
<div class="member-info">
<div class="member-name">${member.hostname || 'Unknown Device'}</div>
<div class="member-ip">${member.ip || 'No IP'}</div>
<div class="member-status ${statusClass}">
${statusIcon} ${statusText}
</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 class="member-labels" style="display: none;">
<!-- Labels will be populated dynamically from node status API -->
</div>
</div>
</div>
<div class="member-details">
<div class="loading-details">Loading detailed information...</div>
</div>
</div>
</div>
</div>
`;
}
setupMemberCardInteractions() {
// Close button
const closeBtn = this.findElement('.member-overlay-close');
if (closeBtn) {
this.addEventListener(closeBtn, 'click', () => {
this.hide();
});
}
// Setup member card expansion - automatically expand when shown
setTimeout(async () => {
const memberCard = this.findElement('.member-card');
if (memberCard) {
const memberDetails = memberCard.querySelector('.member-details');
const memberIp = memberCard.dataset.memberIp;
// Automatically expand the card to show details
await this.expandCard(memberCard, memberIp, memberDetails);
}
}, 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);
// Update the labels in the member header with the actual node status data
const nodeStatus = nodeDetailsVM.get('nodeStatus');
if (nodeStatus && nodeStatus.labels) {
const labelsContainer = card.querySelector('.member-labels');
if (labelsContainer) {
// Update existing labels container and show it
labelsContainer.innerHTML = Object.entries(nodeStatus.labels)
.map(([key, value]) => `<span class="label-chip">${key}: ${value}</span>`)
.join('');
labelsContainer.style.display = 'block';
} else {
// Create new labels container if it doesn't exist
const memberInfo = card.querySelector('.member-info');
if (memberInfo) {
const labelsDiv = document.createElement('div');
labelsDiv.className = 'member-labels';
labelsDiv.innerHTML = Object.entries(nodeStatus.labels)
.map(([key, value]) => `<span class="label-chip">${key}: ${value}</span>`)
.join('');
// Insert after latency
const latencyDiv = memberInfo.querySelector('.member-latency');
if (latencyDiv) {
latencyDiv.parentNode.insertBefore(labelsDiv, latencyDiv.nextSibling);
}
}
}
}
// Mount the component
nodeDetailsComponent.mount();
// Update UI
card.classList.add('expanded');
} catch (error) {
console.error('Failed to expand member card:', error);
// Still show the UI even if details fail to load
card.classList.add('expanded');
const details = card.querySelector('.member-details');
if (details) {
details.innerHTML = '<div class="error">Failed to load node details</div>';
}
}
}
}