diff --git a/public/scripts/components.js b/public/scripts/components.js
deleted file mode 100644
index dea26ac..0000000
--- a/public/scripts/components.js
+++ /dev/null
@@ -1,3125 +0,0 @@
-// 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) {
- logger.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);
-
- logger.debug('ClusterMembersComponent: Constructor called');
- logger.debug('ClusterMembersComponent: Container:', container);
- logger.debug('ClusterMembersComponent: Container ID:', container?.id);
- logger.debug('ClusterMembersComponent: Container innerHTML:', container?.innerHTML);
-
- // Track if we're in the middle of a render operation
- this.renderInProgress = false;
- this.lastRenderData = null;
-
- // Ensure initial render happens even if no data
- setTimeout(() => {
- if (this.isMounted && !this.renderInProgress) {
- logger.debug('ClusterMembersComponent: Performing initial render check');
- this.render();
- }
- }, 200);
- }
-
- mount() {
- logger.debug('ClusterMembersComponent: Starting mount...');
- super.mount();
-
- // Show loading state immediately when mounted
- logger.debug('ClusterMembersComponent: Showing initial loading state');
- this.showLoadingState();
-
- // Set up loading timeout safeguard
- this.setupLoadingTimeout();
-
- logger.debug('ClusterMembersComponent: Mounted successfully');
- }
-
- // Setup loading timeout safeguard to prevent getting stuck in loading state
- setupLoadingTimeout() {
- this.loadingTimeout = setTimeout(() => {
- const isLoading = this.viewModel.get('isLoading');
- if (isLoading) {
- logger.warn('ClusterMembersComponent: Loading timeout reached, forcing render check');
- this.forceRenderCheck();
- }
- }, 10000); // 10 second timeout
- }
-
- // Force a render check when loading gets stuck
- forceRenderCheck() {
- logger.debug('ClusterMembersComponent: Force render check called');
- const members = this.viewModel.get('members');
- const error = this.viewModel.get('error');
- const isLoading = this.viewModel.get('isLoading');
-
- logger.debug('ClusterMembersComponent: Force render check state:', { members, error, isLoading });
-
- if (error) {
- this.showErrorState(error);
- } else if (members && members.length > 0) {
- this.renderMembers(members);
- } else if (!isLoading) {
- this.showEmptyState();
- }
- }
-
- setupEventListeners() {
- logger.debug('ClusterMembersComponent: Setting up event listeners...');
- // Note: Refresh button is now handled by ClusterViewComponent
- // since it's in the cluster header, not in the members container
- }
-
- setupViewModelListeners() {
- logger.debug('ClusterMembersComponent: Setting up view model listeners...');
- // Listen to cluster members changes with change detection
- this.subscribeToProperty('members', this.handleMembersUpdate.bind(this));
- this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this));
- this.subscribeToProperty('error', this.handleErrorUpdate.bind(this));
- logger.debug('ClusterMembersComponent: View model listeners set up');
- }
-
- // Handle members update with state preservation
- handleMembersUpdate(newMembers, previousMembers) {
- logger.debug('ClusterMembersComponent: Members updated:', { newMembers, previousMembers });
-
- // Prevent multiple simultaneous renders
- if (this.renderInProgress) {
- logger.debug('ClusterMembersComponent: Render already in progress, skipping update');
- return;
- }
-
- // Check if we're currently loading - if so, let the loading handler deal with it
- const isLoading = this.viewModel.get('isLoading');
- if (isLoading) {
- logger.debug('ClusterMembersComponent: Currently loading, skipping members update (will be handled by loading completion)');
- return;
- }
-
- // On first load (no previous members), always render
- if (!previousMembers || !Array.isArray(previousMembers) || previousMembers.length === 0) {
- logger.debug('ClusterMembersComponent: First load or no previous members, performing full render');
- this.render();
- return;
- }
-
- if (this.shouldPreserveState(newMembers, previousMembers)) {
- // Perform partial update to preserve UI state
- logger.debug('ClusterMembersComponent: Preserving state, performing partial update');
- this.updateMembersPartially(newMembers, previousMembers);
- } else {
- // Full re-render if structure changed significantly
- logger.debug('ClusterMembersComponent: Structure changed, performing full re-render');
- this.render();
- }
- }
-
- // Handle loading state update
- handleLoadingUpdate(isLoading) {
- logger.debug('ClusterMembersComponent: Loading state changed:', isLoading);
-
- if (isLoading) {
- logger.debug('ClusterMembersComponent: Showing loading state');
- this.renderLoading(`\n
\n
Loading cluster members...
\n
\n `);
-
- // Set up a loading completion check
- this.checkLoadingCompletion();
- } else {
- logger.debug('ClusterMembersComponent: Loading completed, checking if we need to render');
- // When loading completes, check if we have data to render
- this.handleLoadingCompletion();
- }
- }
-
- // Check if loading has completed and handle accordingly
- handleLoadingCompletion() {
- const members = this.viewModel.get('members');
- const error = this.viewModel.get('error');
- const isLoading = this.viewModel.get('isLoading');
-
- logger.debug('ClusterMembersComponent: Handling loading completion:', { members, error, isLoading });
-
- if (error) {
- logger.debug('ClusterMembersComponent: Loading completed with error, showing error state');
- this.showErrorState(error);
- } else if (members && members.length > 0) {
- logger.debug('ClusterMembersComponent: Loading completed with data, rendering members');
- this.renderMembers(members);
- } else if (!isLoading) {
- logger.debug('ClusterMembersComponent: Loading completed but no data, showing empty state');
- this.showEmptyState();
- }
- }
-
- // Set up a check to ensure loading completion is handled
- checkLoadingCompletion() {
- // Clear any existing completion check
- if (this.loadingCompletionCheck) {
- clearTimeout(this.loadingCompletionCheck);
- }
-
- // Set up a completion check that runs after a short delay
- this.loadingCompletionCheck = setTimeout(() => {
- const isLoading = this.viewModel.get('isLoading');
- if (!isLoading) {
- logger.debug('ClusterMembersComponent: Loading completion check triggered');
- this.handleLoadingCompletion();
- }
- }, 1000); // Check after 1 second
- }
-
- // Handle error state update
- handleErrorUpdate(error) {
- if (error) {
- this.showErrorState(error);
- }
- }
-
- // Check if we should 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() {
- // Rely on lifecycle flags controlled by App
- if (!this.isMounted || this.isPaused) {
- logger.debug('ClusterMembersComponent: Not mounted or paused, skipping render');
- return true;
- }
- return false;
- }
-
- // Update members partially to preserve UI state
- updateMembersPartially(newMembers, previousMembers) {
- logger.debug('ClusterMembersComponent: Performing partial update to preserve UI state');
-
- // Build previous map by IP for stable diffs
- const prevByIp = new Map((previousMembers || []).map(m => [m.ip, m]));
- newMembers.forEach((newMember) => {
- const prevMember = prevByIp.get(newMember.ip);
- if (prevMember && this.hasMemberChanged(newMember, prevMember)) {
- this.updateMemberCard(newMember);
- }
- });
- }
-
- // Check if a specific member has changed
- hasMemberChanged(newMember, prevMember) {
- return newMember.status !== prevMember.status ||
- newMember.latency !== prevMember.latency ||
- newMember.hostname !== prevMember.hostname;
- }
-
- // Update a specific member card without re-rendering the entire component
- updateMemberCard(member) {
- const card = this.findElement(`[data-member-ip="${member.ip}"]`);
- if (!card) return;
-
- // Update status
- const statusElement = card.querySelector('.member-status');
- if (statusElement) {
- const statusClass = member.status === 'active' ? 'status-online' : 'status-offline';
- const statusIcon = member.status === 'active' ? '🟢' : '🔴';
-
- statusElement.className = `member-status ${statusClass}`;
- statusElement.innerHTML = `${statusIcon}`;
- }
-
- // Update latency
- const latencyElement = card.querySelector('.latency-value');
- if (latencyElement) {
- latencyElement.textContent = member.latency ? member.latency + 'ms' : 'N/A';
- }
-
- // Update hostname if changed
- const hostnameElement = card.querySelector('.member-hostname');
- if (hostnameElement && member.hostname !== hostnameElement.textContent) {
- hostnameElement.textContent = member.hostname || 'Unknown Device';
- }
- }
-
- render() {
- if (this.renderInProgress) {
- logger.debug('ClusterMembersComponent: Render already in progress, skipping');
- return;
- }
-
- // Check if we should skip rendering during view switches
- if (this.shouldSkipRender()) {
- return;
- }
-
- this.renderInProgress = true;
-
- try {
- logger.debug('ClusterMembersComponent: render() called');
- logger.debug('ClusterMembersComponent: Container element:', this.container);
- logger.debug('ClusterMembersComponent: Is mounted:', this.isMounted);
-
- const members = this.viewModel.get('members');
- const isLoading = this.viewModel.get('isLoading');
- const error = this.viewModel.get('error');
-
- logger.debug('ClusterMembersComponent: render data:', { members, isLoading, error });
-
- if (isLoading) {
- logger.debug('ClusterMembersComponent: Showing loading state');
- this.showLoadingState();
- return;
- }
-
- if (error) {
- logger.debug('ClusterMembersComponent: Showing error state');
- this.showErrorState(error);
- return;
- }
-
- if (!members || members.length === 0) {
- logger.debug('ClusterMembersComponent: Showing empty state');
- this.showEmptyState();
- return;
- }
-
- logger.debug('ClusterMembersComponent: Rendering members:', members);
- this.renderMembers(members);
-
- } finally {
- this.renderInProgress = false;
- }
- }
-
- // Show loading state
- showLoadingState() {
- logger.debug('ClusterMembersComponent: showLoadingState() called');
- this.renderLoading(`
-
-
Loading cluster members...
-
- `);
- }
-
- // Show error state
- showErrorState(error) {
- logger.debug('ClusterMembersComponent: showErrorState() called with error:', error);
- this.renderError(`Error loading cluster members: ${error}`);
- }
-
- // Show empty state
- showEmptyState() {
- logger.debug('ClusterMembersComponent: showEmptyState() called');
- this.renderEmpty(`
-
-
🌐
-
No cluster members found
-
- The cluster might be empty or not yet discovered
-
-
- `);
- }
-
- renderMembers(members) {
- logger.debug('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' ? '🟢' : '🔴';
-
- logger.debug('ClusterMembersComponent: Rendering member:', member);
-
- return `
-
-
-
-
Loading detailed information...
-
-
- `;
- }).join('');
-
- logger.debug('ClusterMembersComponent: Setting HTML, length:', membersHTML.length);
- this.setHTML('', membersHTML);
- logger.debug('ClusterMembersComponent: HTML set, setting up member cards...');
- this.setupMemberCards(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 = 'Loading detailed information...
';
-
- // 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) {
- logger.error('Failed to expand card:', error);
- memberDetails.innerHTML = `
-
- Error loading node details:
- ${error.message}
-
- `;
- }
- }
-
- collapseCard(card, expandIcon) {
- card.classList.remove('expanded');
- if (expandIcon) {
- expandIcon.classList.remove('expanded');
- }
- }
-
- setupTabs(container) {
- super.setupTabs(container, {
- onChange: (targetTab) => {
- const memberCard = container.closest('.member-card');
- if (memberCard) {
- const memberIp = memberCard.dataset.memberIp;
- this.viewModel.storeActiveTab(memberIp, targetTab);
- }
- }
- });
- }
-
- // Restore active tab state
- restoreActiveTab(container, activeTab) {
- const tabButtons = container.querySelectorAll('.tab-button');
- const tabContents = container.querySelectorAll('.tab-content');
-
- // Remove active class from all buttons and contents
- tabButtons.forEach(btn => btn.classList.remove('active'));
- tabContents.forEach(content => content.classList.remove('active'));
-
- // Add active class to the restored tab
- const activeButton = container.querySelector(`[data-tab="${activeTab}"]`);
- const activeContent = container.querySelector(`#${activeTab}-tab`);
-
- if (activeButton) activeButton.classList.add('active');
- if (activeContent) activeContent.classList.add('active');
- }
-
- // Note: handleRefresh method has been moved to ClusterViewComponent
- // since the refresh button is in the cluster header, not in the members container
-
- // Debug method to check component state
- debugState() {
- const members = this.viewModel.get('members');
- const isLoading = this.viewModel.get('isLoading');
- const error = this.viewModel.get('error');
- const expandedCards = this.viewModel.get('expandedCards');
- const activeTabs = this.viewModel.get('activeTabs');
-
- logger.debug('ClusterMembersComponent: Debug State:', {
- isMounted: this.isMounted,
- container: this.container,
- members: members,
- membersCount: members?.length || 0,
- isLoading: isLoading,
- error: error,
- expandedCardsCount: expandedCards?.size || 0,
- activeTabsCount: activeTabs?.size || 0,
- loadingTimeout: this.loadingTimeout
- });
-
- return { members, isLoading, error, expandedCards, activeTabs };
- }
-
- // Manual refresh method that bypasses potential state conflicts
- async manualRefresh() {
- logger.debug('ClusterMembersComponent: Manual refresh called');
-
- try {
- // Clear any existing loading state
- this.viewModel.set('isLoading', false);
- this.viewModel.set('error', null);
-
- // Force a fresh data load
- await this.viewModel.updateClusterMembers();
-
- logger.debug('ClusterMembersComponent: Manual refresh completed');
- } catch (error) {
- logger.error('ClusterMembersComponent: Manual refresh failed:', error);
- this.showErrorState(error.message);
- }
- }
-
- unmount() {
- if (!this.isMounted) return;
-
- this.isMounted = false;
-
- // Clear any pending timeouts
- if (this.loadingTimeout) {
- clearTimeout(this.loadingTimeout);
- this.loadingTimeout = null;
- }
-
- if (this.loadingCompletionCheck) {
- clearTimeout(this.loadingCompletionCheck);
- this.loadingCompletionCheck = null;
- }
-
- // Clear any pending render operations
- this.renderInProgress = false;
-
- this.cleanupEventListeners();
- this.cleanupViewModelListeners();
-
- logger.debug(`${this.constructor.name} unmounted`);
- }
-
- // Override pause method to handle timeouts and operations
- onPause() {
- logger.debug('ClusterMembersComponent: Pausing...');
-
- // Clear any pending timeouts
- if (this.loadingTimeout) {
- clearTimeout(this.loadingTimeout);
- this.loadingTimeout = null;
- }
-
- if (this.loadingCompletionCheck) {
- clearTimeout(this.loadingCompletionCheck);
- this.loadingCompletionCheck = null;
- }
-
- // Mark as paused to prevent new operations
- this.isPaused = true;
- }
-
- // Override resume method to restore functionality
- onResume() {
- logger.debug('ClusterMembersComponent: Resuming...');
-
- this.isPaused = false;
-
- // Re-setup loading timeout if needed
- if (!this.loadingTimeout) {
- this.setupLoadingTimeout();
- }
-
- // Check if we need to handle any pending operations
- this.checkPendingOperations();
- }
-
- // Check for any operations that need to be handled after resume
- checkPendingOperations() {
- const isLoading = this.viewModel.get('isLoading');
- const members = this.viewModel.get('members');
-
- // If we were loading and it completed while paused, handle the completion
- if (!isLoading && members && members.length > 0) {
- logger.debug('ClusterMembersComponent: Handling pending loading completion after resume');
- this.handleLoadingCompletion();
- }
- }
-
- // Override to determine if re-render is needed on resume
- shouldRenderOnResume() {
- // Don't re-render on resume - maintain current state
- return false;
- }
-}
-
-// 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('Loading detailed information...
');
- }
- }
-
- // 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('Loading detailed information...
');
- return;
- }
-
- if (error) {
- this.renderError(`Error loading node details: ${error}`);
- return;
- }
-
- if (!nodeStatus) {
- this.renderEmpty('No node status available
');
- return;
- }
-
- this.renderNodeDetails(nodeStatus, tasks, capabilities);
- }
-
- renderNodeDetails(nodeStatus, tasks, capabilities) {
- // Use persisted active tab from the view model, default to 'status'
- const activeTab = (this.viewModel && typeof this.viewModel.get === 'function' && this.viewModel.get('activeTab')) || 'status';
- logger.debug('NodeDetailsComponent: Rendering with activeTab:', activeTab);
-
- const html = `
-
-
-
-
-
- Free Heap:
- ${Math.round(nodeStatus.freeHeap / 1024)}KB
-
-
- Chip ID:
- ${nodeStatus.chipId}
-
-
- SDK Version:
- ${nodeStatus.sdkVersion}
-
-
- CPU Frequency:
- ${nodeStatus.cpuFreqMHz}MHz
-
-
- Flash Size:
- ${Math.round(nodeStatus.flashChipSize / 1024)}KB
-
-
-
-
- ${nodeStatus.api ? nodeStatus.api.map(endpoint =>
- `
${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}
`
- ).join('') : '
No API endpoints available
'}
-
-
-
- ${this.renderCapabilitiesTab(capabilities)}
-
-
-
- ${this.renderTasksTab(tasks)}
-
-
-
- ${this.renderFirmwareTab()}
-
-
- `;
-
- this.setHTML('', html);
- this.setupTabs();
- // Restore last active tab from view model if available
- const restored = this.viewModel && typeof this.viewModel.get === 'function' ? this.viewModel.get('activeTab') : null;
- if (restored) {
- this.setActiveTab(restored);
- }
- this.setupFirmwareUpload();
- }
-
- renderCapabilitiesTab(capabilities) {
- if (!capabilities || !Array.isArray(capabilities.endpoints) || capabilities.endpoints.length === 0) {
- return `
-
-
🧩 No capabilities reported
-
This node did not return any capabilities
-
- `;
- }
-
- // 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 = ' '.repeat(padCount);
- return ``;
- }).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
- ? `${ep.params.map((p, pidx) => `
-
- `).join('')}
`
- : 'No parameters
';
- return `
-
- `;
- }).join('');
-
- // Attach events after render in setupCapabilitiesEvents()
- setTimeout(() => this.setupCapabilitiesEvents(), 0);
-
- return `
-
-
-
-
- ${items}
- `;
- }
-
- 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 = `
-
-
❌ Missing required fields: ${missing.map(m => this.escapeHtml(m.name)).join(', ')}
-
- `;
- return;
- }
-
- // Show loading state
- resultEl.style.display = 'block';
- resultEl.innerHTML = 'Calling endpoint...
';
-
- 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 = `
-
-
✅ Success
-
${this.escapeHtml(pretty)}
-
- `;
- } catch (err) {
- resultEl.innerHTML = `
-
-
❌ Error: ${this.escapeHtml(err.message || 'Request failed')}
-
- `;
- }
- });
- });
- }
-
- escapeHtml(str) {
- return String(str)
- .replace(/&/g, '&')
- .replace(//g, '>');
- }
-
- renderTasksTab(tasks) {
- const summary = this.viewModel.get('tasksSummary');
- if (tasks && tasks.length > 0) {
- const summaryHTML = summary ? `
-
-
-
📋
-
-
Tasks Overview
-
System task management and monitoring
-
-
-
-
-
${summary.totalTasks ?? tasks.length}
-
Total
-
-
-
${summary.activeTasks ?? tasks.filter(t => t.running).length}
-
Active
-
-
-
${(summary.totalTasks ?? tasks.length) - (summary.activeTasks ?? tasks.filter(t => t.running).length)}
-
Stopped
-
-
-
- ` : '';
- const tasksHTML = tasks.map(task => `
-
-
-
- Interval: ${task.interval}ms
- ${task.enabled ? '🟢 Enabled' : '🔴 Disabled'}
-
-
- `).join('');
-
- return `
- ${summaryHTML}
- ${tasksHTML}
- `;
- } else {
- const total = summary?.totalTasks ?? 0;
- const active = summary?.activeTasks ?? 0;
- return `
-
-
-
📋
-
-
Tasks Overview
-
${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
-
-
-
-
-
-
-
${total - active}
-
Stopped
-
-
-
-
-
📋 No active tasks found
-
- ${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
-
-
- `;
- }
- }
-
- renderFirmwareTab() {
- return `
-
-
Firmware Update
-
-
-
-
Select a .bin or .hex file to upload
-
-
-
- `;
- }
-
- setupTabs() {
- logger.debug('NodeDetailsComponent: Setting up tabs');
- super.setupTabs(this.container, {
- onChange: (tab) => {
- // Persist active tab in the view model for restoration
- if (this.viewModel && typeof this.viewModel.setActiveTab === 'function') {
- this.viewModel.setActiveTab(tab);
- }
- }
- });
- }
-
- // Update active tab without full re-render
- updateActiveTab(newTab, previousTab = null) {
- this.setActiveTab(newTab);
- logger.debug(`NodeDetailsComponent: Active tab updated to '${newTab}'`);
- }
-
- setupFirmwareUpload() {
- const uploadBtn = this.findElement('.upload-btn[data-action="select-file"]');
- if (uploadBtn) {
- this.addEventListener(uploadBtn, 'click', (e) => {
- e.stopPropagation();
- const fileInput = this.findElement('#firmware-file');
- if (fileInput) {
- fileInput.click();
- }
- });
-
- // Set up file input change handler
- const fileInput = this.findElement('#firmware-file');
- if (fileInput) {
- this.addEventListener(fileInput, 'change', async (e) => {
- e.stopPropagation();
- const file = e.target.files[0];
- if (file) {
- await this.uploadFirmware(file);
- }
- });
- }
- }
- }
-
- async uploadFirmware(file) {
- const uploadStatus = this.findElement('#upload-status');
- const uploadBtn = this.findElement('.upload-btn');
- const originalText = uploadBtn.textContent;
-
- try {
- // Show upload status
- uploadStatus.style.display = 'block';
- uploadStatus.innerHTML = `
-
-
📤 Uploading ${file.name}...
-
Size: ${(file.size / 1024).toFixed(1)}KB
-
- `;
-
- // Disable upload button
- uploadBtn.disabled = true;
- uploadBtn.textContent = '⏳ Uploading...';
-
- // Get the member IP from the card
- const memberCard = this.container.closest('.member-card');
- const memberIp = memberCard.dataset.memberIp;
-
- if (!memberIp) {
- throw new Error('Could not determine target node IP address');
- }
-
- // Upload firmware
- const result = await this.viewModel.uploadFirmware(file, memberIp);
-
- // Show success
- uploadStatus.innerHTML = `
-
-
✅ Firmware uploaded successfully!
-
Node: ${memberIp}
-
Size: ${(file.size / 1024).toFixed(1)}KB
-
- `;
-
- logger.debug('Firmware upload successful:', result);
-
- } catch (error) {
- logger.error('Firmware upload failed:', error);
-
- // Show error
- uploadStatus.innerHTML = `
-
-
❌ Upload failed: ${error.message}
-
- `;
- } 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);
-
- logger.debug('FirmwareComponent: Constructor called');
- logger.debug('FirmwareComponent: Container:', container);
- logger.debug('FirmwareComponent: Container ID:', container?.id);
-
- // Check if the dropdown exists in the container
- if (container) {
- const dropdown = container.querySelector('#specific-node-select');
- logger.debug('FirmwareComponent: Dropdown found in constructor:', !!dropdown);
- if (dropdown) {
- logger.debug('FirmwareComponent: Dropdown tagName:', dropdown.tagName);
- logger.debug('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');
- logger.debug('FirmwareComponent: setupEventListeners - specificNodeSelect found:', !!specificNodeSelect);
- if (specificNodeSelect) {
- logger.debug('FirmwareComponent: specificNodeSelect element:', specificNodeSelect);
- logger.debug('FirmwareComponent: specificNodeSelect tagName:', specificNodeSelect.tagName);
- logger.debug('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);
- logger.debug('FirmwareComponent: Event listener added to specificNodeSelect');
- } else {
- logger.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();
-
- logger.debug('FirmwareComponent: Mounting...');
-
- // Check if the dropdown exists when mounted
- const dropdown = this.findElement('#specific-node-select');
- logger.debug('FirmwareComponent: Mount - dropdown found:', !!dropdown);
- if (dropdown) {
- logger.debug('FirmwareComponent: Mount - dropdown tagName:', dropdown.tagName);
- logger.debug('FirmwareComponent: Mount - dropdown id:', dropdown.id);
- logger.debug('FirmwareComponent: Mount - dropdown innerHTML:', dropdown.innerHTML);
- }
-
- // Initialize target visibility and label list on first mount
- try {
- this.updateTargetVisibility();
- this.populateLabelSelect();
- this.updateAffectedNodesPreview();
- } catch (e) {
- logger.warn('FirmwareComponent: Initialization after mount failed:', e);
- }
-
- logger.debug('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;
- logger.debug('FirmwareComponent: handleNodeSelect called with nodeIp:', nodeIp);
- logger.debug('Event:', event);
- logger.debug('Event target:', event.target);
- logger.debug('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) {
- logger.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) {
- logger.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) {
- logger.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) {
- logger.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) {
- logger.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 = `
-
-
-
- ${nodes.map(node => `
-
-
- ${node.hostname || node.ip}
- ${node.ip}
-
-
Pending...
-
-
- `).join('')}
-
-
- `;
-
- 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 = 'Status: Upload completed successfully';
- } else if (successfulUploads === 0) {
- progressSummary.innerHTML = 'Status: Upload failed';
- }
- }
- }
- }
-
- 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 = `✅ Upload to ${results[0].hostname || results[0].nodeIp} completed successfully at ${new Date().toLocaleTimeString()}`;
- } else {
- progressHeader.textContent = `📤 Firmware Upload Failed`;
- progressSummary.innerHTML = `❌ Upload to ${results[0].hostname || results[0].nodeIp} failed at ${new Date().toLocaleTimeString()}`;
- }
- } else if (successCount === totalCount) {
- // Multi-node upload - all successful
- progressHeader.textContent = `📤 Firmware Upload Complete (${successCount}/${totalCount} Successful)`;
- progressSummary.innerHTML = `✅ All uploads completed successfully at ${new Date().toLocaleTimeString()}`;
- } else {
- // Multi-node upload - some failed
- progressHeader.textContent = `📤 Firmware Upload Results (${successCount}/${totalCount} Successful)`;
- progressSummary.innerHTML = `⚠️ Upload completed with ${totalCount - successCount} failure(s) at ${new Date().toLocaleTimeString()}`;
- }
- }
- }
-
- 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');
-
- logger.debug('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) {
- logger.warn('FirmwareComponent: populateNodeSelect - select element not found');
- return;
- }
-
- if (select.tagName !== 'SELECT') {
- logger.warn('FirmwareComponent: populateNodeSelect - element is not a SELECT:', select.tagName);
- return;
- }
-
- logger.debug('FirmwareComponent: populateNodeSelect called');
- logger.debug('FirmwareComponent: Select element:', select);
- logger.debug('FirmwareComponent: Available nodes:', this.viewModel.get('availableNodes'));
-
- // Clear existing options
- select.innerHTML = '';
-
- // 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);
-
- logger.debug('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);
-
- logger.debug('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 = ['']
- .concat(labels.filter(l => !selected.has(l)).map(l => ``));
- 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 => `
-
- ${l}
-
-
- `).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 = `No nodes match the selected labels
`;
- return;
- }
- const html = `
-
-
-
- ${nodes.map(n => `
-
-
${n.hostname || n.ip}${n.ip}
-
- `).join('')}
-
-
`;
- container.innerHTML = html;
- }
-}
-
-// Cluster View Component
-class ClusterViewComponent extends Component {
- constructor(container, viewModel, eventBus) {
- super(container, viewModel, eventBus);
-
- logger.debug('ClusterViewComponent: Constructor called');
- logger.debug('ClusterViewComponent: Container:', container);
- logger.debug('ClusterViewComponent: Container ID:', container?.id);
-
- // Find elements for sub-components
- const primaryNodeContainer = this.findElement('.primary-node-info');
- const clusterMembersContainer = this.findElement('#cluster-members-container');
-
- logger.debug('ClusterViewComponent: Primary node container:', primaryNodeContainer);
- logger.debug('ClusterViewComponent: Cluster members container:', clusterMembersContainer);
- logger.debug('ClusterViewComponent: Cluster members container ID:', clusterMembersContainer?.id);
- logger.debug('ClusterViewComponent: Cluster members container innerHTML:', clusterMembersContainer?.innerHTML);
-
- // Create sub-components
- this.primaryNodeComponent = new PrimaryNodeComponent(
- primaryNodeContainer,
- viewModel,
- eventBus
- );
-
- this.clusterMembersComponent = new ClusterMembersComponent(
- clusterMembersContainer,
- viewModel,
- eventBus
- );
-
- logger.debug('ClusterViewComponent: Sub-components created');
-
- // Track if we've already loaded data to prevent unnecessary reloads
- this.dataLoaded = false;
- }
-
- mount() {
- logger.debug('ClusterViewComponent: Mounting...');
- super.mount();
-
- logger.debug('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) {
- logger.debug('ClusterViewComponent: Starting initial data load...');
- // Initial data load - ensure it happens after mounting
- setTimeout(() => {
- this.viewModel.updateClusterMembers().then(() => {
- this.dataLoaded = true;
- }).catch(error => {
- logger.error('ClusterViewComponent: Failed to load initial data:', error);
- });
- }, 100);
- } else {
- logger.debug('ClusterViewComponent: Data already loaded, skipping initial load');
- }
-
- // Set up periodic updates
- // this.setupPeriodicUpdates(); // Disabled automatic refresh
- logger.debug('ClusterViewComponent: Mounted successfully');
- }
-
- setupRefreshButton() {
- logger.debug('ClusterViewComponent: Setting up refresh button...');
-
- const refreshBtn = this.findElement('.refresh-btn');
- logger.debug('ClusterViewComponent: Found refresh button:', !!refreshBtn, refreshBtn);
-
- if (refreshBtn) {
- logger.debug('ClusterViewComponent: Adding click event listener to refresh button');
- this.addEventListener(refreshBtn, 'click', this.handleRefresh.bind(this));
- logger.debug('ClusterViewComponent: Event listener added successfully');
- } else {
- logger.error('ClusterViewComponent: Refresh button not found!');
- logger.debug('ClusterViewComponent: Container HTML:', this.container.innerHTML);
- logger.debug('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button'));
- }
- }
-
- async handleRefresh() {
- logger.debug('ClusterViewComponent: Refresh button clicked, performing full refresh...');
-
- // Get the refresh button and show loading state
- const refreshBtn = this.findElement('.refresh-btn');
- logger.debug('ClusterViewComponent: Found refresh button for loading state:', !!refreshBtn);
-
- if (refreshBtn) {
- const originalText = refreshBtn.innerHTML;
- logger.debug('ClusterViewComponent: Original button text:', originalText);
-
- refreshBtn.innerHTML = `
-
- Refreshing...
- `;
- refreshBtn.disabled = true;
-
- try {
- logger.debug('ClusterViewComponent: Starting cluster members update...');
- // Always perform a full refresh when user clicks refresh button
- await this.viewModel.updateClusterMembers();
- logger.debug('ClusterViewComponent: Cluster members update completed successfully');
- } catch (error) {
- logger.error('ClusterViewComponent: Error during refresh:', error);
- // Show error state
- if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) {
- this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed');
- }
- } finally {
- logger.debug('ClusterViewComponent: Restoring button state...');
- // Restore button state
- refreshBtn.innerHTML = originalText;
- refreshBtn.disabled = false;
- }
- } else {
- logger.warn('ClusterViewComponent: Refresh button not found, using fallback refresh');
- // Fallback if button not found
- try {
- await this.viewModel.updateClusterMembers();
- } catch (error) {
- logger.error('ClusterViewComponent: Fallback refresh failed:', error);
- if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) {
- this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed');
- }
- }
- }
- }
-
- unmount() {
- logger.debug('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();
- logger.debug('ClusterViewComponent: Unmounted');
- }
-
- // Override pause method to handle sub-components
- onPause() {
- logger.debug('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() {
- logger.debug('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);
-
- logger.debug('FirmwareViewComponent: Constructor called');
- logger.debug('FirmwareViewComponent: Container:', container);
-
- const firmwareContainer = this.findElement('#firmware-container');
- logger.debug('FirmwareViewComponent: Firmware container found:', !!firmwareContainer);
-
- this.firmwareComponent = new FirmwareComponent(
- firmwareContainer,
- viewModel,
- eventBus
- );
-
- logger.debug('FirmwareViewComponent: FirmwareComponent created');
- }
-
- mount() {
- super.mount();
-
- logger.debug('FirmwareViewComponent: Mounting...');
-
- // Mount sub-component
- this.firmwareComponent.mount();
-
- // Update available nodes
- this.updateAvailableNodes();
-
- logger.debug('FirmwareViewComponent: Mounted successfully');
- }
-
- unmount() {
- // Unmount sub-component
- if (this.firmwareComponent) {
- this.firmwareComponent.unmount();
- }
-
- super.unmount();
- }
-
- // Override pause method to handle sub-components
- onPause() {
- logger.debug('FirmwareViewComponent: Pausing...');
-
- // Pause sub-component
- if (this.firmwareComponent && this.firmwareComponent.isMounted) {
- this.firmwareComponent.pause();
- }
- }
-
- // Override resume method to handle sub-components
- onResume() {
- logger.debug('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 {
- logger.debug('FirmwareViewComponent: updateAvailableNodes called');
- const response = await window.apiClient.getClusterMembers();
- const nodes = response.members || [];
- logger.debug('FirmwareViewComponent: Got nodes:', nodes);
- this.viewModel.updateAvailableNodes(nodes);
- logger.debug('FirmwareViewComponent: Available nodes updated in view model');
- } catch (error) {
- logger.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);
- logger.debug('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);
-
- logger.debug('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;
-
- logger.debug('TopologyGraphComponent: Starting mount...');
- logger.debug('TopologyGraphComponent: isInitialized =', this.isInitialized);
-
- // Call initialize if not already done
- if (!this.isInitialized) {
- logger.debug('TopologyGraphComponent: Initializing during mount...');
- this.initialize().then(() => {
- logger.debug('TopologyGraphComponent: Initialization completed, calling completeMount...');
- // Complete mount after initialization
- this.completeMount();
- }).catch(error => {
- logger.error('TopologyGraphComponent: Initialization failed during mount:', error);
- // Still complete mount to prevent blocking
- this.completeMount();
- });
- } else {
- logger.debug('TopologyGraphComponent: Already initialized, calling completeMount directly...');
- this.completeMount();
- }
- }
-
- completeMount() {
- logger.debug('TopologyGraphComponent: completeMount called');
- this.isMounted = true;
- logger.debug('TopologyGraphComponent: Setting up event listeners...');
- this.setupEventListeners();
- logger.debug('TopologyGraphComponent: Setting up view model listeners...');
- this.setupViewModelListeners();
- logger.debug('TopologyGraphComponent: Calling render...');
- this.render();
-
- logger.debug('TopologyGraphComponent: Mounted successfully');
- }
-
- setupEventListeners() {
- logger.debug('TopologyGraphComponent: setupEventListeners called');
- logger.debug('TopologyGraphComponent: Container:', this.container);
- logger.debug('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
- logger.debug('TopologyGraphComponent: No event listeners needed (refresh button removed)');
- }
-
- setupViewModelListeners() {
- logger.debug('TopologyGraphComponent: setupViewModelListeners called');
- logger.debug('TopologyGraphComponent: isInitialized =', this.isInitialized);
-
- if (this.isInitialized) {
- // Component is already initialized, set up subscriptions immediately
- logger.debug('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
- logger.debug('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() {
- logger.debug('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) {
- logger.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
-
- logger.debug('TopologyGraphComponent: SVG setup completed');
- }
-
- // Ensure component is initialized
- async ensureInitialized() {
- if (!this.isInitialized) {
- logger.debug('TopologyGraphComponent: Ensuring initialization...');
- await this.initialize();
- }
- return this.isInitialized;
- }
-
- renderGraph() {
- try {
- // Check if component is initialized
- if (!this.isInitialized) {
- logger.debug('TopologyGraphComponent: Component not yet initialized, ensuring initialization...');
- this.ensureInitialized().then(() => {
- // Re-render after initialization
- this.renderGraph();
- }).catch(error => {
- logger.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) {
- logger.debug('TopologyGraphComponent: SVG not initialized yet, setting up SVG first');
- this.setupSVG();
- }
-
- if (!nodes || nodes.length === 0) {
- this.showNoData();
- return;
- }
-
- logger.debug('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()) {
- logger.debug('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) {
- logger.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() {
- logger.debug('TopologyGraphComponent: handleRefresh called');
-
- if (!this.isInitialized) {
- logger.debug('TopologyGraphComponent: Component not yet initialized, ensuring initialization...');
- this.ensureInitialized().then(() => {
- // Refresh after initialization
- this.viewModel.updateNetworkTopology();
- }).catch(error => {
- logger.error('TopologyGraphComponent: Failed to initialize for refresh:', error);
- });
- return;
- }
-
- logger.debug('TopologyGraphComponent: Calling updateNetworkTopology...');
- this.viewModel.updateNetworkTopology();
- }
-
- handleLoadingState(isLoading) {
- logger.debug('TopologyGraphComponent: handleLoadingState called with:', isLoading);
- const container = this.findElement('#topology-graph-container');
-
- if (isLoading) {
- container.innerHTML = 'Loading network topology...
';
- }
- }
-
- handleError() {
- const error = this.viewModel.get('error');
- if (error) {
- const container = this.findElement('#topology-graph-container');
- container.innerHTML = ``;
- }
- }
-
- showNoData() {
- const container = this.findElement('#topology-graph-container');
- container.innerHTML = '';
- }
-
- 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() {
- logger.debug('TopologyGraphComponent: render called');
- if (!this.isInitialized) {
- logger.debug('TopologyGraphComponent: Not initialized yet, skipping render');
- return;
- }
- const nodes = this.viewModel.get('nodes');
- const links = this.viewModel.get('links');
- if (nodes && nodes.length > 0) {
- logger.debug('TopologyGraphComponent: Rendering graph with data');
- this.renderGraph();
- } else {
- logger.debug('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 `
-
-
-
-
-
-
-
Loading detailed information...
-
-
-
-
- `;
- }
-
- 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) {
- // Find the labels container in the header
- const labelsContainer = document.querySelector('.member-overlay-header .member-labels');
- if (labelsContainer) {
- // Update existing labels container and show it
- labelsContainer.innerHTML = Object.entries(nodeStatus.labels)
- .map(([key, value]) => `${key}: ${value}`)
- .join('');
- labelsContainer.style.display = 'block';
- }
- }
-
- // Mount the component
- nodeDetailsComponent.mount();
-
- // Update UI
- card.classList.add('expanded');
-
- } catch (error) {
- logger.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 = 'Failed to load node details
';
- }
- }
- }
-}
\ No newline at end of file