Merge pull request 'feat: label editor' (#16) from feature/labels-editor into main
Reviewed-on: #16
This commit is contained in:
44
index.js
44
index.js
@@ -678,6 +678,16 @@ app.post('/api/proxy-call', async (req, res) => {
|
|||||||
fetchOptions.body = bodyParams.toString();
|
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
|
// Execute request
|
||||||
const response = await fetch(fullUrl, fetchOptions);
|
const response = await fetch(fullUrl, fetchOptions);
|
||||||
const respContentType = response.headers.get('content-type') || '';
|
const respContentType = response.headers.get('content-type') || '';
|
||||||
@@ -690,6 +700,8 @@ app.post('/api/proxy-call', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
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({
|
return res.status(response.status).json({
|
||||||
error: 'Upstream request failed',
|
error: 'Upstream request failed',
|
||||||
status: response.status,
|
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
|
// File upload endpoint for firmware updates
|
||||||
app.post('/api/node/update', async (req, res) => {
|
app.post('/api/node/update', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -920,6 +957,13 @@ async function getCurrentClusterMembers() {
|
|||||||
const clusterResponse = await performWithFailover((client) => client.getClusterStatus());
|
const clusterResponse = await performWithFailover((client) => client.getClusterStatus());
|
||||||
const apiMembers = clusterResponse.members || [];
|
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
|
// Update our local discoveredNodes with fresh information from the API
|
||||||
let updatedNodes = false;
|
let updatedNodes = false;
|
||||||
apiMembers.forEach(apiMember => {
|
apiMembers.forEach(apiMember => {
|
||||||
|
|||||||
@@ -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
|
// Global API client instance
|
||||||
|
|||||||
@@ -504,6 +504,7 @@ class ClusterMembersComponent extends Component {
|
|||||||
newMembers.forEach((newMember) => {
|
newMembers.forEach((newMember) => {
|
||||||
const prevMember = prevByIp.get(newMember.ip);
|
const prevMember = prevByIp.get(newMember.ip);
|
||||||
if (prevMember && this.hasMemberChanged(newMember, prevMember)) {
|
if (prevMember && this.hasMemberChanged(newMember, prevMember)) {
|
||||||
|
logger.debug('ClusterMembersComponent: Member changed, updating card for:', newMember.ip);
|
||||||
this.updateMemberCard(newMember);
|
this.updateMemberCard(newMember);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -511,9 +512,33 @@ class ClusterMembersComponent extends Component {
|
|||||||
|
|
||||||
// Check if a specific member has changed
|
// Check if a specific member has changed
|
||||||
hasMemberChanged(newMember, prevMember) {
|
hasMemberChanged(newMember, prevMember) {
|
||||||
return newMember.status !== prevMember.status ||
|
// Check basic properties
|
||||||
|
if (newMember.status !== prevMember.status ||
|
||||||
newMember.latency !== prevMember.latency ||
|
newMember.latency !== prevMember.latency ||
|
||||||
newMember.hostname !== prevMember.hostname;
|
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
|
// 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) {
|
if (hostnameElement && member.hostname !== hostnameElement.textContent) {
|
||||||
hostnameElement.textContent = member.hostname || 'Unknown Device';
|
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]) => `<span class="label-chip">${this.escapeHtml(String(key))}: ${this.escapeHtml(String(value))}</span>`)
|
||||||
|
.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() {
|
render() {
|
||||||
|
|||||||
@@ -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() {
|
render() {
|
||||||
const nodeStatus = this.viewModel.get('nodeStatus');
|
const nodeStatus = this.viewModel.get('nodeStatus');
|
||||||
const tasks = this.viewModel.get('tasks');
|
const tasks = this.viewModel.get('tasks');
|
||||||
@@ -259,6 +443,7 @@ class NodeDetailsComponent extends Component {
|
|||||||
<div class="tabs-container">
|
<div class="tabs-container">
|
||||||
<div class="tabs-header">
|
<div class="tabs-header">
|
||||||
<button class="tab-button ${activeTab === 'status' ? 'active' : ''}" data-tab="status">Status</button>
|
<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 === 'endpoints' ? 'active' : ''}" data-tab="endpoints">Endpoints</button>
|
||||||
<button class="tab-button ${activeTab === 'tasks' ? 'active' : ''}" data-tab="tasks">Tasks</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>
|
<button class="tab-button ${activeTab === 'firmware' ? 'active' : ''}" data-tab="firmware">Firmware</button>
|
||||||
@@ -275,6 +460,10 @@ class NodeDetailsComponent extends Component {
|
|||||||
${this.renderStatusTab(nodeStatus, monitoringResources)}
|
${this.renderStatusTab(nodeStatus, monitoringResources)}
|
||||||
</div>
|
</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">
|
<div class="tab-content ${activeTab === 'endpoints' ? 'active' : ''}" id="endpoints-tab">
|
||||||
${this.renderEndpointsTab(endpoints)}
|
${this.renderEndpointsTab(endpoints)}
|
||||||
</div>
|
</div>
|
||||||
@@ -292,6 +481,7 @@ class NodeDetailsComponent extends Component {
|
|||||||
|
|
||||||
this.setHTML('', html);
|
this.setHTML('', html);
|
||||||
this.setupTabs();
|
this.setupTabs();
|
||||||
|
this.setupLabelsEditor();
|
||||||
this.setupTabRefreshButton();
|
this.setupTabRefreshButton();
|
||||||
// Restore last active tab from view model if available
|
// Restore last active tab from view model if available
|
||||||
const restored = this.viewModel && typeof this.viewModel.get === 'function' ? this.viewModel.get('activeTab') : null;
|
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();
|
await this.viewModel.loadEndpointsData();
|
||||||
} else if (activeTab === 'tasks' && typeof this.viewModel.loadTasksData === 'function') {
|
} else if (activeTab === 'tasks' && typeof this.viewModel.loadTasksData === 'function') {
|
||||||
await this.viewModel.loadTasksData();
|
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') {
|
} else if (activeTab === 'status' && typeof this.viewModel.loadMonitoringResources === 'function') {
|
||||||
// status tab: load monitoring resources
|
// status tab: load monitoring resources
|
||||||
await this.viewModel.loadMonitoringResources();
|
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) {
|
renderEndpointsTab(endpoints) {
|
||||||
if (!endpoints || !Array.isArray(endpoints.endpoints) || endpoints.endpoints.length === 0) {
|
if (!endpoints || !Array.isArray(endpoints.endpoints) || endpoints.endpoints.length === 0) {
|
||||||
return `
|
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
|
// Update active tab without full re-render
|
||||||
updateActiveTab(newTab, previousTab = null) {
|
updateActiveTab(newTab, previousTab = null) {
|
||||||
this.setActiveTab(newTab);
|
this.setActiveTab(newTab);
|
||||||
|
|||||||
@@ -5103,3 +5103,296 @@ html {
|
|||||||
font-size: 1.5rem;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user