feat: label editor
This commit is contained in:
@@ -220,6 +220,190 @@ class NodeDetailsComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Labels editor handlers
|
||||
handleAddLabel() {
|
||||
const keyInput = this.findElement('#label-key');
|
||||
const valueInput = this.findElement('#label-value');
|
||||
|
||||
if (!keyInput || !valueInput) return;
|
||||
|
||||
const key = keyInput.value.trim();
|
||||
const value = valueInput.value.trim();
|
||||
|
||||
if (!key || !value) return;
|
||||
|
||||
// Check if key already exists
|
||||
const existingLabel = this.findElement(`.label-item[data-key="${this.escapeHtml(key)}"]`);
|
||||
if (existingLabel) {
|
||||
this.showMessage(`Label '${key}' already exists`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the label to the UI
|
||||
this.addLabelToUI(key, value);
|
||||
|
||||
// Clear inputs
|
||||
keyInput.value = '';
|
||||
valueInput.value = '';
|
||||
|
||||
// Enable save button
|
||||
const saveBtn = this.findElement('.save-labels-btn');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
|
||||
this.showMessage(`Label '${key}' added`, 'success');
|
||||
}
|
||||
|
||||
handleRemoveLabel(key) {
|
||||
const labelItem = this.findElement(`.label-item[data-key="${this.escapeHtml(key)}"]`);
|
||||
if (labelItem) {
|
||||
labelItem.remove();
|
||||
|
||||
// Enable save button
|
||||
const saveBtn = this.findElement('.save-labels-btn');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
|
||||
this.showMessage(`Label '${key}' removed`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
async handleSaveLabels() {
|
||||
const saveBtn = this.findElement('.save-labels-btn');
|
||||
if (!saveBtn) return;
|
||||
|
||||
// Get current labels from UI
|
||||
const labels = this.getCurrentLabelsFromUI();
|
||||
|
||||
// Show loading state
|
||||
const originalText = saveBtn.innerHTML;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = `
|
||||
<svg class="spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M1 4v6h6" />
|
||||
<path d="M23 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>
|
||||
Saving...
|
||||
`;
|
||||
|
||||
try {
|
||||
const nodeIp = this.viewModel.get('nodeIp');
|
||||
if (!nodeIp) {
|
||||
throw new Error('No node IP available');
|
||||
}
|
||||
|
||||
await window.apiClient.setNodeLabels(nodeIp, labels);
|
||||
|
||||
// Disable save button
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = originalText;
|
||||
|
||||
this.showMessage('Labels saved successfully', 'success');
|
||||
|
||||
// Trigger a cluster update to refresh member cards with new labels
|
||||
// Add a small delay to allow the node to update its member list
|
||||
setTimeout(() => {
|
||||
this.triggerClusterUpdate();
|
||||
}, 1000);
|
||||
|
||||
// Don't refresh the entire node details - just update the labels in the current view
|
||||
// The labels are already updated in the UI, no need to reload everything
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to save labels:', error);
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = originalText;
|
||||
this.showMessage(`Failed to save labels: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
addLabelToUI(key, value) {
|
||||
const labelsList = this.findElement('.labels-list');
|
||||
if (!labelsList) return;
|
||||
|
||||
// Remove no-labels message if it exists
|
||||
const noLabels = labelsList.querySelector('.no-labels');
|
||||
if (noLabels) {
|
||||
noLabels.remove();
|
||||
}
|
||||
|
||||
const labelHtml = `
|
||||
<div class="label-item" data-key="${this.escapeHtml(key)}">
|
||||
<div class="label-content">
|
||||
<span class="label-key">${this.escapeHtml(key)}</span>
|
||||
<span class="label-separator">=</span>
|
||||
<span class="label-value">${this.escapeHtml(value)}</span>
|
||||
</div>
|
||||
<button class="label-remove-btn" data-key="${this.escapeHtml(key)}" title="Remove label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
labelsList.insertAdjacentHTML('beforeend', labelHtml);
|
||||
|
||||
// Re-setup event listeners for the new remove button
|
||||
const newRemoveBtn = labelsList.querySelector(`.label-remove-btn[data-key="${this.escapeHtml(key)}"]`);
|
||||
if (newRemoveBtn) {
|
||||
this.addEventListener(newRemoveBtn, 'click', (e) => {
|
||||
const key = e.target.closest('.label-remove-btn').dataset.key;
|
||||
this.handleRemoveLabel(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentLabelsFromUI() {
|
||||
const labels = {};
|
||||
const labelItems = this.findAllElements('.label-item');
|
||||
|
||||
labelItems.forEach(item => {
|
||||
const key = item.dataset.key;
|
||||
const valueElement = item.querySelector('.label-value');
|
||||
if (key && valueElement) {
|
||||
labels[key] = valueElement.textContent;
|
||||
}
|
||||
});
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
showMessage(message, type = 'info') {
|
||||
// Create a temporary message element
|
||||
const messageEl = document.createElement('div');
|
||||
messageEl.className = `labels-message labels-message-${type}`;
|
||||
messageEl.textContent = message;
|
||||
|
||||
// Add to the labels section
|
||||
const labelsSection = this.findElement('.labels-section');
|
||||
if (labelsSection) {
|
||||
labelsSection.appendChild(messageEl);
|
||||
|
||||
// Remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
if (messageEl.parentNode) {
|
||||
messageEl.remove();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async triggerClusterUpdate() {
|
||||
try {
|
||||
// Trigger a server-side cluster refresh to update the UI
|
||||
await window.apiClient.request('/api/cluster/refresh', {
|
||||
method: 'POST',
|
||||
body: { reason: 'labels_updated' }
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger cluster refresh:', error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const nodeStatus = this.viewModel.get('nodeStatus');
|
||||
const tasks = this.viewModel.get('tasks');
|
||||
@@ -259,6 +443,7 @@ class NodeDetailsComponent extends Component {
|
||||
<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 === 'labels' ? 'active' : ''}" data-tab="labels">Labels</button>
|
||||
<button class="tab-button ${activeTab === 'endpoints' ? 'active' : ''}" data-tab="endpoints">Endpoints</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>
|
||||
@@ -275,6 +460,10 @@ class NodeDetailsComponent extends Component {
|
||||
${this.renderStatusTab(nodeStatus, monitoringResources)}
|
||||
</div>
|
||||
|
||||
<div class="tab-content ${activeTab === 'labels' ? 'active' : ''}" id="labels-tab">
|
||||
${this.renderLabelsTab(nodeStatus)}
|
||||
</div>
|
||||
|
||||
<div class="tab-content ${activeTab === 'endpoints' ? 'active' : ''}" id="endpoints-tab">
|
||||
${this.renderEndpointsTab(endpoints)}
|
||||
</div>
|
||||
@@ -292,6 +481,7 @@ class NodeDetailsComponent extends Component {
|
||||
|
||||
this.setHTML('', html);
|
||||
this.setupTabs();
|
||||
this.setupLabelsEditor();
|
||||
this.setupTabRefreshButton();
|
||||
// Restore last active tab from view model if available
|
||||
const restored = this.viewModel && typeof this.viewModel.get === 'function' ? this.viewModel.get('activeTab') : null;
|
||||
@@ -325,6 +515,9 @@ class NodeDetailsComponent extends Component {
|
||||
await this.viewModel.loadEndpointsData();
|
||||
} else if (activeTab === 'tasks' && typeof this.viewModel.loadTasksData === 'function') {
|
||||
await this.viewModel.loadTasksData();
|
||||
} else if (activeTab === 'labels' && nodeIp && typeof this.viewModel.loadNodeDetails === 'function') {
|
||||
// labels tab: refresh node details to get updated labels
|
||||
await this.viewModel.loadNodeDetails(nodeIp);
|
||||
} else if (activeTab === 'status' && typeof this.viewModel.loadMonitoringResources === 'function') {
|
||||
// status tab: load monitoring resources
|
||||
await this.viewModel.loadMonitoringResources();
|
||||
@@ -512,6 +705,84 @@ class NodeDetailsComponent extends Component {
|
||||
`;
|
||||
}
|
||||
|
||||
renderLabelsTab(nodeStatus) {
|
||||
const labels = nodeStatus?.labels || {};
|
||||
const labelsArray = Object.entries(labels);
|
||||
|
||||
let html = `
|
||||
<div class="labels-section">
|
||||
<div class="labels-header">
|
||||
<h3>Node Labels</h3>
|
||||
<p class="labels-description">Manage custom labels for this node. Labels help organize and identify nodes in your cluster.</p>
|
||||
</div>
|
||||
|
||||
<div class="labels-list">
|
||||
`;
|
||||
|
||||
if (labelsArray.length === 0) {
|
||||
html += `
|
||||
<div class="no-labels">
|
||||
<div>No labels configured</div>
|
||||
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">Add labels to organize and identify this node</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
labelsArray.forEach(([key, value]) => {
|
||||
html += `
|
||||
<div class="label-item" data-key="${this.escapeHtml(key)}">
|
||||
<div class="label-content">
|
||||
<span class="label-key">${this.escapeHtml(key)}</span>
|
||||
<span class="label-separator">=</span>
|
||||
<span class="label-value">${this.escapeHtml(value)}</span>
|
||||
</div>
|
||||
<button class="label-remove-btn" data-key="${this.escapeHtml(key)}" title="Remove label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
|
||||
<div class="add-label-section">
|
||||
<div class="add-label-form">
|
||||
<div class="form-group">
|
||||
<label for="label-key">Key:</label>
|
||||
<input type="text" id="label-key" placeholder="e.g., environment" maxlength="50">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="label-value">Value:</label>
|
||||
<input type="text" id="label-value" placeholder="e.g., production" maxlength="100">
|
||||
</div>
|
||||
<button class="add-label-btn" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
Add Label
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="labels-actions">
|
||||
<button class="save-labels-btn" type="button" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||
<polyline points="17,21 17,13 7,13 7,21"/>
|
||||
<polyline points="7,3 7,8 15,8"/>
|
||||
</svg>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
renderEndpointsTab(endpoints) {
|
||||
if (!endpoints || !Array.isArray(endpoints.endpoints) || endpoints.endpoints.length === 0) {
|
||||
return `
|
||||
@@ -839,6 +1110,61 @@ class NodeDetailsComponent extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
setupLabelsEditor() {
|
||||
// Add label functionality
|
||||
const addLabelBtn = this.findElement('.add-label-btn');
|
||||
if (addLabelBtn) {
|
||||
this.addEventListener(addLabelBtn, 'click', () => this.handleAddLabel());
|
||||
}
|
||||
|
||||
// Remove label functionality
|
||||
const removeButtons = this.findAllElements('.label-remove-btn');
|
||||
removeButtons.forEach(btn => {
|
||||
this.addEventListener(btn, 'click', (e) => {
|
||||
const key = e.target.closest('.label-remove-btn').dataset.key;
|
||||
this.handleRemoveLabel(key);
|
||||
});
|
||||
});
|
||||
|
||||
// Save labels functionality
|
||||
const saveLabelsBtn = this.findElement('.save-labels-btn');
|
||||
if (saveLabelsBtn) {
|
||||
this.addEventListener(saveLabelsBtn, 'click', () => this.handleSaveLabels());
|
||||
}
|
||||
|
||||
// Enter key support for adding labels
|
||||
const keyInput = this.findElement('#label-key');
|
||||
const valueInput = this.findElement('#label-value');
|
||||
|
||||
if (keyInput) {
|
||||
this.addEventListener(keyInput, 'keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
valueInput.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (valueInput) {
|
||||
this.addEventListener(valueInput, 'keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.handleAddLabel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Input validation
|
||||
if (keyInput && valueInput) {
|
||||
const validateInputs = () => {
|
||||
const hasKey = keyInput.value.trim().length > 0;
|
||||
const hasValue = valueInput.value.trim().length > 0;
|
||||
addLabelBtn.disabled = !hasKey || !hasValue;
|
||||
};
|
||||
|
||||
this.addEventListener(keyInput, 'input', validateInputs);
|
||||
this.addEventListener(valueInput, 'input', validateInputs);
|
||||
}
|
||||
}
|
||||
|
||||
// Update active tab without full re-render
|
||||
updateActiveTab(newTab, previousTab = null) {
|
||||
this.setActiveTab(newTab);
|
||||
|
||||
Reference in New Issue
Block a user