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 {
+ @@ -275,6 +460,10 @@ class NodeDetailsComponent extends Component { ${this.renderStatusTab(nodeStatus, monitoringResources)}
+
+ ${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 = ` +
+
+

Node Labels

+

Manage custom labels for this node. Labels help organize and identify nodes in your cluster.

+
+ +
+ `; + + 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; + } +}