refactor(components): split components.js into separate files and add loader; app waits for components before init
This commit is contained in:
@@ -1,13 +1,22 @@
|
||||
// Main SPORE UI Application
|
||||
|
||||
// Initialize the application when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
logger.debug('=== SPORE UI Application Initialization ===');
|
||||
|
||||
// Initialize the framework (but don't navigate yet)
|
||||
logger.debug('App: Creating framework instance...');
|
||||
const app = window.app;
|
||||
|
||||
// Wait for components to be ready (loader ensures constructors exist)
|
||||
try {
|
||||
if (typeof window.waitForComponentsReady === 'function') {
|
||||
await window.waitForComponentsReady();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('App: Components loader timeout; proceeding anyway');
|
||||
}
|
||||
|
||||
// Create view models
|
||||
logger.debug('App: Creating view models...');
|
||||
const clusterViewModel = new ClusterViewModel();
|
||||
|
||||
628
public/scripts/components/ClusterMembersComponent.js
Normal file
628
public/scripts/components/ClusterMembersComponent.js
Normal file
@@ -0,0 +1,628 @@
|
||||
// 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(`
|
||||
<div class="loading">
|
||||
<div>Loading cluster members...</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Set up a loading completion check
|
||||
this.checkLoadingCompletion();
|
||||
} else {
|
||||
logger.debug('ClusterMembersComponent: Loading completed, checking if we need to render');
|
||||
// When loading completes, check if we have data to render
|
||||
this.handleLoadingCompletion();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if loading has completed and handle accordingly
|
||||
handleLoadingCompletion() {
|
||||
const members = this.viewModel.get('members');
|
||||
const error = this.viewModel.get('error');
|
||||
const isLoading = this.viewModel.get('isLoading');
|
||||
|
||||
logger.debug('ClusterMembersComponent: Handling loading completion:', { members, error, isLoading });
|
||||
|
||||
if (error) {
|
||||
logger.debug('ClusterMembersComponent: Loading completed with error, showing error state');
|
||||
this.showErrorState(error);
|
||||
} else if (members && members.length > 0) {
|
||||
logger.debug('ClusterMembersComponent: Loading completed with data, rendering members');
|
||||
this.renderMembers(members);
|
||||
} else if (!isLoading) {
|
||||
logger.debug('ClusterMembersComponent: Loading completed but no data, showing empty state');
|
||||
this.showEmptyState();
|
||||
}
|
||||
}
|
||||
|
||||
// Set up a check to ensure loading completion is handled
|
||||
checkLoadingCompletion() {
|
||||
// Clear any existing completion check
|
||||
if (this.loadingCompletionCheck) {
|
||||
clearTimeout(this.loadingCompletionCheck);
|
||||
}
|
||||
|
||||
// Set up a completion check that runs after a short delay
|
||||
this.loadingCompletionCheck = setTimeout(() => {
|
||||
const isLoading = this.viewModel.get('isLoading');
|
||||
if (!isLoading) {
|
||||
logger.debug('ClusterMembersComponent: Loading completion check triggered');
|
||||
this.handleLoadingCompletion();
|
||||
}
|
||||
}, 1000); // Check after 1 second
|
||||
}
|
||||
|
||||
// Handle error state update
|
||||
handleErrorUpdate(error) {
|
||||
if (error) {
|
||||
this.showErrorState(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should 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(`
|
||||
<div class="loading">
|
||||
<div>Loading cluster members...</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
showErrorState(error) {
|
||||
logger.debug('ClusterMembersComponent: showErrorState() called with error:', error);
|
||||
this.renderError(`Error loading cluster members: ${error}`);
|
||||
}
|
||||
|
||||
// Show empty state
|
||||
showEmptyState() {
|
||||
logger.debug('ClusterMembersComponent: showEmptyState() called');
|
||||
this.renderEmpty(`
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🌐</div>
|
||||
<div>No cluster members found</div>
|
||||
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">
|
||||
The cluster might be empty or not yet discovered
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
renderMembers(members) {
|
||||
logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members');
|
||||
|
||||
const membersHTML = members.map(member => {
|
||||
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 `
|
||||
<div class="member-card" data-member-ip="${member.ip}">
|
||||
<div class="member-header">
|
||||
<div class="member-info">
|
||||
<div class="member-row-1">
|
||||
<div class="status-hostname-group">
|
||||
<div class="member-status ${statusClass}">
|
||||
${statusIcon}
|
||||
</div>
|
||||
<div class="member-hostname">${this.escapeHtml(member.hostname || 'Unknown Device')}</div>
|
||||
</div>
|
||||
<div class="member-ip">${this.escapeHtml(member.ip || 'No IP')}</div>
|
||||
<div class="member-latency">
|
||||
<span class="latency-label">Latency:</span>
|
||||
<span class="latency-value">${member.latency ? member.latency + 'ms' : 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
${member.labels && Object.keys(member.labels).length ? `
|
||||
<div class="member-row-2">
|
||||
<div class="member-labels">
|
||||
${Object.entries(member.labels).map(([key, value]) => `<span class=\"label-chip\">${this.escapeHtml(key)}: ${this.escapeHtml(value)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="expand-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="member-details">
|
||||
<div class="loading-details">Loading detailed information...</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
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 = '<div class="loading-details">Loading detailed information...</div>';
|
||||
|
||||
// Make the entire card clickable
|
||||
this.addEventListener(card, 'click', async (e) => {
|
||||
if (e.target === expandIcon) return;
|
||||
|
||||
const isExpanding = !card.classList.contains('expanded');
|
||||
|
||||
if (isExpanding) {
|
||||
await this.expandCard(card, memberIp, memberDetails);
|
||||
} else {
|
||||
this.collapseCard(card, expandIcon);
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the expand icon click handler for visual feedback
|
||||
if (expandIcon) {
|
||||
this.addEventListener(expandIcon, 'click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const isExpanding = !card.classList.contains('expanded');
|
||||
|
||||
if (isExpanding) {
|
||||
await this.expandCard(card, memberIp, memberDetails);
|
||||
} else {
|
||||
this.collapseCard(card, expandIcon);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
async expandCard(card, memberIp, memberDetails) {
|
||||
try {
|
||||
// Create node details view model and component
|
||||
const nodeDetailsVM = new NodeDetailsViewModel();
|
||||
const nodeDetailsComponent = new NodeDetailsComponent(memberDetails, nodeDetailsVM, this.eventBus);
|
||||
|
||||
// Load node details
|
||||
await nodeDetailsVM.loadNodeDetails(memberIp);
|
||||
|
||||
// Mount the component
|
||||
nodeDetailsComponent.mount();
|
||||
|
||||
// Update UI
|
||||
card.classList.add('expanded');
|
||||
const expandIcon = card.querySelector('.expand-icon');
|
||||
if (expandIcon) {
|
||||
expandIcon.classList.add('expanded');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to expand card:', error);
|
||||
memberDetails.innerHTML = `
|
||||
<div class="error">
|
||||
<strong>Error loading node details:</strong><br>
|
||||
${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
collapseCard(card, expandIcon) {
|
||||
card.classList.remove('expanded');
|
||||
if (expandIcon) {
|
||||
expandIcon.classList.remove('expanded');
|
||||
}
|
||||
}
|
||||
|
||||
setupTabs(container) {
|
||||
super.setupTabs(container, {
|
||||
onChange: (targetTab) => {
|
||||
const memberCard = container.closest('.member-card');
|
||||
if (memberCard) {
|
||||
const memberIp = memberCard.dataset.memberIp;
|
||||
this.viewModel.storeActiveTab(memberIp, targetTab);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Restore active tab state
|
||||
restoreActiveTab(container, activeTab) {
|
||||
const tabButtons = container.querySelectorAll('.tab-button');
|
||||
const tabContents = container.querySelectorAll('.tab-content');
|
||||
|
||||
// Remove active class from all buttons and contents
|
||||
tabButtons.forEach(btn => btn.classList.remove('active'));
|
||||
tabContents.forEach(content => content.classList.remove('active'));
|
||||
|
||||
// Add active class to the restored tab
|
||||
const activeButton = container.querySelector(`[data-tab="${activeTab}"]`);
|
||||
const activeContent = container.querySelector(`#${activeTab}-tab`);
|
||||
|
||||
if (activeButton) activeButton.classList.add('active');
|
||||
if (activeContent) activeContent.classList.add('active');
|
||||
}
|
||||
|
||||
// Note: handleRefresh method has been moved to ClusterViewComponent
|
||||
// since the refresh button is in the cluster header, not in the members container
|
||||
|
||||
// Debug method to check component state
|
||||
debugState() {
|
||||
const members = this.viewModel.get('members');
|
||||
const isLoading = this.viewModel.get('isLoading');
|
||||
const error = this.viewModel.get('error');
|
||||
const expandedCards = this.viewModel.get('expandedCards');
|
||||
const activeTabs = this.viewModel.get('activeTabs');
|
||||
|
||||
logger.debug('ClusterMembersComponent: Debug State:', {
|
||||
isMounted: this.isMounted,
|
||||
container: this.container,
|
||||
members: members,
|
||||
membersCount: members?.length || 0,
|
||||
isLoading: isLoading,
|
||||
error: error,
|
||||
expandedCardsCount: expandedCards?.size || 0,
|
||||
activeTabsCount: activeTabs?.size || 0,
|
||||
loadingTimeout: this.loadingTimeout
|
||||
});
|
||||
|
||||
return { members, isLoading, error, expandedCards, activeTabs };
|
||||
}
|
||||
|
||||
// Manual refresh method that bypasses potential state conflicts
|
||||
async manualRefresh() {
|
||||
logger.debug('ClusterMembersComponent: Manual refresh called');
|
||||
|
||||
try {
|
||||
// Clear any existing loading state
|
||||
this.viewModel.set('isLoading', false);
|
||||
this.viewModel.set('error', null);
|
||||
|
||||
// Force a fresh data load
|
||||
await this.viewModel.updateClusterMembers();
|
||||
|
||||
logger.debug('ClusterMembersComponent: Manual refresh completed');
|
||||
} catch (error) {
|
||||
logger.error('ClusterMembersComponent: Manual refresh failed:', error);
|
||||
this.showErrorState(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
unmount() {
|
||||
if (!this.isMounted) return;
|
||||
|
||||
this.isMounted = false;
|
||||
|
||||
// Clear any pending timeouts
|
||||
if (this.loadingTimeout) {
|
||||
clearTimeout(this.loadingTimeout);
|
||||
this.loadingTimeout = null;
|
||||
}
|
||||
|
||||
if (this.loadingCompletionCheck) {
|
||||
clearTimeout(this.loadingCompletionCheck);
|
||||
this.loadingCompletionCheck = null;
|
||||
}
|
||||
|
||||
// Clear any pending render operations
|
||||
this.renderInProgress = false;
|
||||
|
||||
this.cleanupEventListeners();
|
||||
this.cleanupViewModelListeners();
|
||||
|
||||
logger.debug(`${this.constructor.name} unmounted`);
|
||||
}
|
||||
|
||||
// Override pause method to handle timeouts and operations
|
||||
onPause() {
|
||||
logger.debug('ClusterMembersComponent: Pausing...');
|
||||
|
||||
// Clear any pending timeouts
|
||||
if (this.loadingTimeout) {
|
||||
clearTimeout(this.loadingTimeout);
|
||||
this.loadingTimeout = null;
|
||||
}
|
||||
|
||||
if (this.loadingCompletionCheck) {
|
||||
clearTimeout(this.loadingCompletionCheck);
|
||||
this.loadingCompletionCheck = null;
|
||||
}
|
||||
|
||||
// Mark as paused to prevent new operations
|
||||
this.isPaused = true;
|
||||
}
|
||||
|
||||
// Override resume method to restore functionality
|
||||
onResume() {
|
||||
logger.debug('ClusterMembersComponent: Resuming...');
|
||||
|
||||
this.isPaused = false;
|
||||
|
||||
// Re-setup loading timeout if needed
|
||||
if (!this.loadingTimeout) {
|
||||
this.setupLoadingTimeout();
|
||||
}
|
||||
|
||||
// Check if we need to handle any pending operations
|
||||
this.checkPendingOperations();
|
||||
}
|
||||
|
||||
// Check for any operations that need to be handled after resume
|
||||
checkPendingOperations() {
|
||||
const isLoading = this.viewModel.get('isLoading');
|
||||
const members = this.viewModel.get('members');
|
||||
|
||||
// If we were loading and it completed while paused, handle the completion
|
||||
if (!isLoading && members && members.length > 0) {
|
||||
logger.debug('ClusterMembersComponent: Handling pending loading completion after resume');
|
||||
this.handleLoadingCompletion();
|
||||
}
|
||||
}
|
||||
|
||||
// Override to determine if re-render is needed on resume
|
||||
shouldRenderOnResume() {
|
||||
// Don't re-render on resume - maintain current state
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
window.ClusterMembersComponent = ClusterMembersComponent;
|
||||
50
public/scripts/components/ClusterStatusComponent.js
Normal file
50
public/scripts/components/ClusterStatusComponent.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
210
public/scripts/components/ClusterViewComponent.js
Normal file
210
public/scripts/components/ClusterViewComponent.js
Normal file
@@ -0,0 +1,210 @@
|
||||
// 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 = `
|
||||
<svg class="refresh-icon spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 4v6h6M23 20v-6h-6"/>
|
||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
|
||||
</svg>
|
||||
Refreshing...
|
||||
`;
|
||||
refreshBtn.disabled = true;
|
||||
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
window.ClusterViewComponent = ClusterViewComponent;
|
||||
16
public/scripts/components/ComponentsLoader.js
Normal file
16
public/scripts/components/ComponentsLoader.js
Normal file
@@ -0,0 +1,16 @@
|
||||
(function(){
|
||||
// Simple readiness flag once all component constructors are present
|
||||
function allReady(){
|
||||
return !!(window.PrimaryNodeComponent && window.ClusterMembersComponent && window.NodeDetailsComponent && window.FirmwareComponent && window.ClusterViewComponent && window.FirmwareViewComponent && window.TopologyGraphComponent && window.MemberCardOverlayComponent && window.ClusterStatusComponent);
|
||||
}
|
||||
window.waitForComponentsReady = function(timeoutMs = 5000){
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
(function check(){
|
||||
if (allReady()) return resolve(true);
|
||||
if (Date.now() - start > timeoutMs) return reject(new Error('Components did not load in time'));
|
||||
setTimeout(check, 25);
|
||||
})();
|
||||
});
|
||||
};
|
||||
})();
|
||||
698
public/scripts/components/FirmwareComponent.js
Normal file
698
public/scripts/components/FirmwareComponent.js
Normal file
@@ -0,0 +1,698 @@
|
||||
// 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 = `
|
||||
<div class="firmware-upload-progress" id="firmware-upload-progress">
|
||||
<div class="progress-header">
|
||||
<h3>📤 Firmware Upload Progress</h3>
|
||||
<div class="progress-info">
|
||||
<span>File: ${file.name}</span>
|
||||
<span>Size: ${(file.size / 1024).toFixed(1)}KB</span>
|
||||
<span>Targets: ${nodes.length} node(s)</span>
|
||||
</div>
|
||||
<div class="overall-progress">
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" id="overall-progress-bar" style="width: 0%; background-color: #fbbf24;"></div>
|
||||
</div>
|
||||
<span class="progress-text">0/${nodes.length} Successful (0%)</span>
|
||||
</div>
|
||||
<div class="progress-summary" id="progress-summary">
|
||||
<span>Status: Preparing upload...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-list" id="progress-list">
|
||||
${nodes.map(node => `
|
||||
<div class="progress-item" data-node-ip="${node.ip}">
|
||||
<div class="progress-node-info">
|
||||
<span class="node-name">${node.hostname || node.ip}</span>
|
||||
<span class="node-ip">${node.ip}</span>
|
||||
</div>
|
||||
<div class="progress-status">Pending...</div>
|
||||
<div class="progress-time" id="time-${node.ip}"></div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = progressHTML;
|
||||
|
||||
// Initialize progress for single-node uploads
|
||||
if (nodes.length === 1) {
|
||||
const node = nodes[0];
|
||||
this.updateNodeProgress(1, 1, node.ip, 'Pending...');
|
||||
}
|
||||
}
|
||||
|
||||
updateNodeProgress(current, total, nodeIp, status) {
|
||||
const progressItem = this.findElement(`[data-node-ip="${nodeIp}"]`);
|
||||
if (progressItem) {
|
||||
const statusElement = progressItem.querySelector('.progress-status');
|
||||
const timeElement = progressItem.querySelector('.progress-time');
|
||||
|
||||
if (statusElement) {
|
||||
statusElement.textContent = status;
|
||||
|
||||
// Add status-specific styling
|
||||
statusElement.className = 'progress-status';
|
||||
if (status === 'Completed') {
|
||||
statusElement.classList.add('success');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
} else if (status === 'Failed') {
|
||||
statusElement.classList.add('error');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
} else if (status === 'Uploading...') {
|
||||
statusElement.classList.add('uploading');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = 'Started: ' + new Date().toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateOverallProgress(successfulUploads, totalNodes) {
|
||||
const progressBar = this.findElement('#overall-progress-bar');
|
||||
const progressText = this.findElement('.progress-text');
|
||||
|
||||
if (progressBar and 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 and totalNodes === 1) {
|
||||
if (successfulUploads === 1) {
|
||||
progressSummary.innerHTML = '<span>Status: Upload completed successfully</span>';
|
||||
} else if (successfulUploads === 0) {
|
||||
progressSummary.innerHTML = '<span>Status: Upload failed</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
displayUploadResults(results) {
|
||||
const progressHeader = this.findElement('.progress-header h3');
|
||||
const progressSummary = this.findElement('#progress-summary');
|
||||
|
||||
if (progressHeader and progressSummary) {
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const totalCount = results.length;
|
||||
const successRate = Math.round((successCount / totalCount) * 100);
|
||||
|
||||
if (totalCount === 1) {
|
||||
// Single node upload
|
||||
if (successCount === 1) {
|
||||
progressHeader.textContent = `📤 Firmware Upload Complete`;
|
||||
progressSummary.innerHTML = `<span>✅ Upload to ${results[0].hostname || results[0].nodeIp} completed successfully at ${new Date().toLocaleTimeString()}</span>`;
|
||||
} else {
|
||||
progressHeader.textContent = `📤 Firmware Upload Failed`;
|
||||
progressSummary.innerHTML = `<span>❌ Upload to ${results[0].hostname || results[0].nodeIp} failed at ${new Date().toLocaleTimeString()}</span>`;
|
||||
}
|
||||
} else if (successCount === totalCount) {
|
||||
// Multi-node upload - all successful
|
||||
progressHeader.textContent = `📤 Firmware Upload Complete (${successCount}/${totalCount} Successful)`;
|
||||
progressSummary.innerHTML = `<span>✅ All uploads completed successfully at ${new Date().toLocaleTimeString()}</span>`;
|
||||
} else {
|
||||
// Multi-node upload - some failed
|
||||
progressHeader.textContent = `📤 Firmware Upload Results (${successCount}/${totalCount} Successful)`;
|
||||
progressSummary.innerHTML = `<span>⚠️ Upload completed with ${totalCount - successCount} failure(s) at ${new Date().toLocaleTimeString()}</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateFileInfo() {
|
||||
const file = this.viewModel.get('selectedFile');
|
||||
const fileInfo = this.findElement('#file-info');
|
||||
const deployBtn = this.findElement('#deploy-btn');
|
||||
|
||||
if (file) {
|
||||
fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`;
|
||||
fileInfo.classList.add('has-file');
|
||||
} else {
|
||||
fileInfo.textContent = 'No file selected';
|
||||
fileInfo.classList.remove('has-file');
|
||||
}
|
||||
|
||||
this.updateDeployButton();
|
||||
}
|
||||
|
||||
updateTargetVisibility() {
|
||||
const targetType = this.viewModel.get('targetType');
|
||||
const specificNodeSelect = this.findElement('#specific-node-select');
|
||||
const labelSelect = this.findElement('#label-select');
|
||||
|
||||
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 = '<option value="">Select a node...</option>';
|
||||
|
||||
// Get available nodes from the view model
|
||||
const availableNodes = this.viewModel.get('availableNodes');
|
||||
|
||||
if (!availableNodes || availableNodes.length === 0) {
|
||||
// No nodes available
|
||||
const option = document.createElement('option');
|
||||
option.value = "";
|
||||
option.textContent = "No nodes available";
|
||||
option.disabled = true;
|
||||
select.appendChild(option);
|
||||
return;
|
||||
}
|
||||
|
||||
availableNodes.forEach(node => {
|
||||
const option = document.createElement('option');
|
||||
option.value = node.ip;
|
||||
option.textContent = `${node.hostname} (${node.ip})`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
// Ensure event listener is still bound after repopulating
|
||||
this.ensureNodeSelectListener(select);
|
||||
|
||||
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 = ['<option value="">Select a label...</option>']
|
||||
.concat(labels.filter(l => !selected.has(l)).map(l => `<option value="${l}">${l}</option>`));
|
||||
select.innerHTML = options.join('');
|
||||
// Ensure change listener remains bound
|
||||
if (this._boundLabelSelectHandler) {
|
||||
select.removeEventListener('change', this._boundLabelSelectHandler);
|
||||
select.addEventListener('change', this._boundLabelSelectHandler);
|
||||
}
|
||||
this.renderSelectedLabelChips();
|
||||
}
|
||||
|
||||
renderSelectedLabelChips() {
|
||||
const container = this.findElement('#selected-labels-container');
|
||||
if (!container) return;
|
||||
const selected = this.viewModel.get('selectedLabels') || [];
|
||||
if (selected.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = selected.map(l => `
|
||||
<span class="label-chip removable" data-label="${l}">
|
||||
${l}
|
||||
<button class="chip-remove" data-label="${l}" title="Remove">×</button>
|
||||
</span>
|
||||
`).join('');
|
||||
Array.from(container.querySelectorAll('.chip-remove')).forEach(btn => {
|
||||
this.addEventListener(btn, 'click', (e) => {
|
||||
e.stopPropagation();
|
||||
const label = btn.getAttribute('data-label');
|
||||
const current = this.viewModel.get('selectedLabels') || [];
|
||||
this.viewModel.setSelectedLabels(current.filter(x => x !== label));
|
||||
this.populateLabelSelect();
|
||||
this.updateAffectedNodesPreview();
|
||||
this.updateDeployButton();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateAffectedNodesPreview() {
|
||||
const container = this.findElement('#firmware-nodes-list');
|
||||
if (!container) return;
|
||||
if (this.viewModel.get('targetType') !== 'labels') {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const nodes = this.viewModel.getAffectedNodesByLabels();
|
||||
if (!nodes.length) {
|
||||
container.innerHTML = `<div class="empty-state"><div>No nodes match the selected labels</div></div>`;
|
||||
return;
|
||||
}
|
||||
const html = `
|
||||
<div class="affected-nodes">
|
||||
<div class="progress-header"><h3>🎯 Affected Nodes (${nodes.length})</h3></div>
|
||||
<div class="progress-list">
|
||||
${nodes.map(n => `
|
||||
<div class="progress-item" data-node-ip="${n.ip}">
|
||||
<div class="progress-node-info"><span class="node-name">${n.hostname || n.ip}</span><span class="node-ip">${n.ip}</span></div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
container.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
window.FirmwareComponent = FirmwareComponent;
|
||||
82
public/scripts/components/FirmwareViewComponent.js
Normal file
82
public/scripts/components/FirmwareViewComponent.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
529
public/scripts/components/NodeDetailsComponent.js
Normal file
529
public/scripts/components/NodeDetailsComponent.js
Normal file
@@ -0,0 +1,529 @@
|
||||
// Node Details Component with enhanced state preservation
|
||||
class NodeDetailsComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
}
|
||||
|
||||
setupViewModelListeners() {
|
||||
this.subscribeToProperty('nodeStatus', this.handleNodeStatusUpdate.bind(this));
|
||||
this.subscribeToProperty('tasks', this.handleTasksUpdate.bind(this));
|
||||
this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this));
|
||||
this.subscribeToProperty('error', this.handleErrorUpdate.bind(this));
|
||||
this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this));
|
||||
this.subscribeToProperty('capabilities', this.handleCapabilitiesUpdate.bind(this));
|
||||
}
|
||||
|
||||
// Handle node status update with state preservation
|
||||
handleNodeStatusUpdate(newStatus, previousStatus) {
|
||||
if (newStatus && !this.viewModel.get('isLoading')) {
|
||||
this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('capabilities'));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tasks update with state preservation
|
||||
handleTasksUpdate(newTasks, previousTasks) {
|
||||
const nodeStatus = this.viewModel.get('nodeStatus');
|
||||
if (nodeStatus && !this.viewModel.get('isLoading')) {
|
||||
this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('capabilities'));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle loading state update
|
||||
handleLoadingUpdate(isLoading) {
|
||||
if (isLoading) {
|
||||
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle error state update
|
||||
handleErrorUpdate(error) {
|
||||
if (error) {
|
||||
this.renderError(`Error loading node details: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle active tab update
|
||||
handleActiveTabUpdate(newTab, previousTab) {
|
||||
// Update tab UI without full re-render
|
||||
this.updateActiveTab(newTab, previousTab);
|
||||
}
|
||||
|
||||
// Handle capabilities update with state preservation
|
||||
handleCapabilitiesUpdate(newCapabilities, previousCapabilities) {
|
||||
const nodeStatus = this.viewModel.get('nodeStatus');
|
||||
const tasks = this.viewModel.get('tasks');
|
||||
if (nodeStatus && !this.viewModel.get('isLoading')) {
|
||||
this.renderNodeDetails(nodeStatus, tasks, newCapabilities);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const nodeStatus = this.viewModel.get('nodeStatus');
|
||||
const tasks = this.viewModel.get('tasks');
|
||||
const isLoading = this.viewModel.get('isLoading');
|
||||
const error = this.viewModel.get('error');
|
||||
const capabilities = this.viewModel.get('capabilities');
|
||||
|
||||
if (isLoading) {
|
||||
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
this.renderError(`Error loading node details: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nodeStatus) {
|
||||
this.renderEmpty('<div class="loading-details">No node status available</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderNodeDetails(nodeStatus, tasks, capabilities);
|
||||
}
|
||||
|
||||
renderNodeDetails(nodeStatus, tasks, capabilities) {
|
||||
// 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 = `
|
||||
<div class="tabs-container">
|
||||
<div class="tabs-header">
|
||||
<button class="tab-button ${activeTab === 'status' ? 'active' : ''}" data-tab="status">Status</button>
|
||||
<button class="tab-button ${activeTab === 'endpoints' ? 'active' : ''}" data-tab="endpoints">Endpoints</button>
|
||||
<button class="tab-button ${activeTab === 'capabilities' ? 'active' : ''}" data-tab="capabilities">Capabilities</button>
|
||||
<button class="tab-button ${activeTab === 'tasks' ? 'active' : ''}" data-tab="tasks">Tasks</button>
|
||||
<button class="tab-button ${activeTab === 'firmware' ? 'active' : ''}" data-tab="firmware">Firmware</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content ${activeTab === 'status' ? 'active' : ''}" id="status-tab">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Free Heap:</span>
|
||||
<span class="detail-value">${Math.round(nodeStatus.freeHeap / 1024)}KB</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Chip ID:</span>
|
||||
<span class="detail-value">${nodeStatus.chipId}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">SDK Version:</span>
|
||||
<span class="detail-value">${nodeStatus.sdkVersion}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">CPU Frequency:</span>
|
||||
<span class="detail-value">${nodeStatus.cpuFreqMHz}MHz</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Flash Size:</span>
|
||||
<span class="detail-value">${Math.round(nodeStatus.flashChipSize / 1024)}KB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content ${activeTab === 'endpoints' ? 'active' : ''}" id="endpoints-tab">
|
||||
${nodeStatus.api ? nodeStatus.api.map(endpoint =>
|
||||
`<div class="endpoint-item">${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}</div>`
|
||||
).join('') : '<div class="endpoint-item">No API endpoints available</div>'}
|
||||
</div>
|
||||
|
||||
<div class="tab-content ${activeTab === 'capabilities' ? 'active' : ''}" id="capabilities-tab">
|
||||
${this.renderCapabilitiesTab(capabilities)}
|
||||
</div>
|
||||
|
||||
<div class="tab-content ${activeTab === 'tasks' ? 'active' : ''}" id="tasks-tab">
|
||||
${this.renderTasksTab(tasks)}
|
||||
</div>
|
||||
|
||||
<div class="tab-content ${activeTab === 'firmware' ? 'active' : ''}" id="firmware-tab">
|
||||
${this.renderFirmwareTab()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.setHTML('', html);
|
||||
this.setupTabs();
|
||||
// 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 `
|
||||
<div class="no-capabilities">
|
||||
<div>🧩 No capabilities reported</div>
|
||||
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">This node did not return any capabilities</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Sort endpoints by URI (name), then by method for stable ordering
|
||||
const endpoints = [...capabilities.endpoints].sort((a, b) => {
|
||||
const aUri = String(a.uri || '').toLowerCase();
|
||||
const bUri = String(b.uri || '').toLowerCase();
|
||||
if (aUri < bUri) return -1;
|
||||
if (aUri > bUri) return 1;
|
||||
const aMethod = String(a.method || '').toLowerCase();
|
||||
const bMethod = String(b.method || '').toLowerCase();
|
||||
return aMethod.localeCompare(bMethod);
|
||||
});
|
||||
|
||||
const total = endpoints.length;
|
||||
|
||||
// Preserve selection based on a stable key of method+uri if available
|
||||
const selectedKey = String(this.getUIState('capSelectedKey') || '');
|
||||
let selectedIndex = endpoints.findIndex(ep => `${ep.method} ${ep.uri}` === selectedKey);
|
||||
if (selectedIndex === -1) {
|
||||
selectedIndex = Number(this.getUIState('capSelectedIndex'));
|
||||
if (Number.isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= total) {
|
||||
selectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute padding for aligned display in dropdown
|
||||
const maxMethodLen = endpoints.reduce((m, ep) => Math.max(m, String(ep.method || '').length), 0);
|
||||
|
||||
const selectorOptions = endpoints.map((ep, idx) => {
|
||||
const method = String(ep.method || '');
|
||||
const uri = String(ep.uri || '');
|
||||
const padCount = Math.max(1, (maxMethodLen - method.length) + 2);
|
||||
const spacer = ' '.repeat(padCount);
|
||||
return `<option value="${idx}" data-method="${method}" data-uri="${uri}" ${idx === selectedIndex ? 'selected' : ''}>${method}${spacer}${uri}</option>`;
|
||||
}).join('');
|
||||
|
||||
const items = endpoints.map((ep, idx) => {
|
||||
const formId = `cap-form-${idx}`;
|
||||
const resultId = `cap-result-${idx}`;
|
||||
const params = Array.isArray(ep.params) && ep.params.length > 0
|
||||
? `<div class="capability-params">${ep.params.map((p, pidx) => `
|
||||
<label class="capability-param" for="${formId}-field-${pidx}">
|
||||
<span class="param-name">${p.name}${p.required ? ' *' : ''}</span>
|
||||
${ (Array.isArray(p.values) && p.values.length > 1)
|
||||
? `<select id="${formId}-field-${pidx}" data-param-name="${p.name}" data-param-location="${p.location || 'body'}" data-param-type="${p.type || 'string'}" data-param-required="${p.required ? '1' : '0'}" class="param-input">${p.values.map(v => `<option value="${v}">${v}</option>`).join('')}</select>`
|
||||
: `<input id="${formId}-field-${pidx}" data-param-name="${p.name}" data-param-location="${p.location || 'body'}" data-param-type="${p.type || 'string'}" data-param-required="${p.required ? '1' : '0'}" class="param-input" type="text" placeholder="${p.location || 'body'} • ${p.type || 'string'}" value="${(Array.isArray(p.values) && p.values.length === 1) ? p.values[0] : ''}">`
|
||||
}
|
||||
</label>
|
||||
`).join('')}</div>`
|
||||
: '<div class="capability-params none">No parameters</div>';
|
||||
return `
|
||||
<div class="capability-item" data-cap-index="${idx}" style="display:${idx === selectedIndex ? '' : 'none'};">
|
||||
<div class="capability-header">
|
||||
<span class="cap-method">${ep.method}</span>
|
||||
<span class="cap-uri">${ep.uri}</span>
|
||||
<button class="cap-call-btn" data-action="call-capability" data-method="${ep.method}" data-uri="${ep.uri}" data-form-id="${formId}" data-result-id="${resultId}">Call</button>
|
||||
</div>
|
||||
<form id="${formId}" class="capability-form" onsubmit="return false;">
|
||||
${params}
|
||||
</form>
|
||||
<div id="${resultId}" class="capability-result" style="display:none;"></div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Attach events after render in setupCapabilitiesEvents()
|
||||
setTimeout(() => this.setupCapabilitiesEvents(), 0);
|
||||
|
||||
return `
|
||||
<div class="capability-selector">
|
||||
<label class="param-name" for="capability-select">Capability</label>
|
||||
<select id="capability-select" class="param-input" style="font-family: monospace;">${selectorOptions}</select>
|
||||
</div>
|
||||
<div class="capabilities-list">${items}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setupCapabilitiesEvents() {
|
||||
const selector = this.findElement('#capability-select');
|
||||
if (selector) {
|
||||
this.addEventListener(selector, 'change', (e) => {
|
||||
const selected = Number(e.target.value);
|
||||
const items = Array.from(this.findAllElements('.capability-item'));
|
||||
items.forEach((el, idx) => {
|
||||
el.style.display = (idx === selected) ? '' : 'none';
|
||||
});
|
||||
this.setUIState('capSelectedIndex', selected);
|
||||
const opt = e.target.selectedOptions && e.target.selectedOptions[0];
|
||||
if (opt) {
|
||||
const method = opt.dataset.method || '';
|
||||
const uri = opt.dataset.uri || '';
|
||||
this.setUIState('capSelectedKey', `${method} ${uri}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const buttons = this.findAllElements('.cap-call-btn');
|
||||
buttons.forEach(btn => {
|
||||
this.addEventListener(btn, 'click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const method = btn.dataset.method || 'GET';
|
||||
const uri = btn.dataset.uri || '';
|
||||
const formId = btn.dataset.formId;
|
||||
const resultId = btn.dataset.resultId;
|
||||
|
||||
const formEl = this.findElement(`#${formId}`);
|
||||
const resultEl = this.findElement(`#${resultId}`);
|
||||
if (!formEl || !resultEl) return;
|
||||
|
||||
const inputs = Array.from(formEl.querySelectorAll('.param-input'));
|
||||
const params = inputs.map(input => ({
|
||||
name: input.dataset.paramName,
|
||||
location: input.dataset.paramLocation || 'body',
|
||||
type: input.dataset.paramType || 'string',
|
||||
required: input.dataset.paramRequired === '1',
|
||||
value: input.value
|
||||
}));
|
||||
|
||||
// Required validation
|
||||
const missing = params.filter(p => p.required && (!p.value || String(p.value).trim() === ''));
|
||||
if (missing.length > 0) {
|
||||
resultEl.style.display = 'block';
|
||||
resultEl.innerHTML = `
|
||||
<div class="cap-call-error">
|
||||
<div>❌ Missing required fields: ${missing.map(m => this.escapeHtml(m.name)).join(', ')}</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
resultEl.style.display = 'block';
|
||||
resultEl.innerHTML = '<div class="loading">Calling endpoint...</div>';
|
||||
|
||||
try {
|
||||
const response = await this.viewModel.callCapability(method, uri, params);
|
||||
const pretty = typeof response?.data === 'object' ? JSON.stringify(response.data, null, 2) : String(response?.data ?? '');
|
||||
resultEl.innerHTML = `
|
||||
<div class="cap-call-success">
|
||||
<div>✅ Success</div>
|
||||
<pre class="cap-result-pre">${this.escapeHtml(pretty)}</pre>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
resultEl.innerHTML = `
|
||||
<div class="cap-call-error">
|
||||
<div>❌ Error: ${this.escapeHtml(err.message || 'Request failed')}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
renderTasksTab(tasks) {
|
||||
const summary = this.viewModel.get('tasksSummary');
|
||||
if (tasks && tasks.length > 0) {
|
||||
const summaryHTML = summary ? `
|
||||
<div class="tasks-summary">
|
||||
<div class="tasks-summary-left">
|
||||
<div class="summary-icon">📋</div>
|
||||
<div>
|
||||
<div class="summary-title">Tasks Overview</div>
|
||||
<div class="summary-subtitle">System task management and monitoring</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tasks-summary-right">
|
||||
<div class="summary-stat total">
|
||||
<div class="summary-stat-value">${summary.totalTasks ?? tasks.length}</div>
|
||||
<div class="summary-stat-label">Total</div>
|
||||
</div>
|
||||
<div class="summary-stat active">
|
||||
<div class="summary-stat-value">${summary.activeTasks ?? tasks.filter(t => t.running).length}</div>
|
||||
<div class="summary-stat-label">Active</div>
|
||||
</div>
|
||||
<div class="summary-stat stopped">
|
||||
<div class="summary-stat-value">${(summary.totalTasks ?? tasks.length) - (summary.activeTasks ?? tasks.filter(t => t.running).length)}</div>
|
||||
<div class="summary-stat-label">Stopped</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
const tasksHTML = tasks.map(task => `
|
||||
<div class="task-item">
|
||||
<div class="task-header">
|
||||
<span class="task-name">${task.name || 'Unknown Task'}</span>
|
||||
<span class="task-status ${task.running ? 'running' : 'stopped'}">
|
||||
${task.running ? '🟢 Running' : '🔴 Stopped'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="task-details">
|
||||
<span class="task-interval">Interval: ${task.interval}ms</span>
|
||||
<span class="task-enabled">${task.enabled ? '🟢 Enabled' : '🔴 Disabled'}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
${summaryHTML}
|
||||
${tasksHTML}
|
||||
`;
|
||||
} else {
|
||||
const total = summary?.totalTasks ?? 0;
|
||||
const active = summary?.activeTasks ?? 0;
|
||||
return `
|
||||
<div class="tasks-summary">
|
||||
<div class="tasks-summary-left">
|
||||
<div class="summary-icon">📋</div>
|
||||
<div>
|
||||
<div class="summary-title">Tasks Overview</div>
|
||||
<div class="summary-subtitle">${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tasks-summary-right">
|
||||
<div class="summary-stat total">
|
||||
<div class="summary-stat-value">${total}</div>
|
||||
<div class="summary-stat-label">Total</div>
|
||||
</div>
|
||||
<div class="summary-stat active">
|
||||
<div class="summary-stat-value">${active}</div>
|
||||
<div class="summary-stat-label">Active</div>
|
||||
</div>
|
||||
<div class="summary-stat stopped">
|
||||
<div class="summary-stat-value">${total - active}</div>
|
||||
<div class="summary-stat-label">Stopped</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="no-tasks">
|
||||
<div>📋 No active tasks found</div>
|
||||
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">
|
||||
${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
renderFirmwareTab() {
|
||||
return `
|
||||
<div class="firmware-upload">
|
||||
<h4>Firmware Update</h4>
|
||||
<div class="upload-area">
|
||||
<input type="file" id="firmware-file" accept=".bin,.hex" style="display: none;">
|
||||
<button class="upload-btn" data-action="select-file">
|
||||
📁 Choose Firmware File
|
||||
</button>
|
||||
<div class="upload-info">Select a .bin or .hex file to upload</div>
|
||||
<div id="upload-status" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setupTabs() {
|
||||
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 = `
|
||||
<div class="upload-progress">
|
||||
<div>📤 Uploading ${file.name}...</div>
|
||||
<div style="font-size: 0.8rem; opacity: 0.7;">Size: ${(file.size / 1024).toFixed(1)}KB</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Disable upload button
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.textContent = '⏳ Uploading...';
|
||||
|
||||
// Get the member IP from the card
|
||||
const memberCard = this.container.closest('.member-card');
|
||||
const memberIp = memberCard.dataset.memberIp;
|
||||
|
||||
if (!memberIp) {
|
||||
throw new Error('Could not determine target node IP address');
|
||||
}
|
||||
|
||||
// Upload firmware
|
||||
const result = await this.viewModel.uploadFirmware(file, memberIp);
|
||||
|
||||
// Show success
|
||||
uploadStatus.innerHTML = `
|
||||
<div class="upload-success">
|
||||
<div>✅ Firmware uploaded successfully!</div>
|
||||
<div style="font-size: 0.8rem; margin-top: 0.5rem; opacity: 0.7;">Node: ${memberIp}</div>
|
||||
<div style="font-size: 0.8rem; margin-top: 0.5rem; opacity: 0.7;">Size: ${(file.size / 1024).toFixed(1)}KB</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
logger.debug('Firmware upload successful:', result);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Firmware upload failed:', error);
|
||||
|
||||
// Show error
|
||||
uploadStatus.innerHTML = `
|
||||
<div class="upload-error">
|
||||
<div>❌ Upload failed: ${error.message}</div>
|
||||
</div>
|
||||
`;
|
||||
} finally {
|
||||
// Re-enable upload button
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = originalText;
|
||||
|
||||
// Clear file input
|
||||
const fileInput = this.findElement('#firmware-file');
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.NodeDetailsComponent = NodeDetailsComponent;
|
||||
90
public/scripts/components/PrimaryNodeComponent.js
Normal file
90
public/scripts/components/PrimaryNodeComponent.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.PrimaryNodeComponent = PrimaryNodeComponent;
|
||||
802
public/scripts/components/TopologyGraphComponent.js
Normal file
802
public/scripts/components/TopologyGraphComponent.js
Normal file
@@ -0,0 +1,802 @@
|
||||
// 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])
|
||||
.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)');
|
||||
|
||||
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)');
|
||||
}
|
||||
|
||||
// Clear existing graph elements but preserve the main group and its transform
|
||||
svgGroup.selectAll('.graph-element').remove();
|
||||
|
||||
// Create links
|
||||
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)
|
||||
.attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8)))
|
||||
.attr('marker-end', null);
|
||||
|
||||
// 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
|
||||
node.append('circle')
|
||||
.attr('r', d => this.getNodeRadius(d.status))
|
||||
.attr('fill', d => this.getNodeColor(d.status))
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
// Status indicator
|
||||
node.append('circle')
|
||||
.attr('r', 3)
|
||||
.attr('fill', d => this.getStatusIndicatorColor(d.status))
|
||||
.attr('cx', -8)
|
||||
.attr('cy', -8);
|
||||
|
||||
// Hostname
|
||||
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')
|
||||
.attr('fill', '#ecf0f1')
|
||||
.attr('font-weight', '500');
|
||||
|
||||
// IP
|
||||
node.append('text')
|
||||
.text(d => d.ip)
|
||||
.attr('x', 15)
|
||||
.attr('y', 20)
|
||||
.attr('font-size', '11px')
|
||||
.attr('fill', 'rgba(255, 255, 255, 0.7)');
|
||||
|
||||
// Status text
|
||||
node.append('text')
|
||||
.text(d => d.status)
|
||||
.attr('x', 15)
|
||||
.attr('y', 35)
|
||||
.attr('font-size', '11px')
|
||||
.attr('fill', d => this.getNodeColor(d.status))
|
||||
.attr('font-weight', '600');
|
||||
|
||||
// Latency labels on links
|
||||
const linkLabels = svgGroup.append('g')
|
||||
.attr('class', 'graph-element')
|
||||
.selectAll('text')
|
||||
.data(links)
|
||||
.enter().append('text')
|
||||
.attr('font-size', '12px')
|
||||
.attr('fill', '#ecf0f1')
|
||||
.attr('font-weight', '600')
|
||||
.attr('text-anchor', 'middle')
|
||||
.style('text-shadow', '0 1px 2px rgba(0, 0, 0, 0.8)')
|
||||
.text(d => `${d.latency}ms`);
|
||||
|
||||
// Simulation
|
||||
if (!this.simulation) {
|
||||
this.simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links).id(d => d.id).distance(300))
|
||||
.force('charge', d3.forceManyBody().strength(-800))
|
||||
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
|
||||
.force('collision', d3.forceCollide().radius(80));
|
||||
|
||||
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);
|
||||
|
||||
linkLabels
|
||||
.attr('x', d => (d.source.x + d.target.x) / 2)
|
||||
.attr('y', d => (d.source.y + d.target.y) / 2 - 5);
|
||||
|
||||
node
|
||||
.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
} else {
|
||||
this.simulation.nodes(nodes);
|
||||
this.simulation.force('link').links(links);
|
||||
this.simulation.alpha(0.3).restart();
|
||||
}
|
||||
|
||||
// Node interactions
|
||||
node.on('click', (event, d) => {
|
||||
this.viewModel.selectNode(d.id);
|
||||
this.updateSelection(d.id);
|
||||
this.showMemberCardOverlay(d);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
link.on('mouseover', (event, d) => {
|
||||
d3.select(event.currentTarget)
|
||||
.attr('stroke-width', d => Math.max(2, Math.min(4, d.latency / 6)))
|
||||
.attr('stroke-opacity', 0.9);
|
||||
});
|
||||
|
||||
link.on('mouseout', (event, d) => {
|
||||
d3.select(event.currentTarget)
|
||||
.attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8)))
|
||||
.attr('stroke-opacity', 0.7);
|
||||
});
|
||||
|
||||
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)`) // Hidden by CSS opacity
|
||||
.style('opacity', '0');
|
||||
|
||||
legend.append('rect')
|
||||
.attr('width', 320)
|
||||
.attr('height', 120)
|
||||
.attr('fill', 'rgba(0, 0, 0, 0.7)')
|
||||
.attr('rx', 8)
|
||||
.attr('stroke', 'rgba(255, 255, 255, 0.2)')
|
||||
.attr('stroke-width', 1);
|
||||
|
||||
const nodeLegend = legend.append('g')
|
||||
.attr('transform', 'translate(20, 20)');
|
||||
|
||||
nodeLegend.append('text')
|
||||
.text('Node Status:')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('font-size', '14px')
|
||||
.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')
|
||||
.attr('fill', '#ecf0f1');
|
||||
});
|
||||
|
||||
const linkLegend = legend.append('g')
|
||||
.attr('transform', 'translate(150, 20)');
|
||||
|
||||
linkLegend.append('text')
|
||||
.text('Link Latency:')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('font-size', '14px')
|
||||
.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);
|
||||
|
||||
linkLegend.append('text')
|
||||
.text(item.range)
|
||||
.attr('x', 25)
|
||||
.attr('y', item.y + 4)
|
||||
.attr('font-size', '12px')
|
||||
.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';
|
||||
case 'INACTIVE':
|
||||
return '#f59e0b';
|
||||
case 'DEAD':
|
||||
return '#ef4444';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
getLinkColor(latency) {
|
||||
if (latency <= 30) return '#10b981';
|
||||
if (latency <= 50) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
getNodeColor(status) {
|
||||
switch (status?.toUpperCase()) {
|
||||
case 'ACTIVE':
|
||||
return '#10b981';
|
||||
case 'INACTIVE':
|
||||
return '#f59e0b';
|
||||
case 'DEAD':
|
||||
return '#ef4444';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<div class="loading"><div>Loading network topology...</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
handleError() {
|
||||
const error = this.viewModel.get('error');
|
||||
if (error) {
|
||||
const container = this.findElement('#topology-graph-container');
|
||||
container.innerHTML = `<div class="error"><div>Error: ${error}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
showNoData() {
|
||||
const container = this.findElement('#topology-graph-container');
|
||||
container.innerHTML = '<div class="no-data"><div>No cluster members found</div></div>';
|
||||
}
|
||||
|
||||
showMemberCardOverlay(nodeData) {
|
||||
// Create overlay container if it doesn't exist
|
||||
let overlayContainer = document.getElementById('member-card-overlay');
|
||||
if (!overlayContainer) {
|
||||
overlayContainer = document.createElement('div');
|
||||
overlayContainer.id = 'member-card-overlay';
|
||||
overlayContainer.className = 'member-card-overlay';
|
||||
document.body.appendChild(overlayContainer);
|
||||
}
|
||||
|
||||
// Create and show the overlay component
|
||||
if (!this.memberOverlayComponent) {
|
||||
const overlayVM = new ViewModel();
|
||||
this.memberOverlayComponent = new MemberCardOverlayComponent(overlayContainer, overlayVM, this.eventBus);
|
||||
this.memberOverlayComponent.mount();
|
||||
}
|
||||
|
||||
// Convert node data to member data format
|
||||
const memberData = {
|
||||
ip: nodeData.ip,
|
||||
hostname: nodeData.hostname,
|
||||
status: this.normalizeStatus(nodeData.status),
|
||||
latency: nodeData.latency,
|
||||
labels: nodeData.resources || {}
|
||||
};
|
||||
|
||||
this.memberOverlayComponent.show(memberData);
|
||||
}
|
||||
|
||||
// Normalize status from topology format to member card format
|
||||
normalizeStatus(status) {
|
||||
if (!status) return 'unknown';
|
||||
|
||||
const normalized = status.toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'active':
|
||||
return 'active';
|
||||
case 'inactive':
|
||||
return 'inactive';
|
||||
case 'dead':
|
||||
return 'offline';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// Override render method to display the graph
|
||||
render() {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal Member Card Overlay Component (kept in same file to avoid circular loads)
|
||||
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;
|
||||
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);
|
||||
|
||||
setTimeout(() => {
|
||||
this.container.classList.add('visible');
|
||||
}, 10);
|
||||
|
||||
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 statusIcon = member.status === 'active' ? '🟢' :
|
||||
member.status === 'inactive' ? '🟠' : '🔴';
|
||||
|
||||
return `
|
||||
<div class="member-overlay-content">
|
||||
<div class="member-overlay-header">
|
||||
<div class="member-info">
|
||||
<div class="member-row-1">
|
||||
<div class="status-hostname-group">
|
||||
<div class="member-status ${statusClass}">
|
||||
${statusIcon}
|
||||
</div>
|
||||
<div class="member-hostname">${member.hostname || 'Unknown Device'}</div>
|
||||
</div>
|
||||
<div class="member-ip">${member.ip || 'No IP'}</div>
|
||||
<div class="member-latency">
|
||||
<span class="latency-label">Latency:</span>
|
||||
<span class="latency-value">${member.latency ? member.latency + 'ms' : 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="member-labels" style="display: none;"></div>
|
||||
</div>
|
||||
<button class="member-overlay-close" aria-label="Close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="member-overlay-body">
|
||||
<div class="member-card expanded" data-member-ip="${member.ip}">
|
||||
<div class="member-details">
|
||||
<div class="loading-details">Loading detailed information...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setupMemberCardInteractions() {
|
||||
const closeBtn = this.findElement('.member-overlay-close');
|
||||
if (closeBtn) {
|
||||
this.addEventListener(closeBtn, 'click', () => {
|
||||
this.hide();
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
const memberCard = this.findElement('.member-card');
|
||||
if (memberCard) {
|
||||
const memberDetails = memberCard.querySelector('.member-details');
|
||||
const memberIp = memberCard.dataset.memberIp;
|
||||
await this.expandCard(memberCard, memberIp, memberDetails);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
async expandCard(card, memberIp, memberDetails) {
|
||||
try {
|
||||
const nodeDetailsVM = new NodeDetailsViewModel();
|
||||
const nodeDetailsComponent = new NodeDetailsComponent(memberDetails, nodeDetailsVM, this.eventBus);
|
||||
await nodeDetailsVM.loadNodeDetails(memberIp);
|
||||
|
||||
const nodeStatus = nodeDetailsVM.get('nodeStatus');
|
||||
if (nodeStatus && nodeStatus.labels) {
|
||||
const labelsContainer = document.querySelector('.member-overlay-header .member-labels');
|
||||
if (labelsContainer) {
|
||||
labelsContainer.innerHTML = Object.entries(nodeStatus.labels)
|
||||
.map(([key, value]) => `<span class="label-chip">${key}: ${value}</span>`)
|
||||
.join('');
|
||||
labelsContainer.style.display = 'block';
|
||||
}
|
||||
}
|
||||
nodeDetailsComponent.mount();
|
||||
card.classList.add('expanded');
|
||||
} catch (error) {
|
||||
logger.error('Failed to expand member card:', error);
|
||||
card.classList.add('expanded');
|
||||
const details = card.querySelector('.member-details');
|
||||
if (details) {
|
||||
details.innerHTML = '<div class="error">Failed to load node details</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.TopologyGraphComponent = TopologyGraphComponent;
|
||||
window.MemberCardOverlayComponent = MemberCardOverlayComponent;
|
||||
Reference in New Issue
Block a user