// 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 `${p.values.map(v => `${v} `).join('')} `;
}
renderColorComponent(p, formId, pidx) {
const defaultValue = p.default !== undefined ? p.default : (Array.isArray(p.values) && p.values.length === 1) ? p.values[0] : 0;
return `
`;
}
renderNumberRangeComponent(p, formId, pidx) {
const defaultValue = p.default !== undefined ? p.default : 0;
const maxValue = p.value || 100;
return ``;
}
renderTextComponent(p, formId, pidx) {
const defaultValue = p.default !== undefined ? p.default : (Array.isArray(p.values) && p.values.length === 1) ? p.values[0] : '';
return ` `;
}
// Component map for parameter types
getParameterComponentMap() {
const components = {
select: this.renderSelectComponent,
color: this.renderColorComponent,
numberRange: this.renderNumberRangeComponent,
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' }
];
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('Loading detailed information...
');
}
}
// 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);
}
}
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('Loading detailed information...
');
return;
}
if (error) {
this.renderError(`Error loading node details: ${error}`);
return;
}
if (!nodeStatus) {
this.renderEmpty('No node status available
');
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}
${this.renderStatusTab(nodeStatus, monitoringResources)}
${this.renderEndpointsTab(endpoints)}
${this.renderTasksTab(tasks)}
${this.renderFirmwareTab()}
`;
this.setHTML('', html);
this.setupTabs();
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 = `
`;
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 === '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 += `
Chip ID:
${nodeStatus.chipId}
SDK Version:
${nodeStatus.sdkVersion}
CPU Frequency:
${nodeStatus.cpuFreqMHz}MHz
Flash Size:
${this.formatFlashSize(nodeStatus.flashChipSize)}
`;
// Add monitoring resources if available
if (monitoringResources) {
html += `
`;
// CPU Usage
if (monitoringResources.cpu) {
html += `
CPU Usage (Avg):
${monitoringResources.cpu.average_usage ? monitoringResources.cpu.average_usage.toFixed(1) + '%' : 'N/A'}
`;
}
// 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 += `
Heap Usage:
${heapUsagePercent.toFixed(1)}% (${usedHeapKB}KB / ${totalHeapKB}KB)
`;
}
// 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 += `
Filesystem:
${usagePercent}% (${usedKB}KB / ${totalKB}KB)
`;
}
// System Information
if (monitoringResources.system) {
html += `
Uptime:
${monitoringResources.system.uptime_formatted || 'N/A'}
`;
}
// 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 += `
WiFi RSSI:
${monitoringResources.network.wifi_rssi || 'N/A'} dBm
Network Uptime:
${uptimeFormatted}
`;
}
html += `
`;
}
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 `
${cpuUsage.toFixed(1)}%
CPU
${heapUsage.toFixed(1)}%
Heap
${filesystemUsage.toFixed(1)}%
Storage
`;
}
renderEndpointsTab(endpoints) {
if (!endpoints || !Array.isArray(endpoints.endpoints) || endpoints.endpoints.length === 0) {
return `
🧩 No endpoints reported
This node did not return any endpoints
`;
}
// 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 = ' '.repeat(padCount);
return `${method}${spacer}${uri} `;
}).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
? `${ep.params.map((p, pidx) => `
${p.name}${p.required ? ' *' : ''}
${this.renderParameterComponent(p, formId, pidx)}
`).join('')}
`
: 'No parameters
';
return `
`;
}).join('');
// Attach events after render in setupEndpointsEvents()
setTimeout(() => this.setupEndpointsEvents(), 0);
return `
Endpoint
${selectorOptions}
${items}
`;
}
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);
}
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.value || String(p.value).trim() === ''));
if (missing.length > 0) {
resultEl.style.display = 'block';
resultEl.innerHTML = `
❌ Missing required fields: ${missing.map(m => this.escapeHtml(m.name)).join(', ')}
`;
return;
}
// Show loading state
resultEl.style.display = 'block';
resultEl.innerHTML = 'Calling endpoint...
';
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 = `
✅ Success
${this.escapeHtml(pretty)}
`;
} catch (err) {
resultEl.innerHTML = `
❌ Error: ${this.escapeHtml(err.message || 'Request failed')}
`;
}
});
});
// 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, '&')
.replace(//g, '>');
}
renderTasksTab(tasks) {
const summary = this.viewModel.get('tasksSummary');
if (tasks && tasks.length > 0) {
const summaryHTML = summary ? `
📋
Tasks Overview
System task management and monitoring
${summary.totalTasks ?? tasks.length}
Total
${summary.activeTasks ?? tasks.filter(t => t.running).length}
Active
${(summary.totalTasks ?? tasks.length) - (summary.activeTasks ?? tasks.filter(t => t.running).length)}
Stopped
` : '';
const tasksHTML = tasks.map(task => `
Interval: ${task.interval}ms
${task.enabled ? '🟢 Enabled' : '🔴 Disabled'}
`).join('');
return `
${summaryHTML}
${tasksHTML}
`;
} else {
const total = summary?.totalTasks ?? 0;
const active = summary?.activeTasks ?? 0;
return `
📋
Tasks Overview
${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
${total - active}
Stopped
📋 No active tasks found
${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
`;
}
}
renderFirmwareTab() {
return `
`;
}
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);
}
}
});
}
// 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 = `
📤 Uploading ${file.name}...
Size: ${(file.size / 1024).toFixed(1)}KB
`;
// 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 = `
✅ Firmware uploaded successfully!
Node: ${memberIp}
Size: ${(file.size / 1024).toFixed(1)}KB
`;
logger.debug('Firmware upload successful:', result);
} catch (error) {
logger.error('Firmware upload failed:', error);
// Show error
uploadStatus.innerHTML = `
❌ Upload failed: ${error.message}
`;
} 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;