// 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
\n
Loading cluster members...
\n
\n `);
// Set up a loading completion check
this.checkLoadingCompletion();
} else {
console.log('ClusterMembersComponent: Loading completed, checking if we need to render');
// When loading completes, check if we have data to render
this.handleLoadingCompletion();
}
}
// Check if loading has completed and handle accordingly
handleLoadingCompletion() {
const members = this.viewModel.get('members');
const error = this.viewModel.get('error');
const isLoading = this.viewModel.get('isLoading');
console.log('ClusterMembersComponent: Handling loading completion:', { members, error, isLoading });
if (error) {
console.log('ClusterMembersComponent: Loading completed with error, showing error state');
this.showErrorState(error);
} else if (members && members.length > 0) {
console.log('ClusterMembersComponent: Loading completed with data, rendering members');
this.renderMembers(members);
} else if (!isLoading) {
console.log('ClusterMembersComponent: Loading completed but no data, showing empty state');
this.showEmptyState();
}
}
// Set up a check to ensure loading completion is handled
checkLoadingCompletion() {
// Clear any existing completion check
if (this.loadingCompletionCheck) {
clearTimeout(this.loadingCompletionCheck);
}
// Set up a completion check that runs after a short delay
this.loadingCompletionCheck = setTimeout(() => {
const isLoading = this.viewModel.get('isLoading');
if (!isLoading) {
console.log('ClusterMembersComponent: Loading completion check triggered');
this.handleLoadingCompletion();
}
}, 1000); // Check after 1 second
}
// Handle error state update
handleErrorUpdate(error) {
if (error) {
this.showErrorState(error);
}
}
// Check if we should preserve UI state during update
shouldPreserveState(newMembers, previousMembers) {
if (!previousMembers || !Array.isArray(previousMembers)) return false;
if (!Array.isArray(newMembers)) return false;
// If member count changed, we need to re-render
if (newMembers.length !== previousMembers.length) return false;
// Check if member IPs are the same (same nodes)
const newIps = new Set(newMembers.map(m => m.ip));
const prevIps = new Set(previousMembers.map(m => m.ip));
// If IPs are the same, we can preserve state
return newIps.size === prevIps.size &&
[...newIps].every(ip => prevIps.has(ip));
}
// Check if we should skip rendering during view switches
shouldSkipRender() {
// Skip rendering if we're in the middle of a view switch
const isViewSwitching = document.querySelectorAll('.view-content.active').length === 0;
if (isViewSwitching) {
console.log('ClusterMembersComponent: View switching in progress, skipping render');
return true;
}
// Skip rendering if the component is not visible
const isVisible = this.container.style.display !== 'none' &&
this.container.style.opacity !== '0' &&
this.container.classList.contains('active');
if (!isVisible) {
console.log('ClusterMembersComponent: Component not visible, skipping render');
return true;
}
return false;
}
// Update members partially to preserve UI state
updateMembersPartially(newMembers, previousMembers) {
console.log('ClusterMembersComponent: Performing partial update to preserve UI state');
// Update only the data that changed, preserving expanded states and active tabs
newMembers.forEach((newMember, index) => {
const prevMember = previousMembers[index];
if (prevMember && this.hasMemberChanged(newMember, prevMember)) {
this.updateMemberCard(newMember, index);
}
});
}
// Check if a specific member has changed
hasMemberChanged(newMember, prevMember) {
return newMember.status !== prevMember.status ||
newMember.latency !== prevMember.latency ||
newMember.hostname !== prevMember.hostname;
}
// Update a specific member card without re-rendering the entire component
updateMemberCard(member, index) {
const card = this.findElement(`[data-member-ip="${member.ip}"]`);
if (!card) return;
// Update status
const statusElement = card.querySelector('.member-status');
if (statusElement) {
const statusClass = member.status === 'active' ? 'status-online' : 'status-offline';
const 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) {
console.log('ClusterMembersComponent: Render already in progress, skipping');
return;
}
// Check if we should skip rendering during view switches
if (this.shouldSkipRender()) {
return;
}
this.renderInProgress = true;
try {
console.log('ClusterMembersComponent: render() called');
console.log('ClusterMembersComponent: Container element:', this.container);
console.log('ClusterMembersComponent: Is mounted:', this.isMounted);
const members = this.viewModel.get('members');
const isLoading = this.viewModel.get('isLoading');
const error = this.viewModel.get('error');
console.log('ClusterMembersComponent: render data:', { members, isLoading, error });
if (isLoading) {
console.log('ClusterMembersComponent: Showing loading state');
this.showLoadingState();
return;
}
if (error) {
console.log('ClusterMembersComponent: Showing error state');
this.showErrorState(error);
return;
}
if (!members || members.length === 0) {
console.log('ClusterMembersComponent: Showing empty state');
this.showEmptyState();
return;
}
console.log('ClusterMembersComponent: Rendering members:', members);
this.renderMembers(members);
} finally {
this.renderInProgress = false;
}
}
// Show loading state
showLoadingState() {
console.log('ClusterMembersComponent: showLoadingState() called');
this.renderLoading(`
Loading cluster members...
`);
}
// Show error state
showErrorState(error) {
console.log('ClusterMembersComponent: showErrorState() called with error:', error);
this.renderError(`Error loading cluster members: ${error}`);
}
// Show empty state
showEmptyState() {
console.log('ClusterMembersComponent: showEmptyState() called');
this.renderEmpty(`
🌐
No cluster members found
The cluster might be empty or not yet discovered
`);
}
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 `
Loading detailed information...
`;
}).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 = '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) {
console.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');
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('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) {
// Always start with 'status' tab, don't restore previous state
const activeTab = 'status';
console.log('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 `${method}${spacer}${uri} `;
}).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) => `
${p.name}${p.required ? ' *' : ''}
${ (Array.isArray(p.values) && p.values.length > 1)
? `${p.values.map(v => `${v} `).join('')} `
: ` `
}
`).join('')}
`
: 'No parameters
';
return `
`;
}).join('');
// Attach events after render in setupCapabilitiesEvents()
setTimeout(() => this.setupCapabilitiesEvents(), 0);
return `
Capability
${selectorOptions}
${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 `
`;
}
setupTabs() {
console.log('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);
console.log(`NodeDetailsComponent: Active tab updated to '${newTab}'`);
}
setupFirmwareUpload() {
const uploadBtn = this.findElement('.upload-btn[data-action="select-file"]');
if (uploadBtn) {
this.addEventListener(uploadBtn, 'click', (e) => {
e.stopPropagation();
const fileInput = this.findElement('#firmware-file');
if (fileInput) {
fileInput.click();
}
});
// Set up file input change handler
const fileInput = this.findElement('#firmware-file');
if (fileInput) {
this.addEventListener(fileInput, 'change', async (e) => {
e.stopPropagation();
const file = e.target.files[0];
if (file) {
await this.uploadFirmware(file);
}
});
}
}
}
async uploadFirmware(file) {
const uploadStatus = this.findElement('#upload-status');
const uploadBtn = this.findElement('.upload-btn');
const originalText = uploadBtn.textContent;
try {
// Show upload status
uploadStatus.style.display = 'block';
uploadStatus.innerHTML = `
📤 Uploading ${file.name}...
Size: ${(file.size / 1024).toFixed(1)}KB
`;
// Disable upload button
uploadBtn.disabled = true;
uploadBtn.textContent = '⏳ Uploading...';
// Get the member IP from the card
const memberCard = this.container.closest('.member-card');
const memberIp = memberCard.dataset.memberIp;
if (!memberIp) {
throw new Error('Could not determine target node IP address');
}
// Upload firmware
const result = await this.viewModel.uploadFirmware(file, memberIp);
// Show success
uploadStatus.innerHTML = `
✅ Firmware uploaded successfully!
Node: ${memberIp}
Size: ${(file.size / 1024).toFixed(1)}KB
`;
console.log('Firmware upload successful:', result);
} catch (error) {
console.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);
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 = `
${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');
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 = 'Select a node... ';
// 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 = ['Select a label... ']
.concat(labels.filter(l => !selected.has(l)).map(l => `${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);
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 = `
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 = '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() {
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 `
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) {
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 = 'Failed to load node details
';
}
}
}
}