feat: filter cluster members by multiple labels

This commit is contained in:
2025-10-16 20:47:07 +02:00
parent e431d3b551
commit 3314f7e10a
5 changed files with 406 additions and 53 deletions

View File

@@ -91,7 +91,10 @@
<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">
<div class="filter-pills-container" id="filter-pills-container">
<!-- Active filter pills will be dynamically added here -->
</div>
<button id="clear-filters-btn" class="clear-filters-btn" title="Clear all 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>

View File

@@ -29,11 +29,8 @@ class ClusterMembersComponent extends Component {
// Selection state for highlighting
this.selectedMemberIp = null;
// Filter state
this.currentFilter = {
labelKey: '',
labelValue: ''
};
// Filter state - now supports multiple active filters
this.activeFilters = []; // Array of {labelKey, labelValue} objects
this.allMembers = []; // Store unfiltered members
}
@@ -70,9 +67,9 @@ class ClusterMembersComponent extends Component {
return Array.from(labelValues).sort();
}
// Filter members based on current filter criteria
// Filter members based on current active filters
filterMembers(members) {
if (!this.currentFilter.labelKey && !this.currentFilter.labelValue) {
if (!this.activeFilters || this.activeFilters.length === 0) {
return members;
}
@@ -81,36 +78,37 @@ class ClusterMembersComponent extends Component {
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;
// 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() {
// Always use unfiltered member list for dropdown options
const labelKeys = this.extractLabelKeys(this.allMembers);
// 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>';
labelKeys.forEach(key => {
availableLabelKeys.forEach(key => {
const option = document.createElement('option');
option.value = key;
option.textContent = key;
@@ -118,11 +116,10 @@ class ClusterMembersComponent extends Component {
});
// Restore selection if it still exists
if (currentKey && labelKeys.includes(currentKey)) {
if (currentKey && availableLabelKeys.includes(currentKey)) {
keySelect.value = currentKey;
} else {
keySelect.value = '';
this.currentFilter.labelKey = '';
}
}
@@ -133,16 +130,16 @@ class ClusterMembersComponent extends Component {
this.updateClearButtonState();
}
// Update clear button state based on current filter
// Update clear button state based on current active filters
updateClearButtonState() {
const clearBtn = document.getElementById('clear-filters-btn');
if (clearBtn) {
const hasFilters = this.currentFilter.labelKey || this.currentFilter.labelValue;
const hasFilters = this.activeFilters && this.activeFilters.length > 0;
clearBtn.disabled = !hasFilters;
logger.debug('ClusterMembersComponent: Clear button state updated:', {
hasFilters,
disabled: clearBtn.disabled,
currentFilter: this.currentFilter
activeFilters: this.activeFilters
});
}
}
@@ -155,9 +152,13 @@ class ClusterMembersComponent extends Component {
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);
// 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;
@@ -170,12 +171,11 @@ class ClusterMembersComponent extends Component {
valueSelect.value = currentValue;
} else {
valueSelect.value = '';
this.currentFilter.labelValue = '';
}
} else {
// If no key is selected, show all unique values from all keys
// If no key is selected, show all unique values from all keys in filtered members
const allValues = new Set();
this.allMembers.forEach(member => {
filteredMembers.forEach(member => {
if (member.labels && typeof member.labels === 'object') {
Object.values(member.labels).forEach(value => {
allValues.add(value);
@@ -196,11 +196,126 @@ class ClusterMembersComponent extends Component {
valueSelect.value = currentValue;
} else {
valueSelect.value = '';
this.currentFilter.labelValue = '';
}
}
}
// 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);
@@ -222,20 +337,34 @@ class ClusterMembersComponent extends Component {
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();
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) => {
this.currentFilter.labelValue = e.target.value;
this.updateClearButtonState();
this.applyFilter();
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 = '';
}
});
}
@@ -244,11 +373,7 @@ class ClusterMembersComponent extends Component {
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();
this.clearAllFilters();
});
} else {
logger.warn('ClusterMembersComponent: Clear filters button not found');

View File

@@ -184,6 +184,64 @@ p {
align-items: center;
}
.filter-pills-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
min-height: 1.5rem;
margin: 0 0.5rem;
}
.filter-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8rem;
padding: 0.3rem 0.7rem;
border-radius: 9999px;
background: rgba(30, 58, 138, 0.35);
border: 1px solid rgba(59, 130, 246, 0.4);
color: #dbeafe;
white-space: nowrap;
transition: all 0.2s ease;
}
.filter-pill:hover {
background: rgba(30, 58, 138, 0.45);
border-color: rgba(59, 130, 246, 0.5);
}
.filter-pill-text {
white-space: nowrap;
}
.filter-pill-remove {
background: none;
border: none;
color: #dbeafe;
cursor: pointer;
padding: 0;
margin-left: 0.25rem;
border-radius: 50%;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.filter-pill-remove:hover {
background: rgba(59, 130, 246, 0.3);
color: #ffffff;
}
.filter-pill-remove svg {
width: 10px;
height: 10px;
}
.filter-group {
display: flex;
align-items: center;