1285 lines
40 KiB
JavaScript
1285 lines
40 KiB
JavaScript
// Cluster Members Component
|
|
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);
|
|
|
|
// Drawer state for desktop (shared singleton)
|
|
this.drawer = new DrawerComponent();
|
|
|
|
// Terminal panel container (shared with drawer)
|
|
this.terminalPanelContainer = null;
|
|
|
|
// Selection state for highlighting
|
|
this.selectedMemberIp = null;
|
|
|
|
// Filter state - now supports multiple active filters
|
|
this.activeFilters = []; // Array of {labelKey, labelValue} objects
|
|
this.allMembers = []; // Store unfiltered members
|
|
}
|
|
|
|
// Determine if we should use desktop drawer behavior
|
|
isDesktop() {
|
|
return this.drawer.isDesktop();
|
|
}
|
|
|
|
// Extract all unique label keys from members
|
|
extractLabelKeys(members) {
|
|
const labelKeys = new Set();
|
|
|
|
members.forEach(member => {
|
|
if (member.labels && typeof member.labels === 'object') {
|
|
Object.keys(member.labels).forEach(key => {
|
|
labelKeys.add(key);
|
|
});
|
|
}
|
|
});
|
|
|
|
return Array.from(labelKeys).sort();
|
|
}
|
|
|
|
// Extract unique values for a specific label key from members
|
|
extractLabelValuesForKey(members, labelKey) {
|
|
const labelValues = new Set();
|
|
|
|
members.forEach(member => {
|
|
if (member.labels && typeof member.labels === 'object' && member.labels[labelKey]) {
|
|
labelValues.add(member.labels[labelKey]);
|
|
}
|
|
});
|
|
|
|
return Array.from(labelValues).sort();
|
|
}
|
|
|
|
// Filter members based on current active filters
|
|
filterMembers(members) {
|
|
if (!this.activeFilters || this.activeFilters.length === 0) {
|
|
return members;
|
|
}
|
|
|
|
return members.filter(member => {
|
|
if (!member.labels || typeof member.labels !== 'object') {
|
|
return false;
|
|
}
|
|
|
|
// All active filters must match (AND logic)
|
|
return this.activeFilters.every(filter => {
|
|
if (filter.labelKey && filter.labelValue) {
|
|
// Both key and value specified - exact match
|
|
return member.labels[filter.labelKey] === filter.labelValue;
|
|
} else if (filter.labelKey && !filter.labelValue) {
|
|
// Only key specified - member must have this key
|
|
return member.labels.hasOwnProperty(filter.labelKey);
|
|
} else if (!filter.labelKey && filter.labelValue) {
|
|
// Only value specified - member must have this value for any key
|
|
return Object.values(member.labels).includes(filter.labelValue);
|
|
}
|
|
return true;
|
|
});
|
|
});
|
|
}
|
|
|
|
// Update filter dropdowns with current label data
|
|
updateFilterDropdowns() {
|
|
// Get currently filtered members to determine available options
|
|
const filteredMembers = this.filterMembers(this.allMembers);
|
|
|
|
// Extract available label keys from filtered members
|
|
const availableLabelKeys = this.extractLabelKeys(filteredMembers);
|
|
|
|
// Update label key dropdown
|
|
const keySelect = document.getElementById('label-key-filter');
|
|
if (keySelect) {
|
|
const currentKey = keySelect.value;
|
|
keySelect.innerHTML = '<option value="">All Labels</option>';
|
|
availableLabelKeys.forEach(key => {
|
|
const option = document.createElement('option');
|
|
option.value = key;
|
|
option.textContent = key;
|
|
keySelect.appendChild(option);
|
|
});
|
|
|
|
// Restore selection if it still exists
|
|
if (currentKey && availableLabelKeys.includes(currentKey)) {
|
|
keySelect.value = currentKey;
|
|
} else {
|
|
keySelect.value = '';
|
|
}
|
|
}
|
|
|
|
// Update label value dropdown based on selected key
|
|
this.updateValueDropdown();
|
|
|
|
// Update clear button state
|
|
this.updateClearButtonState();
|
|
}
|
|
|
|
// Update clear button state based on current active filters
|
|
updateClearButtonState() {
|
|
const clearBtn = document.getElementById('clear-filters-btn');
|
|
if (clearBtn) {
|
|
const hasFilters = this.activeFilters && this.activeFilters.length > 0;
|
|
clearBtn.disabled = !hasFilters;
|
|
logger.debug('ClusterMembersComponent: Clear button state updated:', {
|
|
hasFilters,
|
|
disabled: clearBtn.disabled,
|
|
activeFilters: this.activeFilters
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update the value dropdown based on the currently selected key
|
|
updateValueDropdown() {
|
|
const valueSelect = document.getElementById('label-value-filter');
|
|
if (!valueSelect) return;
|
|
|
|
const currentValue = valueSelect.value;
|
|
valueSelect.innerHTML = '<option value="">All Values</option>';
|
|
|
|
// Get currently filtered members to determine available values
|
|
const filteredMembers = this.filterMembers(this.allMembers);
|
|
|
|
// If a key is selected, show only values for that key from filtered members
|
|
const selectedKey = document.getElementById('label-key-filter')?.value;
|
|
if (selectedKey) {
|
|
const labelValues = this.extractLabelValuesForKey(filteredMembers, selectedKey);
|
|
labelValues.forEach(value => {
|
|
const option = document.createElement('option');
|
|
option.value = value;
|
|
option.textContent = value;
|
|
valueSelect.appendChild(option);
|
|
});
|
|
|
|
// Restore selection if it still exists for this key
|
|
if (currentValue && labelValues.includes(currentValue)) {
|
|
valueSelect.value = currentValue;
|
|
} else {
|
|
valueSelect.value = '';
|
|
}
|
|
} else {
|
|
// If no key is selected, show all unique values from all keys in filtered members
|
|
const allValues = new Set();
|
|
filteredMembers.forEach(member => {
|
|
if (member.labels && typeof member.labels === 'object') {
|
|
Object.values(member.labels).forEach(value => {
|
|
allValues.add(value);
|
|
});
|
|
}
|
|
});
|
|
|
|
const sortedValues = Array.from(allValues).sort();
|
|
sortedValues.forEach(value => {
|
|
const option = document.createElement('option');
|
|
option.value = value;
|
|
option.textContent = value;
|
|
valueSelect.appendChild(option);
|
|
});
|
|
|
|
// Restore selection if it still exists
|
|
if (currentValue && sortedValues.includes(currentValue)) {
|
|
valueSelect.value = currentValue;
|
|
} else {
|
|
valueSelect.value = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add a new filter and create a pill
|
|
addFilter(labelKey, labelValue) {
|
|
// Check if this filter already exists
|
|
const existingFilter = this.activeFilters.find(filter =>
|
|
filter.labelKey === labelKey && filter.labelValue === labelValue
|
|
);
|
|
|
|
if (existingFilter) {
|
|
logger.debug('ClusterMembersComponent: Filter already exists, skipping');
|
|
return;
|
|
}
|
|
|
|
// Add the new filter
|
|
this.activeFilters.push({ labelKey, labelValue });
|
|
|
|
// Create and add the pill
|
|
this.createFilterPill(labelKey, labelValue);
|
|
|
|
// Update dropdowns and apply filter
|
|
this.updateFilterDropdowns();
|
|
this.applyFilter();
|
|
}
|
|
|
|
// Remove a filter and its pill
|
|
removeFilter(labelKey, labelValue) {
|
|
// Remove from active filters
|
|
this.activeFilters = this.activeFilters.filter(filter =>
|
|
!(filter.labelKey === labelKey && filter.labelValue === labelValue)
|
|
);
|
|
|
|
// Remove the pill from DOM
|
|
this.removeFilterPill(labelKey, labelValue);
|
|
|
|
// Update dropdowns and apply filter
|
|
this.updateFilterDropdowns();
|
|
this.applyFilter();
|
|
}
|
|
|
|
// Create a filter pill element
|
|
createFilterPill(labelKey, labelValue) {
|
|
const pillsContainer = document.getElementById('filter-pills-container');
|
|
if (!pillsContainer) return;
|
|
|
|
const pill = document.createElement('div');
|
|
pill.className = 'filter-pill';
|
|
pill.dataset.labelKey = labelKey;
|
|
pill.dataset.labelValue = labelValue;
|
|
|
|
// Create pill text
|
|
const pillText = document.createElement('span');
|
|
pillText.className = 'filter-pill-text';
|
|
|
|
if (labelKey && labelValue) {
|
|
pillText.textContent = `${labelKey}: ${labelValue}`;
|
|
} else if (labelKey && !labelValue) {
|
|
pillText.textContent = `${labelKey}: *`;
|
|
} else if (!labelKey && labelValue) {
|
|
pillText.textContent = `*: ${labelValue}`;
|
|
}
|
|
|
|
// Create remove button
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.className = 'filter-pill-remove';
|
|
removeBtn.title = 'Remove filter';
|
|
removeBtn.innerHTML = `
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M18 6L6 18M6 6l12 12"/>
|
|
</svg>
|
|
`;
|
|
|
|
// Add click handler for remove button
|
|
removeBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.removeFilter(labelKey, labelValue);
|
|
});
|
|
|
|
// Assemble pill
|
|
pill.appendChild(pillText);
|
|
pill.appendChild(removeBtn);
|
|
|
|
// Add to container
|
|
pillsContainer.appendChild(pill);
|
|
}
|
|
|
|
// Remove a filter pill from DOM
|
|
removeFilterPill(labelKey, labelValue) {
|
|
const pillsContainer = document.getElementById('filter-pills-container');
|
|
if (!pillsContainer) return;
|
|
|
|
const pill = pillsContainer.querySelector(`[data-label-key="${labelKey}"][data-label-value="${labelValue}"]`);
|
|
if (pill) {
|
|
pill.remove();
|
|
}
|
|
}
|
|
|
|
// Clear all filters and pills
|
|
clearAllFilters() {
|
|
this.activeFilters = [];
|
|
|
|
// Clear all pills
|
|
const pillsContainer = document.getElementById('filter-pills-container');
|
|
if (pillsContainer) {
|
|
pillsContainer.innerHTML = '';
|
|
}
|
|
|
|
// Reset dropdowns
|
|
const keySelect = document.getElementById('label-key-filter');
|
|
const valueSelect = document.getElementById('label-value-filter');
|
|
if (keySelect) keySelect.value = '';
|
|
if (valueSelect) valueSelect.value = '';
|
|
|
|
// Update dropdowns and apply filter
|
|
this.updateFilterDropdowns();
|
|
this.applyFilter();
|
|
}
|
|
|
|
// Apply filter and re-render
|
|
applyFilter() {
|
|
const filteredMembers = this.filterMembers(this.allMembers);
|
|
this.renderFilteredMembers(filteredMembers);
|
|
}
|
|
|
|
// Set up filter event listeners
|
|
setupFilterListeners() {
|
|
logger.debug('ClusterMembersComponent: Setting up filter listeners...');
|
|
const keySelect = document.getElementById('label-key-filter');
|
|
const valueSelect = document.getElementById('label-value-filter');
|
|
const clearBtn = document.getElementById('clear-filters-btn');
|
|
|
|
logger.debug('ClusterMembersComponent: Filter elements found:', {
|
|
keySelect: !!keySelect,
|
|
valueSelect: !!valueSelect,
|
|
clearBtn: !!clearBtn
|
|
});
|
|
|
|
if (keySelect) {
|
|
keySelect.addEventListener('change', (e) => {
|
|
const selectedKey = e.target.value;
|
|
const selectedValue = valueSelect ? valueSelect.value : '';
|
|
|
|
// If both key and value are selected, add as a filter pill
|
|
if (selectedKey && selectedValue) {
|
|
this.addFilter(selectedKey, selectedValue);
|
|
// Reset dropdowns after adding filter
|
|
keySelect.value = '';
|
|
valueSelect.value = '';
|
|
} else if (selectedKey && !selectedValue) {
|
|
// Only key selected, update value dropdown
|
|
this.updateValueDropdown();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (valueSelect) {
|
|
valueSelect.addEventListener('change', (e) => {
|
|
const selectedValue = e.target.value;
|
|
const selectedKey = keySelect ? keySelect.value : '';
|
|
|
|
// If both key and value are selected, add as a filter pill
|
|
if (selectedKey && selectedValue) {
|
|
this.addFilter(selectedKey, selectedValue);
|
|
// Reset dropdowns after adding filter
|
|
keySelect.value = '';
|
|
valueSelect.value = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
if (clearBtn) {
|
|
clearBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
logger.debug('ClusterMembersComponent: Clear filters button clicked');
|
|
this.clearAllFilters();
|
|
});
|
|
} else {
|
|
logger.warn('ClusterMembersComponent: Clear filters button not found');
|
|
}
|
|
}
|
|
|
|
|
|
openDrawerForMember(memberIp) {
|
|
// Set selected member and update highlighting
|
|
this.setSelectedMember(memberIp);
|
|
|
|
// Get display name for drawer title
|
|
let displayName = memberIp;
|
|
try {
|
|
const members = (this.viewModel && typeof this.viewModel.get === 'function') ? this.viewModel.get('members') : [];
|
|
const member = Array.isArray(members) ? members.find(m => m && m.ip === memberIp) : null;
|
|
const hostname = (member && member.hostname) ? member.hostname : '';
|
|
const ip = (member && member.ip) ? member.ip : memberIp;
|
|
|
|
if (hostname && ip) {
|
|
displayName = `${hostname} - ${ip}`;
|
|
} else if (hostname) {
|
|
displayName = hostname;
|
|
} else if (ip) {
|
|
displayName = ip;
|
|
}
|
|
} catch (_) {
|
|
// no-op if anything goes wrong, default title remains
|
|
}
|
|
|
|
// Open drawer with content callback
|
|
this.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => {
|
|
// Load and mount NodeDetails into drawer
|
|
const nodeDetailsVM = new NodeDetailsViewModel();
|
|
const nodeDetailsComponent = new NodeDetailsComponent(contentContainer, nodeDetailsVM, this.eventBus);
|
|
setActiveComponent(nodeDetailsComponent);
|
|
|
|
nodeDetailsVM.loadNodeDetails(memberIp).then(() => {
|
|
nodeDetailsComponent.mount();
|
|
}).catch((error) => {
|
|
logger.error('Failed to load node details for drawer:', error);
|
|
contentContainer.innerHTML = `
|
|
<div class="error">
|
|
<strong>Error loading node details:</strong><br>
|
|
${this.escapeHtml(error.message)}
|
|
</div>
|
|
`;
|
|
});
|
|
}, null, () => {
|
|
// Close callback - clear selection when drawer is closed
|
|
this.clearSelectedMember();
|
|
});
|
|
}
|
|
|
|
closeDrawer() {
|
|
this.drawer.closeDrawer();
|
|
}
|
|
|
|
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();
|
|
|
|
// Set up filter listeners
|
|
this.setupFilterListeners();
|
|
|
|
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
|
|
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.shouldSkipFullRender(newMembers, previousMembers)) {
|
|
// Perform partial update
|
|
logger.debug('ClusterMembersComponent: Skipping full render, 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 skip full re-render during update
|
|
shouldSkipFullRender(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 skip full re-render
|
|
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
|
|
updateMembersPartially(newMembers, previousMembers) {
|
|
logger.debug('ClusterMembersComponent: Performing partial update');
|
|
|
|
// 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)) {
|
|
logger.debug('ClusterMembersComponent: Member changed, updating card for:', newMember.ip);
|
|
this.updateMemberCard(newMember);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Check if a specific member has changed
|
|
hasMemberChanged(newMember, prevMember) {
|
|
// Check basic properties
|
|
if (newMember.status !== prevMember.status ||
|
|
newMember.latency !== prevMember.latency ||
|
|
newMember.hostname !== prevMember.hostname) {
|
|
return true;
|
|
}
|
|
|
|
// Check labels for changes
|
|
const newLabels = newMember.labels || {};
|
|
const prevLabels = prevMember.labels || {};
|
|
|
|
// Compare label keys
|
|
const newKeys = Object.keys(newLabels);
|
|
const prevKeys = Object.keys(prevLabels);
|
|
|
|
if (newKeys.length !== prevKeys.length) {
|
|
return true;
|
|
}
|
|
|
|
// Compare label values
|
|
for (const key of newKeys) {
|
|
if (newLabels[key] !== prevLabels[key]) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// 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) {
|
|
let statusClass, statusIcon;
|
|
if (member.status && member.status.toUpperCase() === 'ACTIVE') {
|
|
statusClass = 'status-online';
|
|
statusIcon = window.icon('dotGreen', { width: 12, height: 12 });
|
|
} else if (member.status && member.status.toUpperCase() === 'INACTIVE') {
|
|
statusClass = 'status-dead';
|
|
statusIcon = window.icon('dotRed', { width: 12, height: 12 });
|
|
} else {
|
|
statusClass = 'status-offline';
|
|
statusIcon = window.icon('dotRed', { width: 12, height: 12 });
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
// Update labels (add/update/remove labels row as needed)
|
|
const labels = (member && typeof member.labels === 'object') ? member.labels : {};
|
|
const hasLabels = labels && Object.keys(labels).length > 0;
|
|
let labelsRow = card.querySelector('.member-row-2');
|
|
if (hasLabels) {
|
|
const chipsHtml = Object.entries(labels)
|
|
.map(([key, value]) => `<span class="label-chip">${this.escapeHtml(String(key))}: ${this.escapeHtml(String(value))}</span>`)
|
|
.join('');
|
|
if (labelsRow) {
|
|
const labelsContainer = labelsRow.querySelector('.member-labels');
|
|
if (labelsContainer) {
|
|
labelsContainer.innerHTML = chipsHtml;
|
|
}
|
|
} else {
|
|
// Create labels row below row-1
|
|
const memberInfo = card.querySelector('.member-info');
|
|
if (memberInfo) {
|
|
labelsRow = document.createElement('div');
|
|
labelsRow.className = 'member-row-2';
|
|
const labelsContainer = document.createElement('div');
|
|
labelsContainer.className = 'member-labels';
|
|
labelsContainer.innerHTML = chipsHtml;
|
|
labelsRow.appendChild(labelsContainer);
|
|
// Insert after the first row if present, otherwise append
|
|
const firstRow = memberInfo.querySelector('.member-row-1');
|
|
if (firstRow && firstRow.nextSibling) {
|
|
memberInfo.insertBefore(labelsRow, firstRow.nextSibling);
|
|
} else {
|
|
memberInfo.appendChild(labelsRow);
|
|
}
|
|
}
|
|
}
|
|
} else if (labelsRow) {
|
|
// No labels now: remove the labels row if it exists
|
|
labelsRow.remove();
|
|
}
|
|
}
|
|
|
|
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">${window.icon('cluster', { width: 24, height: 24 })}</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');
|
|
|
|
// Store all members for filtering
|
|
this.allMembers = members;
|
|
|
|
// Update filter dropdowns with current label data
|
|
this.updateFilterDropdowns();
|
|
|
|
// Apply current filter to get filtered members
|
|
const filteredMembers = this.filterMembers(members);
|
|
|
|
const membersHTML = filteredMembers.map(member => {
|
|
let statusClass, statusIcon;
|
|
if (member.status && member.status.toUpperCase() === 'ACTIVE') {
|
|
statusClass = 'status-online';
|
|
statusIcon = window.icon('dotGreen', { width: 12, height: 12 });
|
|
} else if (member.status && member.status.toUpperCase() === 'INACTIVE') {
|
|
statusClass = 'status-dead';
|
|
statusIcon = window.icon('dotRed', { width: 12, height: 12 });
|
|
} else {
|
|
statusClass = 'status-offline';
|
|
statusIcon = window.icon('dotRed', { width: 12, height: 12 });
|
|
}
|
|
|
|
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="member-actions">
|
|
<button class="member-terminal-btn" title="Open Terminal" aria-label="Open Terminal">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M4 17l6-6-6-6"></path>
|
|
<path d="M12 19h8"></path>
|
|
</svg>
|
|
</button>
|
|
<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>
|
|
<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(filteredMembers);
|
|
}
|
|
|
|
// Apply filter and re-render
|
|
applyFilter() {
|
|
const filteredMembers = this.filterMembers(this.allMembers);
|
|
this.renderFilteredMembers(filteredMembers);
|
|
}
|
|
|
|
// Render filtered members without updating dropdowns
|
|
renderFilteredMembers(filteredMembers) {
|
|
logger.debug('ClusterMembersComponent: renderFilteredMembers() called with', filteredMembers.length, 'members');
|
|
|
|
const membersHTML = filteredMembers.map(member => {
|
|
let statusClass, statusIcon;
|
|
if (member.status && member.status.toUpperCase() === 'ACTIVE') {
|
|
statusClass = 'status-online';
|
|
statusIcon = window.icon('dotGreen', { width: 12, height: 12 });
|
|
} else if (member.status && member.status.toUpperCase() === 'INACTIVE') {
|
|
statusClass = 'status-dead';
|
|
statusIcon = window.icon('dotRed', { width: 12, height: 12 });
|
|
} else {
|
|
statusClass = 'status-offline';
|
|
statusIcon = window.icon('dotRed', { width: 12, height: 12 });
|
|
}
|
|
|
|
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="member-actions">
|
|
<button class="member-terminal-btn" title="Open Terminal" aria-label="Open Terminal">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M4 17l6-6-6-6"></path>
|
|
<path d="M12 19h8"></path>
|
|
</svg>
|
|
</button>
|
|
<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>
|
|
<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(filteredMembers);
|
|
}
|
|
|
|
setupMemberCards(members) {
|
|
setTimeout(() => {
|
|
this.findAllElements('.member-card').forEach((card, index) => {
|
|
const terminalBtn = card.querySelector('.member-terminal-btn');
|
|
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;
|
|
|
|
// On desktop, open slide-in drawer instead of inline expand
|
|
if (this.isDesktop()) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.openDrawerForMember(memberIp);
|
|
return;
|
|
}
|
|
|
|
// Mobile/low-res: keep inline expand/collapse
|
|
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();
|
|
if (this.isDesktop()) {
|
|
this.openDrawerForMember(memberIp);
|
|
return;
|
|
}
|
|
const isExpanding = !card.classList.contains('expanded');
|
|
if (isExpanding) {
|
|
await this.expandCard(card, memberIp, memberDetails);
|
|
} else {
|
|
this.collapseCard(card, expandIcon);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (terminalBtn) {
|
|
this.addEventListener(terminalBtn, 'click', async (e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
try {
|
|
if (!window.TerminalPanel) return;
|
|
this.ensureTerminalContainer();
|
|
const panel = window.TerminalPanel;
|
|
const wasMinimized = panel.isMinimized;
|
|
panel.open(this.terminalPanelContainer, memberIp);
|
|
if (wasMinimized && panel.restore) {
|
|
panel.restore();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to open member terminal:', err);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}, 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;
|
|
}
|
|
|
|
// Set selected member and update highlighting
|
|
setSelectedMember(memberIp) {
|
|
// Clear previous selection
|
|
this.clearSelectedMember();
|
|
|
|
// Set new selection
|
|
this.selectedMemberIp = memberIp;
|
|
|
|
// Add selected class to the member card
|
|
const card = this.findElement(`[data-member-ip="${memberIp}"]`);
|
|
if (card) {
|
|
card.classList.add('selected');
|
|
}
|
|
}
|
|
|
|
// Clear selected member highlighting
|
|
clearSelectedMember() {
|
|
if (this.selectedMemberIp) {
|
|
const card = this.findElement(`[data-member-ip="${this.selectedMemberIp}"]`);
|
|
if (card) {
|
|
card.classList.remove('selected');
|
|
}
|
|
this.selectedMemberIp = null;
|
|
}
|
|
}
|
|
|
|
ensureTerminalContainer() {
|
|
if (!this.terminalPanelContainer) {
|
|
try {
|
|
const drawer = this.drawer;
|
|
if (drawer && drawer.ensureDrawer) {
|
|
drawer.ensureDrawer();
|
|
this.terminalPanelContainer = drawer.terminalPanelContainer;
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to ensure terminal container:', err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
window.ClusterMembersComponent = ClusterMembersComponent;
|