feat: memberlist filter

This commit is contained in:
2025-10-14 22:19:04 +02:00
parent e58604d726
commit 2cc62d1ee2
3 changed files with 450 additions and 3 deletions

View File

@@ -82,6 +82,22 @@
</svg>
</button>
</div>
<div class="cluster-filters">
<div class="filter-group">
<label for="label-key-filter" class="filter-label">Filter by Label:</label>
<select id="label-key-filter" class="filter-select">
<option value="">All Labels</option>
</select>
<select id="label-value-filter" class="filter-select">
<option value="">All Values</option>
</select>
<button id="clear-filters-btn" class="clear-filters-btn" title="Clear filters">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</div>
<button class="refresh-btn" id="refresh-cluster-btn">
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"

View File

@@ -28,6 +28,13 @@ class ClusterMembersComponent extends Component {
// Selection state for highlighting
this.selectedMemberIp = null;
// Filter state
this.currentFilter = {
labelKey: '',
labelValue: ''
};
this.allMembers = []; // Store unfiltered members
}
// Determine if we should use desktop drawer behavior
@@ -35,6 +42,219 @@ class ClusterMembersComponent extends Component {
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 filter criteria
filterMembers(members) {
if (!this.currentFilter.labelKey && !this.currentFilter.labelValue) {
return members;
}
return members.filter(member => {
if (!member.labels || typeof member.labels !== 'object') {
return false;
}
// If only label key is specified, show all members with that key
if (this.currentFilter.labelKey && !this.currentFilter.labelValue) {
return member.labels.hasOwnProperty(this.currentFilter.labelKey);
}
// If both key and value are specified, show members with exact match
if (this.currentFilter.labelKey && this.currentFilter.labelValue) {
return member.labels[this.currentFilter.labelKey] === this.currentFilter.labelValue;
}
// If only value is specified, show members with that value for any key
if (!this.currentFilter.labelKey && this.currentFilter.labelValue) {
return Object.values(member.labels).includes(this.currentFilter.labelValue);
}
return true;
});
}
// Update filter dropdowns with current label data
updateFilterDropdowns() {
// Always use unfiltered member list for dropdown options
const labelKeys = this.extractLabelKeys(this.allMembers);
// Update label key dropdown
const keySelect = document.getElementById('label-key-filter');
if (keySelect) {
const currentKey = keySelect.value;
keySelect.innerHTML = '<option value="">All Labels</option>';
labelKeys.forEach(key => {
const option = document.createElement('option');
option.value = key;
option.textContent = key;
keySelect.appendChild(option);
});
// Restore selection if it still exists
if (currentKey && labelKeys.includes(currentKey)) {
keySelect.value = currentKey;
} else {
keySelect.value = '';
this.currentFilter.labelKey = '';
}
}
// Update label value dropdown based on selected key
this.updateValueDropdown();
// Update clear button state
this.updateClearButtonState();
}
// Update clear button state based on current filter
updateClearButtonState() {
const clearBtn = document.getElementById('clear-filters-btn');
if (clearBtn) {
const hasFilters = this.currentFilter.labelKey || this.currentFilter.labelValue;
clearBtn.disabled = !hasFilters;
logger.debug('ClusterMembersComponent: Clear button state updated:', {
hasFilters,
disabled: clearBtn.disabled,
currentFilter: this.currentFilter
});
}
}
// 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>';
// If a key is selected, show only values for that key
if (this.currentFilter.labelKey) {
const labelValues = this.extractLabelValuesForKey(this.allMembers, this.currentFilter.labelKey);
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 = '';
this.currentFilter.labelValue = '';
}
} else {
// If no key is selected, show all unique values from all keys
const allValues = new Set();
this.allMembers.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 = '';
this.currentFilter.labelValue = '';
}
}
}
// 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) => {
this.currentFilter.labelKey = e.target.value;
// When key changes, reset value and update value dropdown
this.currentFilter.labelValue = '';
this.updateValueDropdown();
this.updateClearButtonState();
this.applyFilter();
});
}
if (valueSelect) {
valueSelect.addEventListener('change', (e) => {
this.currentFilter.labelValue = e.target.value;
this.updateClearButtonState();
this.applyFilter();
});
}
if (clearBtn) {
clearBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
logger.debug('ClusterMembersComponent: Clear filters button clicked');
this.currentFilter = { labelKey: '', labelValue: '' };
if (keySelect) keySelect.value = '';
if (valueSelect) valueSelect.value = '';
this.updateClearButtonState();
this.applyFilter();
});
} else {
logger.warn('ClusterMembersComponent: Clear filters button not found');
}
}
openDrawerForMember(memberIp) {
// Set selected member and update highlighting
@@ -98,6 +318,9 @@ class ClusterMembersComponent extends Component {
// Set up loading timeout safeguard
this.setupLoadingTimeout();
// Set up filter listeners
this.setupFilterListeners();
logger.debug('ClusterMembersComponent: Mounted successfully');
}
@@ -413,7 +636,16 @@ class ClusterMembersComponent extends Component {
renderMembers(members) {
logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members');
const membersHTML = members.map(member => {
// 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';
@@ -477,7 +709,84 @@ class ClusterMembersComponent extends Component {
logger.debug('ClusterMembersComponent: Setting HTML, length:', membersHTML.length);
this.setHTML('', membersHTML);
logger.debug('ClusterMembersComponent: HTML set, setting up member cards...');
this.setupMemberCards(members);
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) {

View File

@@ -95,7 +95,10 @@ p {
}
.cluster-header-left {
/* Placeholder for future content if needed */
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.primary-node-info {
@@ -176,6 +179,95 @@ p {
background: rgba(255, 255, 255, 0.1);
}
/* Cluster Filters */
.cluster-filters {
display: flex;
align-items: center;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.filter-label {
font-size: 0.85rem;
color: var(--text-secondary);
white-space: nowrap;
}
.filter-select {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: var(--text-primary);
font-size: 0.85rem;
padding: 0.3rem 0.5rem;
min-width: 120px;
cursor: pointer;
transition: all 0.2s ease;
}
.filter-select:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
}
.filter-select:focus {
outline: none;
background: rgba(255, 255, 255, 0.15);
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb), 0.2);
}
.filter-select option {
background: #1a202c;
color: var(--text-primary);
padding: 0.75rem 1rem;
font-size: 0.85rem;
font-weight: 500;
border: none;
}
.filter-select option:hover {
background: #2d3748;
}
.filter-select option:checked {
background: #667eea;
color: var(--text-primary);
font-weight: 600;
}
.clear-filters-btn {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: var(--text-secondary);
padding: 0.3rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.clear-filters-btn:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
color: var(--text-primary);
}
.clear-filters-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.primary-node-refresh:active {
transform: scale(0.95);
}
@@ -4002,9 +4094,39 @@ select.param-input:focus {
.cluster-header-left {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: stretch;
}
.cluster-filters {
justify-content: center;
}
.filter-group {
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
width: 100%;
}
.filter-label {
text-align: center;
font-size: 0.9rem;
}
.filter-select {
width: 100%;
min-width: auto;
padding: 0.5rem;
font-size: 0.9rem;
}
.clear-filters-btn {
align-self: center;
padding: 0.5rem;
}
.primary-node-info {
flex-direction: row;
align-items: center;