Files
spore-ui/public/scripts/components/NodeDetailsComponent.js
2025-10-16 16:51:29 +02:00

1268 lines
55 KiB
JavaScript

// Node Details Component
class NodeDetailsComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
this.suppressLoadingUI = false;
}
// Helper functions for color conversion
rgbIntToHex(rgbInt) {
if (!rgbInt && rgbInt !== 0) return '#000000';
const num = parseInt(rgbInt);
if (isNaN(num)) return '#000000';
const r = (num >> 16) & 255;
const g = (num >> 8) & 255;
const b = num & 255;
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
hexToRgbInt(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return 0;
const r = parseInt(result[1], 16);
const g = parseInt(result[2], 16);
const b = parseInt(result[3], 16);
return (r << 16) + (g << 8) + b;
}
// Format flash size in human readable format
formatFlashSize(bytes) {
if (!bytes || bytes === 0) return 'Unknown';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
// Parameter component renderers
renderSelectComponent(p, formId, pidx) {
return `<select id="${formId}-field-${pidx}" data-param-name="${p.name}" data-param-location="${p.location || 'body'}" data-param-type="${p.type || 'string'}" data-param-required="${p.required ? '1' : '0'}" class="param-input">${p.values.map(v => `<option value="${v}">${v}</option>`).join('')}</select>`;
}
renderColorComponent(p, formId, pidx) {
const defaultValue = p.default !== undefined ? p.default : (Array.isArray(p.values) && p.values.length === 1) ? p.values[0] : 0;
return `<div class="color-input-container">
<input id="${formId}-field-${pidx}"
data-param-name="${p.name}"
data-param-location="${p.location || 'body'}"
data-param-type="${p.type || 'string'}"
data-param-required="${p.required ? '1' : '0'}"
class="param-input color-picker"
type="color"
value="${this.rgbIntToHex(defaultValue)}">
<input type="text"
class="color-rgb-display"
readonly
value="${defaultValue}"
style="margin-left: 5px; width: 80px; font-size: 0.8em;">
</div>`;
}
renderNumberRangeComponent(p, formId, pidx) {
const defaultValue = p.default !== undefined ? p.default : 0;
const maxValue = p.value || 100;
return `<div class="number-range-container">
<input id="${formId}-field-${pidx}"
data-param-name="${p.name}"
data-param-location="${p.location || 'body'}"
data-param-type="${p.type || 'string'}"
data-param-required="${p.required ? '1' : '0'}"
class="param-input range-slider"
type="range"
min="0"
max="${maxValue}"
value="${defaultValue}">
<div class="range-display">
<span class="range-value">${defaultValue}</span>
<span class="range-max">/ ${maxValue}</span>
</div>
</div>`;
}
renderTextComponent(p, formId, pidx) {
const defaultValue = p.default !== undefined ? p.default : (Array.isArray(p.values) && p.values.length === 1) ? p.values[0] : '';
return `<input id="${formId}-field-${pidx}"
data-param-name="${p.name}"
data-param-location="${p.location || 'body'}"
data-param-type="${p.type || 'string'}"
data-param-required="${p.required ? '1' : '0'}"
class="param-input"
type="text"
placeholder="${p.location || 'body'}${p.type || 'string'}"
value="${defaultValue}">`;
}
renderBooleanComponent(p, formId, pidx) {
const defaultValue = p.default !== undefined ? p.default : false;
const checked = defaultValue ? 'checked' : '';
return `<div class="boolean-input-container">
<input id="${formId}-field-${pidx}"
data-param-name="${p.name}"
data-param-location="${p.location || 'body'}"
data-param-type="${p.type || 'boolean'}"
data-param-required="${p.required ? '1' : '0'}"
class="param-input boolean-checkbox"
type="checkbox"
${checked}>
<label for="${formId}-field-${pidx}" class="boolean-label">${p.name}</label>
</div>`;
}
// Component map for parameter types
getParameterComponentMap() {
const components = {
select: this.renderSelectComponent,
color: this.renderColorComponent,
numberRange: this.renderNumberRangeComponent,
boolean: this.renderBooleanComponent,
text: this.renderTextComponent
};
// Bind all methods to this context
return Object.fromEntries(
Object.entries(components).map(([key, method]) => [key, method.bind(this)])
);
}
// Component type determination rules
getComponentType(p) {
const typeRules = [
{ condition: () => Array.isArray(p.values) && p.values.length > 1, type: 'select' },
{ condition: () => p.type === 'color', type: 'color' },
{ condition: () => p.type === 'numberRange', type: 'numberRange' },
{ condition: () => p.type === 'boolean', type: 'boolean' }
];
const matchedRule = typeRules.find(rule => rule.condition());
return matchedRule ? matchedRule.type : 'text';
}
// Main parameter renderer that uses the component map
renderParameterComponent(p, formId, pidx) {
const componentMap = this.getParameterComponentMap();
const componentType = this.getComponentType(p);
const renderer = componentMap[componentType];
return renderer(p, formId, pidx);
}
setupViewModelListeners() {
this.subscribeToProperty('nodeStatus', this.handleNodeStatusUpdate.bind(this));
this.subscribeToProperty('tasks', this.handleTasksUpdate.bind(this));
this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this));
this.subscribeToProperty('error', this.handleErrorUpdate.bind(this));
this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this));
this.subscribeToProperty('endpoints', this.handleEndpointsUpdate.bind(this));
this.subscribeToProperty('monitoringResources', this.handleMonitoringResourcesUpdate.bind(this));
}
// Handle node status update
handleNodeStatusUpdate(newStatus, previousStatus) {
if (newStatus && !this.viewModel.get('isLoading')) {
this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('endpoints'), this.viewModel.get('monitoringResources'));
}
}
// Handle tasks update
handleTasksUpdate(newTasks, previousTasks) {
const nodeStatus = this.viewModel.get('nodeStatus');
if (nodeStatus && !this.viewModel.get('isLoading')) {
this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('endpoints'), this.viewModel.get('monitoringResources'));
}
}
// Handle loading state update
handleLoadingUpdate(isLoading) {
if (isLoading) {
if (this.suppressLoadingUI) return;
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
}
}
// Handle error state update
handleErrorUpdate(error) {
if (error) {
this.renderError(`Error loading node details: ${error}`);
}
}
// Handle active tab update
handleActiveTabUpdate(newTab, previousTab) {
// Update tab UI without full re-render
this.updateActiveTab(newTab, previousTab);
}
// Handle endpoints update
handleEndpointsUpdate(newEndpoints, previousEndpoints) {
const nodeStatus = this.viewModel.get('nodeStatus');
const tasks = this.viewModel.get('tasks');
if (nodeStatus && !this.viewModel.get('isLoading')) {
this.renderNodeDetails(nodeStatus, tasks, newEndpoints, this.viewModel.get('monitoringResources'));
}
}
// Handle monitoring resources update
handleMonitoringResourcesUpdate(newResources, previousResources) {
const nodeStatus = this.viewModel.get('nodeStatus');
const tasks = this.viewModel.get('tasks');
const endpoints = this.viewModel.get('endpoints');
if (nodeStatus && !this.viewModel.get('isLoading')) {
this.renderNodeDetails(nodeStatus, tasks, endpoints, newResources);
}
}
// 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');
const isLoading = this.viewModel.get('isLoading');
const error = this.viewModel.get('error');
const endpoints = this.viewModel.get('endpoints');
const monitoringResources = this.viewModel.get('monitoringResources');
if (isLoading) {
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
return;
}
if (error) {
this.renderError(`Error loading node details: ${error}`);
return;
}
if (!nodeStatus) {
this.renderEmpty('<div class="loading-details">No node status available</div>');
return;
}
this.renderNodeDetails(nodeStatus, tasks, endpoints, monitoringResources);
}
renderNodeDetails(nodeStatus, tasks, endpoints, monitoringResources) {
// Use persisted active tab from the view model, default to 'status'
const activeTab = (this.viewModel && typeof this.viewModel.get === 'function' && this.viewModel.get('activeTab')) || 'status';
logger.debug('NodeDetailsComponent: Rendering with activeTab:', activeTab);
// Labels are already shown in the member card header, so we don't need to show them again here
const labelsBar = '';
const html = `
${labelsBar}
<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>
<button class="tab-refresh-btn" title="Refresh current tab" aria-label="Refresh">
<svg class="refresh-icon" 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>
</button>
</div>
<div class="tab-content ${activeTab === 'status' ? 'active' : ''}" id="status-tab">
${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>
<div class="tab-content ${activeTab === 'tasks' ? 'active' : ''}" id="tasks-tab">
${this.renderTasksTab(tasks)}
</div>
<div class="tab-content ${activeTab === 'firmware' ? 'active' : ''}" id="firmware-tab">
${this.renderFirmwareTab()}
</div>
</div>
`;
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;
if (restored) {
this.setActiveTab(restored);
}
this.setupFirmwareUpload();
}
setupTabRefreshButton() {
const btn = this.findElement('.tab-refresh-btn');
if (!btn) return;
this.addEventListener(btn, 'click', async (e) => {
e.stopPropagation();
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `
<svg class="refresh-icon 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>
`;
try {
const activeTab = (this.viewModel && typeof this.viewModel.get === 'function') ? (this.viewModel.get('activeTab') || 'status') : 'status';
const nodeIp = (this.viewModel && typeof this.viewModel.get === 'function') ? this.viewModel.get('nodeIp') : null;
this.suppressLoadingUI = true;
if (activeTab === 'endpoints' && typeof this.viewModel.loadEndpointsData === 'function') {
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();
} else {
// firmware: refresh core node details
if (nodeIp && typeof this.viewModel.loadNodeDetails === 'function') {
await this.viewModel.loadNodeDetails(nodeIp);
}
}
} catch (err) {
logger.error('Tab refresh failed:', err);
} finally {
this.suppressLoadingUI = false;
btn.disabled = false;
btn.innerHTML = original;
}
});
}
renderStatusTab(nodeStatus, monitoringResources) {
let html = '';
// Add gauges section if monitoring resources are available
if (monitoringResources) {
html += this.renderResourceGauges(monitoringResources);
}
html += `
<div class="detail-row">
<span class="detail-label">Chip ID:</span>
<span class="detail-value">${nodeStatus.chipId}</span>
</div>
<div class="detail-row">
<span class="detail-label">SDK Version:</span>
<span class="detail-value">${nodeStatus.sdkVersion}</span>
</div>
<div class="detail-row">
<span class="detail-label">CPU Frequency:</span>
<span class="detail-value">${nodeStatus.cpuFreqMHz}MHz</span>
</div>
<div class="detail-row">
<span class="detail-label">Flash Size:</span>
<span class="detail-value">${this.formatFlashSize(nodeStatus.flashChipSize)}</span>
</div>
`;
// Add monitoring resources if available
if (monitoringResources) {
html += `
<div class="monitoring-section">
`;
// CPU Usage
if (monitoringResources.cpu) {
html += `
<div class="detail-row">
<span class="detail-label">CPU Usage (Avg):</span>
<span class="detail-value">${monitoringResources.cpu.average_usage ? monitoringResources.cpu.average_usage.toFixed(1) + '%' : 'N/A'}</span>
</div>
`;
}
// Memory Usage
if (monitoringResources.memory) {
const heapUsagePercent = monitoringResources.memory.heap_usage_percent || 0;
const totalHeap = monitoringResources.memory.total_heap || 0;
const usedHeap = totalHeap - (monitoringResources.memory.free_heap || 0);
const usedHeapKB = Math.round(usedHeap / 1024);
const totalHeapKB = Math.round(totalHeap / 1024);
html += `
<div class="detail-row">
<span class="detail-label">Heap Usage:</span>
<span class="detail-value">${heapUsagePercent.toFixed(1)}% (${usedHeapKB}KB / ${totalHeapKB}KB)</span>
</div>
`;
}
// Filesystem Usage
if (monitoringResources.filesystem) {
const usedKB = Math.round(monitoringResources.filesystem.used_bytes / 1024);
const totalKB = Math.round(monitoringResources.filesystem.total_bytes / 1024);
const usagePercent = monitoringResources.filesystem.total_bytes > 0
? ((monitoringResources.filesystem.used_bytes / monitoringResources.filesystem.total_bytes) * 100).toFixed(1)
: '0.0';
html += `
<div class="detail-row">
<span class="detail-label">Filesystem:</span>
<span class="detail-value">${usagePercent}% (${usedKB}KB / ${totalKB}KB)</span>
</div>
`;
}
// System Information
if (monitoringResources.system) {
html += `
<div class="detail-row">
<span class="detail-label">Uptime:</span>
<span class="detail-value">${monitoringResources.system.uptime_formatted || 'N/A'}</span>
</div>
`;
}
// Network Information
if (monitoringResources.network) {
const uptimeSeconds = monitoringResources.network.uptime_seconds || 0;
const uptimeHours = Math.floor(uptimeSeconds / 3600);
const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
const uptimeFormatted = `${uptimeHours}h ${uptimeMinutes}m`;
html += `
<div class="detail-row">
<span class="detail-label">WiFi RSSI:</span>
<span class="detail-value">${monitoringResources.network.wifi_rssi || 'N/A'} dBm</span>
</div>
<div class="detail-row">
<span class="detail-label">Network Uptime:</span>
<span class="detail-value">${uptimeFormatted}</span>
</div>
`;
}
html += `</div>`;
}
return html;
}
renderResourceGauges(monitoringResources) {
// Get values with fallbacks and ensure they are numbers
const cpuUsage = parseFloat(monitoringResources.cpu?.average_usage) || 0;
const heapUsage = parseFloat(monitoringResources.memory?.heap_usage_percent) || 0;
const filesystemUsed = parseFloat(monitoringResources.filesystem?.used_bytes) || 0;
const filesystemTotal = parseFloat(monitoringResources.filesystem?.total_bytes) || 0;
const filesystemUsage = filesystemTotal > 0 ? (filesystemUsed / filesystemTotal) * 100 : 0;
// Convert filesystem bytes to KB
const filesystemUsedKB = Math.round(filesystemUsed / 1024);
const filesystemTotalKB = Math.round(filesystemTotal / 1024);
// Helper function to get color class based on percentage
const getColorClass = (percentage) => {
const numPercentage = parseFloat(percentage);
if (numPercentage === 0 || isNaN(numPercentage)) return 'gauge-empty';
if (numPercentage < 50) return 'gauge-green';
if (numPercentage < 80) return 'gauge-yellow';
return 'gauge-red';
};
return `
<div class="resource-gauges">
<div class="gauge-container">
<div class="gauge ${getColorClass(cpuUsage)}" data-percentage="${cpuUsage}" style="--percentage: ${cpuUsage}">
<div class="gauge-circle">
<div class="gauge-text">
<div class="gauge-value">${cpuUsage.toFixed(1)}%</div>
<div class="gauge-label">CPU</div>
</div>
</div>
</div>
</div>
<div class="gauge-container">
<div class="gauge ${getColorClass(heapUsage)}" data-percentage="${heapUsage}" style="--percentage: ${heapUsage}">
<div class="gauge-circle">
<div class="gauge-text">
<div class="gauge-value">${heapUsage.toFixed(1)}%</div>
<div class="gauge-label">Heap</div>
</div>
</div>
</div>
</div>
<div class="gauge-container">
<div class="gauge ${getColorClass(filesystemUsage)}" data-percentage="${filesystemUsage}" style="--percentage: ${filesystemUsage}">
<div class="gauge-circle">
<div class="gauge-text">
<div class="gauge-value">${filesystemUsage.toFixed(1)}%</div>
<div class="gauge-label">Storage</div>
<!-- <div class="gauge-detail">${filesystemUsedKB}KB / ${filesystemTotalKB}KB</div> -->
</div>
</div>
</div>
</div>
</div>
`;
}
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 `
<div class="no-endpoints">
<div>No endpoints reported</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">This node did not return any endpoints</div>
</div>
`;
}
// Sort endpoints by URI (name), then by method for stable ordering
const endpointsList = [...endpoints.endpoints].sort((a, b) => {
const aUri = String(a.uri || '').toLowerCase();
const bUri = String(b.uri || '').toLowerCase();
if (aUri < bUri) return -1;
if (aUri > bUri) return 1;
const aMethod = String(a.method || '').toLowerCase();
const bMethod = String(b.method || '').toLowerCase();
return aMethod.localeCompare(bMethod);
});
const total = endpointsList.length;
// Preserve selection based on a stable key of method+uri if available
const selectedKey = String(this.getUIState('endpointSelectedKey') || '');
let selectedIndex = endpointsList.findIndex(ep => `${ep.method} ${ep.uri}` === selectedKey);
if (selectedIndex === -1) {
selectedIndex = Number(this.getUIState('endpointSelectedIndex'));
if (Number.isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= total) {
selectedIndex = 0;
}
}
// Compute padding for aligned display in dropdown
const maxMethodLen = endpointsList.reduce((m, ep) => Math.max(m, String(ep.method || '').length), 0);
const selectorOptions = endpointsList.map((ep, idx) => {
const method = String(ep.method || '');
const uri = String(ep.uri || '');
const padCount = Math.max(1, (maxMethodLen - method.length) + 2);
const spacer = '&nbsp;'.repeat(padCount);
return `<option value="${idx}" data-method="${method}" data-uri="${uri}" ${idx === selectedIndex ? 'selected' : ''}>${method}${spacer}${uri}</option>`;
}).join('');
const items = endpointsList.map((ep, idx) => {
const formId = `endpoint-form-${idx}`;
const resultId = `endpoint-result-${idx}`;
const params = Array.isArray(ep.params) && ep.params.length > 0
? `<div class="endpoint-params">${ep.params.map((p, pidx) => `
<label class="endpoint-param" for="${formId}-field-${pidx}">
<span class="param-name">${p.name}${p.required ? ' *' : ''}</span>
${this.renderParameterComponent(p, formId, pidx)}
</label>
`).join('')}</div>`
: '<div class="endpoint-params none">No parameters</div>';
return `
<div class="endpoint-item" data-endpoint-index="${idx}" style="display:${idx === selectedIndex ? '' : 'none'};">
<div class="endpoint-header">
<span class="endpoint-method">${ep.method}</span>
<span class="endpoint-uri">${ep.uri}</span>
<button class="endpoint-call-btn" data-action="call-endpoint" data-method="${ep.method}" data-uri="${ep.uri}" data-form-id="${formId}" data-result-id="${resultId}">Call</button>
</div>
<form id="${formId}" class="endpoint-form" onsubmit="return false;">
${params}
</form>
<div id="${resultId}" class="endpoint-result" style="display:none;"></div>
</div>
`;
}).join('');
// Attach events after render in setupEndpointsEvents()
setTimeout(() => this.setupEndpointsEvents(), 0);
return `
<div class="endpoint-selector">
<label class="param-name" for="endpoint-select">Endpoint</label>
<select id="endpoint-select" class="param-input" style="font-family: monospace;">${selectorOptions}</select>
</div>
<div class="endpoints-list">${items}</div>
`;
}
setupEndpointsEvents() {
const selector = this.findElement('#endpoint-select');
if (selector) {
this.addEventListener(selector, 'change', (e) => {
const selected = Number(e.target.value);
const items = Array.from(this.findAllElements('.endpoint-item'));
items.forEach((el, idx) => {
el.style.display = (idx === selected) ? '' : 'none';
});
this.setUIState('endpointSelectedIndex', selected);
const opt = e.target.selectedOptions && e.target.selectedOptions[0];
if (opt) {
const method = opt.dataset.method || '';
const uri = opt.dataset.uri || '';
this.setUIState('endpointSelectedKey', `${method} ${uri}`);
}
});
}
const buttons = this.findAllElements('.endpoint-call-btn');
buttons.forEach(btn => {
this.addEventListener(btn, 'click', async (e) => {
e.stopPropagation();
const method = btn.dataset.method || 'GET';
const uri = btn.dataset.uri || '';
const formId = btn.dataset.formId;
const resultId = btn.dataset.resultId;
const formEl = this.findElement(`#${formId}`);
const resultEl = this.findElement(`#${resultId}`);
if (!formEl || !resultEl) return;
const inputs = Array.from(formEl.querySelectorAll('.param-input'));
const params = inputs.map(input => {
let value = input.value;
// For color type, convert hex to RGB integer
if (input.dataset.paramType === 'color' && input.type === 'color') {
const rgbDisplay = input.parentElement.querySelector('.color-rgb-display');
value = rgbDisplay ? rgbDisplay.value : this.hexToRgbInt(input.value);
}
// For boolean type, convert checkbox checked state to boolean
else if (input.dataset.paramType === 'boolean' && input.type === 'checkbox') {
value = input.checked;
}
return {
name: input.dataset.paramName,
location: input.dataset.paramLocation || 'body',
type: input.dataset.paramType || 'string',
required: input.dataset.paramRequired === '1',
value: value
};
});
// Required validation
const missing = params.filter(p => p.required && (p.type === 'boolean' ? false : (!p.value || String(p.value).trim() === '')));
if (missing.length > 0) {
resultEl.style.display = 'block';
resultEl.innerHTML = `
<div class="endpoint-call-error">
<div>❌ Missing required fields: ${missing.map(m => this.escapeHtml(m.name)).join(', ')}</div>
</div>
`;
return;
}
// Show loading state
resultEl.style.display = 'block';
resultEl.innerHTML = '<div class="loading">Calling endpoint...</div>';
try {
const response = await this.viewModel.callEndpoint(method, uri, params);
const pretty = typeof response?.data === 'object' ? JSON.stringify(response.data, null, 2) : String(response?.data ?? '');
resultEl.innerHTML = `
<div class="endpoint-call-success">
<div>${window.icon('success', { width: 14, height: 14 })} Success</div>
<pre class="endpoint-result-pre">${this.escapeHtml(pretty)}</pre>
</div>
`;
} catch (err) {
resultEl.innerHTML = `
<div class="endpoint-call-error">
<div>${window.icon('error', { width: 14, height: 14 })} Error: ${this.escapeHtml(err.message || 'Request failed')}</div>
</div>
`;
}
});
});
// Add event listeners for color pickers
const colorPickers = this.findAllElements('.color-picker');
colorPickers.forEach(colorInput => {
this.addEventListener(colorInput, 'input', (e) => {
const rgbDisplay = colorInput.parentElement.querySelector('.color-rgb-display');
if (rgbDisplay) {
const hexValue = e.target.value;
const rgbInt = this.hexToRgbInt(hexValue);
rgbDisplay.value = rgbInt;
}
});
});
// Update color picker when RGB display is manually changed (if we make it editable later)
const rgbDisplays = this.findAllElements('.color-rgb-display');
rgbDisplays.forEach(rgbInput => {
this.addEventListener(rgbInput, 'input', (e) => {
const colorPicker = rgbInput.parentElement.querySelector('.color-picker');
if (colorPicker) {
const rgbInt = parseInt(e.target.value) || 0;
const hexValue = this.rgbIntToHex(rgbInt);
colorPicker.value = hexValue;
}
});
});
// Add event listeners for range sliders
const rangeSliders = this.findAllElements('.range-slider');
rangeSliders.forEach(rangeInput => {
this.addEventListener(rangeInput, 'input', (e) => {
const rangeDisplay = rangeInput.parentElement.querySelector('.range-display .range-value');
if (rangeDisplay) {
rangeDisplay.textContent = e.target.value;
}
});
});
}
escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
renderTasksTab(tasks) {
const summary = this.viewModel.get('tasksSummary');
if (tasks && tasks.length > 0) {
const summaryHTML = summary ? `
<div class="tasks-summary">
<div class="tasks-summary-left">
<div class="summary-icon">${window.icon('file', { width: 16, height: 16 })}</div>
<div>
<div class="summary-title">Tasks Overview</div>
<div class="summary-subtitle">System task management and monitoring</div>
</div>
</div>
<div class="tasks-summary-right">
<div class="summary-stat total">
<div class="summary-stat-value">${summary.totalTasks ?? tasks.length}</div>
<div class="summary-stat-label">Total</div>
</div>
<div class="summary-stat active">
<div class="summary-stat-value">${summary.activeTasks ?? tasks.filter(t => t.running).length}</div>
<div class="summary-stat-label">Active</div>
</div>
<div class="summary-stat stopped">
<div class="summary-stat-value">${(summary.totalTasks ?? tasks.length) - (summary.activeTasks ?? tasks.filter(t => t.running).length)}</div>
<div class="summary-stat-label">Stopped</div>
</div>
</div>
</div>
` : '';
const tasksHTML = tasks.map(task => `
<div class="task-item">
<div class="task-header">
<span class="task-name">${task.name || 'Unknown Task'}</span>
<span class="task-status ${task.running ? 'running' : 'stopped'}">
${task.running ? window.icon('dotGreen', { width: 10, height: 10 }) + ' Running' : window.icon('dotRed', { width: 10, height: 10 }) + ' Stopped'}
</span>
</div>
<div class="task-details">
<span class="task-interval">Interval: ${task.interval}ms</span>
<span class="task-enabled">${task.enabled ? window.icon('dotGreen', { width: 10, height: 10 }) + ' Enabled' : window.icon('dotRed', { width: 10, height: 10 }) + ' Disabled'}</span>
</div>
</div>
`).join('');
return `
${summaryHTML}
${tasksHTML}
`;
} else {
const total = summary?.totalTasks ?? 0;
const active = summary?.activeTasks ?? 0;
return `
<div class="tasks-summary">
<div class="tasks-summary-left">
<div class="summary-icon">${window.icon('file', { width: 16, height: 16 })}</div>
<div>
<div class="summary-title">Tasks Overview</div>
<div class="summary-subtitle">${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}</div>
</div>
</div>
<div class="tasks-summary-right">
<div class="summary-stat total">
<div class="summary-stat-value">${total}</div>
<div class="summary-stat-label">Total</div>
</div>
<div class="summary-stat active">
<div class="summary-stat-value">${active}</div>
<div class="summary-stat-label">Active</div>
</div>
<div class="summary-stat stopped">
<div class="summary-stat-value">${total - active}</div>
<div class="summary-stat-label">Stopped</div>
</div>
</div>
</div>
<div class="no-tasks">
<div>No active tasks found</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">
${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
</div>
</div>
`;
}
}
renderFirmwareTab() {
return `
<div class="firmware-upload">
<h4>Firmware Update</h4>
<div class="upload-area">
<input type="file" id="firmware-file" accept=".bin,.hex" style="display: none;">
<button class="upload-btn" data-action="select-file">
${window.icon('file', { width: 14, height: 14 })} Choose Firmware File
</button>
<div class="upload-info">Select a .bin or .hex file to upload</div>
<div id="upload-status" style="display: none;"></div>
</div>
</div>
`;
}
setupTabs() {
logger.debug('NodeDetailsComponent: Setting up tabs');
super.setupTabs(this.container, {
onChange: (tab) => {
// Persist active tab in the view model for restoration
if (this.viewModel && typeof this.viewModel.setActiveTab === 'function') {
this.viewModel.setActiveTab(tab);
}
}
});
}
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);
logger.debug(`NodeDetailsComponent: Active tab updated to '${newTab}'`);
}
setupFirmwareUpload() {
const uploadBtn = this.findElement('.upload-btn[data-action="select-file"]');
if (uploadBtn) {
this.addEventListener(uploadBtn, 'click', (e) => {
e.stopPropagation();
const fileInput = this.findElement('#firmware-file');
if (fileInput) {
fileInput.click();
}
});
// Set up file input change handler
const fileInput = this.findElement('#firmware-file');
if (fileInput) {
this.addEventListener(fileInput, 'change', async (e) => {
e.stopPropagation();
const file = e.target.files[0];
if (file) {
await this.uploadFirmware(file);
}
});
}
}
}
async uploadFirmware(file) {
const uploadStatus = this.findElement('#upload-status');
const uploadBtn = this.findElement('.upload-btn');
const originalText = uploadBtn.textContent;
try {
// Show upload status
uploadStatus.style.display = 'block';
uploadStatus.innerHTML = `
<div class="upload-progress">
<div>${window.icon('upload', { width: 14, height: 14 })} Uploading ${this.escapeHtml(file.name)}...</div>
<div style="font-size: 0.8rem; opacity: 0.7;">Size: ${(file.size / 1024).toFixed(1)}KB</div>
</div>
`;
// Disable upload button
uploadBtn.disabled = true;
uploadBtn.textContent = 'Uploading...';
// Get the member IP from the card if available, otherwise fallback to view model state
const memberCard = this.container.closest('.member-card');
let memberIp = null;
if (memberCard && memberCard.dataset && memberCard.dataset.memberIp) {
memberIp = memberCard.dataset.memberIp;
} else if (this.viewModel && typeof this.viewModel.get === 'function') {
memberIp = this.viewModel.get('nodeIp');
}
if (!memberIp) {
throw new Error('Could not determine target node IP address');
}
// Upload firmware
const result = await this.viewModel.uploadFirmware(file, memberIp);
// Show success
uploadStatus.innerHTML = `
<div class="upload-success">
<div>${window.icon('success', { width: 14, height: 14 })} Firmware uploaded successfully!</div>
<div style="font-size: 0.8rem; margin-top: 0.5rem; opacity: 0.7;">Node: ${memberIp}</div>
<div style="font-size: 0.8rem; margin-top: 0.5rem; opacity: 0.7;">Size: ${(file.size / 1024).toFixed(1)}KB</div>
</div>
`;
logger.debug('Firmware upload successful:', result);
} catch (error) {
logger.error('Firmware upload failed:', error);
// Show error
uploadStatus.innerHTML = `
<div class="upload-error">
<div>${window.icon('error', { width: 14, height: 14 })} Upload failed: ${this.escapeHtml(error.message)}</div>
</div>
`;
} finally {
// Re-enable upload button
uploadBtn.disabled = false;
uploadBtn.textContent = originalText;
// Clear file input
const fileInput = this.findElement('#firmware-file');
if (fileInput) {
fileInput.value = '';
}
}
}
}
window.NodeDetailsComponent = NodeDetailsComponent;