910 lines
41 KiB
JavaScript
910 lines
41 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;
|
|
}
|
|
|
|
// 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}">`;
|
|
}
|
|
|
|
// 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('<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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
// Build labels bar (above tabs)
|
|
const labelsObj = (nodeStatus && nodeStatus.labels) ? nodeStatus.labels : null;
|
|
const labelsBar = (labelsObj && Object.keys(labelsObj).length)
|
|
? `<div class="member-labels" style="margin: 0 0 12px 0;">${Object.entries(labelsObj)
|
|
.map(([k, v]) => `<span class=\"label-chip\">${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}</span>`)
|
|
.join('')}</div>`
|
|
: '';
|
|
|
|
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 === '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 === '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.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 === '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">${Math.round(nodeStatus.flashChipSize / 1024)}KB</span>
|
|
</div>
|
|
`;
|
|
|
|
// Add monitoring resources if available
|
|
if (monitoringResources) {
|
|
html += `
|
|
<div class="monitoring-section">
|
|
<div class="monitoring-header">Resources</div>
|
|
`;
|
|
|
|
// 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>
|
|
`;
|
|
}
|
|
|
|
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 = ' '.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);
|
|
}
|
|
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 = `
|
|
<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>✅ Success</div>
|
|
<pre class="endpoint-result-pre">${this.escapeHtml(pretty)}</pre>
|
|
</div>
|
|
`;
|
|
} catch (err) {
|
|
resultEl.innerHTML = `
|
|
<div class="endpoint-call-error">
|
|
<div>❌ 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
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">📋</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 ? '🟢 Running' : '🔴 Stopped'}
|
|
</span>
|
|
</div>
|
|
<div class="task-details">
|
|
<span class="task-interval">Interval: ${task.interval}ms</span>
|
|
<span class="task-enabled">${task.enabled ? '🟢 Enabled' : '🔴 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">📋</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">
|
|
📁 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);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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>📤 Uploading ${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>✅ 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>❌ Upload failed: ${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;
|