diff --git a/index.js b/index.js
index 48b7f8e..ace820e 100644
--- a/index.js
+++ b/index.js
@@ -678,6 +678,16 @@ app.post('/api/proxy-call', async (req, res) => {
fetchOptions.body = bodyParams.toString();
}
+ // Debug logging to trace upstream requests
+ try {
+ console.log('[proxy-call] →', upperMethod, fullUrl);
+ if (upperMethod !== 'GET') {
+ console.log('[proxy-call] body:', fetchOptions.body);
+ }
+ } catch (_) {
+ // ignore logging errors
+ }
+
// Execute request
const response = await fetch(fullUrl, fetchOptions);
const respContentType = response.headers.get('content-type') || '';
@@ -690,6 +700,8 @@ app.post('/api/proxy-call', async (req, res) => {
}
if (!response.ok) {
+ // Surface upstream failure details for easier debugging
+ console.warn('[proxy-call] Upstream error', response.status, response.statusText, 'for', upperMethod, fullUrl);
return res.status(response.status).json({
error: 'Upstream request failed',
status: response.status,
@@ -727,6 +739,31 @@ app.get('/api/node/status/:ip', async (req, res) => {
}
});
+// Endpoint to trigger a cluster refresh
+app.post('/api/cluster/refresh', async (req, res) => {
+ try {
+ const { reason } = req.body || {};
+ console.log(`🔄 Manual cluster refresh triggered: ${reason || 'unknown reason'}`);
+ console.log(`📡 WebSocket clients connected: ${wsClients.size}`);
+
+ // Trigger a cluster update broadcast
+ broadcastMemberListChange(reason || 'manual_refresh');
+
+ res.json({
+ success: true,
+ message: 'Cluster refresh triggered',
+ reason: reason || 'manual_refresh',
+ wsClients: wsClients.size
+ });
+ } catch (error) {
+ console.error('Error triggering cluster refresh:', error);
+ res.status(500).json({
+ error: 'Failed to trigger cluster refresh',
+ message: error.message
+ });
+ }
+});
+
// File upload endpoint for firmware updates
app.post('/api/node/update', async (req, res) => {
try {
@@ -919,6 +956,13 @@ async function getCurrentClusterMembers() {
console.log(`📡 Fetching real cluster data from ${discoveredNodes.size} nodes for WebSocket broadcast`);
const clusterResponse = await performWithFailover((client) => client.getClusterStatus());
const apiMembers = clusterResponse.members || [];
+
+ // Debug: Log the labels from the API response
+ apiMembers.forEach(member => {
+ if (member.labels && Object.keys(member.labels).length > 0) {
+ console.log(`🏷️ API member ${member.ip} labels:`, member.labels);
+ }
+ });
// Update our local discoveredNodes with fresh information from the API
let updatedNodes = false;
diff --git a/public/scripts/api-client.js b/public/scripts/api-client.js
index ed50af1..aae118b 100644
--- a/public/scripts/api-client.js
+++ b/public/scripts/api-client.js
@@ -120,6 +120,22 @@ class ApiClient {
}
});
}
+
+ async getNodeLabels(ip) {
+ return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
+ }
+
+ async setNodeLabels(ip, labels) {
+ return this.request('/api/proxy-call', {
+ method: 'POST',
+ body: {
+ ip: ip,
+ method: 'POST',
+ uri: '/api/node/config',
+ params: [{ name: 'labels', value: JSON.stringify(labels) }]
+ }
+ });
+ }
}
// Global API client instance
diff --git a/public/scripts/components/ClusterMembersComponent.js b/public/scripts/components/ClusterMembersComponent.js
index 9ef2296..2e89aba 100644
--- a/public/scripts/components/ClusterMembersComponent.js
+++ b/public/scripts/components/ClusterMembersComponent.js
@@ -504,6 +504,7 @@ class ClusterMembersComponent extends Component {
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);
}
});
@@ -511,9 +512,33 @@ class ClusterMembersComponent extends Component {
// Check if a specific member has changed
hasMemberChanged(newMember, prevMember) {
- return newMember.status !== prevMember.status ||
- newMember.latency !== prevMember.latency ||
- newMember.hostname !== prevMember.hostname;
+ // 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
@@ -551,6 +576,43 @@ class ClusterMembersComponent extends Component {
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]) => `${this.escapeHtml(String(key))}: ${this.escapeHtml(String(value))}`)
+ .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() {
diff --git a/public/scripts/components/NodeDetailsComponent.js b/public/scripts/components/NodeDetailsComponent.js
index e7b92ae..88093be 100644
--- a/public/scripts/components/NodeDetailsComponent.js
+++ b/public/scripts/components/NodeDetailsComponent.js
@@ -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 = `
+
+ 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 = `
+
+
+ ${this.escapeHtml(key)}
+ =
+ ${this.escapeHtml(value)}
+
+
+
+ `;
+
+ 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 {
+
+ ${this.renderLabelsTab(nodeStatus)}
+
+
${this.renderEndpointsTab(endpoints)}
@@ -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 = `
+
+
+
+
+ `;
+
+ if (labelsArray.length === 0) {
+ html += `
+
+
No labels configured
+
Add labels to organize and identify this node
+
+ `;
+ } else {
+ labelsArray.forEach(([key, value]) => {
+ html += `
+
+
+ ${this.escapeHtml(key)}
+ =
+ ${this.escapeHtml(value)}
+
+
+
+ `;
+ });
+ }
+
+ html += `
+
+
+
+
+
+
+
+
+ `;
+
+ 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);
diff --git a/public/styles/main.css b/public/styles/main.css
index 030c4f1..a458959 100644
--- a/public/styles/main.css
+++ b/public/styles/main.css
@@ -5103,3 +5103,296 @@ html {
font-size: 1.5rem;
}
}
+
+/* Labels Editor Styles */
+.labels-section {
+ padding: 1rem 0;
+}
+
+.labels-header {
+ margin-bottom: 1.5rem;
+}
+
+.labels-header h3 {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 0.5rem;
+}
+
+.labels-description {
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ line-height: 1.4;
+ margin: 0;
+}
+
+.labels-list {
+ margin-bottom: 1.5rem;
+}
+
+.no-labels {
+ text-align: center;
+ padding: 2rem 1rem;
+ color: var(--text-tertiary);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px dashed var(--border-secondary);
+ border-radius: 8px;
+}
+
+.label-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-primary);
+ border-radius: 8px;
+ margin-bottom: 0.5rem;
+ transition: all 0.2s ease;
+}
+
+.label-item:hover {
+ background: var(--bg-hover);
+ border-color: var(--border-secondary);
+}
+
+.label-content {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: 1;
+}
+
+.label-key {
+ font-weight: 600;
+ color: var(--accent-primary);
+ font-family: 'Courier New', monospace;
+ font-size: 0.9rem;
+}
+
+.label-separator {
+ color: var(--text-tertiary);
+ font-weight: 500;
+}
+
+.label-value {
+ color: var(--text-primary);
+ font-family: 'Courier New', monospace;
+ font-size: 0.9rem;
+}
+
+.label-remove-btn {
+ background: transparent;
+ border: 1px solid var(--border-secondary);
+ color: var(--text-tertiary);
+ border-radius: 6px;
+ padding: 0.25rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.label-remove-btn:hover {
+ background: var(--accent-error);
+ border-color: var(--accent-error);
+ color: white;
+}
+
+.label-remove-btn svg {
+ width: 14px;
+ height: 14px;
+ stroke: currentColor;
+ stroke-width: 2;
+}
+
+.add-label-section {
+ margin-bottom: 1.5rem;
+}
+
+.add-label-form {
+ display: flex;
+ gap: 0.75rem;
+ align-items: end;
+ flex-wrap: wrap;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ flex: 1;
+ min-width: 120px;
+}
+
+.form-group label {
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.form-group input {
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-primary);
+ border-radius: 6px;
+ padding: 0.5rem 0.75rem;
+ color: var(--text-primary);
+ font-size: 0.9rem;
+ transition: all 0.2s ease;
+}
+
+.form-group input:focus {
+ outline: none;
+ border-color: var(--accent-primary);
+ background: var(--bg-secondary);
+}
+
+.form-group input::placeholder {
+ color: var(--text-tertiary);
+}
+
+.add-label-btn {
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
+ border: 1px solid var(--border-secondary);
+ color: var(--text-secondary);
+ border-radius: 8px;
+ padding: 0.5rem 1rem;
+ cursor: pointer;
+ font-size: 0.9rem;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ transition: all 0.2s ease;
+ height: fit-content;
+}
+
+.add-label-btn:hover:not(:disabled) {
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%);
+ border-color: rgba(255, 255, 255, 0.25);
+ color: var(--text-primary);
+ transform: translateY(-1px);
+}
+
+.add-label-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.add-label-btn svg {
+ width: 16px;
+ height: 16px;
+ stroke: currentColor;
+ stroke-width: 2;
+}
+
+.labels-actions {
+ display: flex;
+ justify-content: flex-end;
+ padding-top: 1rem;
+ margin-bottom: 1rem;
+ border-top: 1px solid var(--border-secondary);
+}
+
+.save-labels-btn {
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
+ border: 1px solid var(--border-secondary);
+ color: var(--text-secondary);
+ border-radius: 8px;
+ padding: 0.5rem 1rem;
+ cursor: pointer;
+ font-size: 0.9rem;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ transition: all 0.2s ease;
+}
+
+.save-labels-btn:hover:not(:disabled) {
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%);
+ border-color: rgba(255, 255, 255, 0.25);
+ color: var(--text-primary);
+ transform: translateY(-1px);
+}
+
+.save-labels-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.save-labels-btn svg {
+ width: 16px;
+ height: 16px;
+ stroke: currentColor;
+ stroke-width: 2;
+}
+
+.labels-message {
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
+ margin-top: 0.5rem;
+ font-size: 0.9rem;
+ font-weight: 500;
+ animation: slideIn 0.3s ease;
+}
+
+.labels-message-success {
+ background: rgba(16, 185, 129, 0.1);
+ border: 1px solid rgba(16, 185, 129, 0.2);
+ color: #10b981;
+}
+
+.labels-message-warning {
+ background: rgba(251, 191, 36, 0.1);
+ border: 1px solid rgba(251, 191, 36, 0.2);
+ color: #f59e0b;
+}
+
+.labels-message-error {
+ background: rgba(248, 113, 113, 0.1);
+ border: 1px solid rgba(248, 113, 113, 0.2);
+ color: #f87171;
+}
+
+.labels-message-info {
+ background: rgba(59, 130, 246, 0.1);
+ border: 1px solid rgba(59, 130, 246, 0.2);
+ color: #3b82f6;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Responsive adjustments for labels editor */
+@media (max-width: 768px) {
+ .add-label-form {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .form-group {
+ min-width: unset;
+ }
+
+ .labels-actions {
+ justify-content: stretch;
+ }
+
+ .save-labels-btn {
+ width: 100%;
+ justify-content: center;
+ }
+}