523 lines
24 KiB
JavaScript
523 lines
24 KiB
JavaScript
// Node Details Component with enhanced state preservation
|
|
class NodeDetailsComponent extends Component {
|
|
constructor(container, viewModel, eventBus) {
|
|
super(container, viewModel, eventBus);
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
// Handle node status update with state preservation
|
|
handleNodeStatusUpdate(newStatus, previousStatus) {
|
|
if (newStatus && !this.viewModel.get('isLoading')) {
|
|
this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('endpoints'));
|
|
}
|
|
}
|
|
|
|
// Handle tasks update with state preservation
|
|
handleTasksUpdate(newTasks, previousTasks) {
|
|
const nodeStatus = this.viewModel.get('nodeStatus');
|
|
if (nodeStatus && !this.viewModel.get('isLoading')) {
|
|
this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('endpoints'));
|
|
}
|
|
}
|
|
|
|
// Handle loading state update
|
|
handleLoadingUpdate(isLoading) {
|
|
if (isLoading) {
|
|
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 with state preservation
|
|
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);
|
|
}
|
|
}
|
|
|
|
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');
|
|
|
|
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);
|
|
}
|
|
|
|
renderNodeDetails(nodeStatus, tasks, endpoints) {
|
|
// 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);
|
|
|
|
const html = `
|
|
<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>
|
|
</div>
|
|
|
|
<div class="tab-content ${activeTab === 'status' ? 'active' : ''}" id="status-tab">
|
|
<div class="detail-row">
|
|
<span class="detail-label">Free Heap:</span>
|
|
<span class="detail-value">${Math.round(nodeStatus.freeHeap / 1024)}KB</span>
|
|
</div>
|
|
<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>
|
|
</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();
|
|
// 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();
|
|
}
|
|
|
|
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>
|
|
${ (Array.isArray(p.values) && p.values.length > 1)
|
|
? `<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>`
|
|
: `<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="${p.default !== undefined ? p.default : (Array.isArray(p.values) && p.values.length === 1) ? p.values[0] : ''}">`
|
|
}
|
|
</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 => ({
|
|
name: input.dataset.paramName,
|
|
location: input.dataset.paramLocation || 'body',
|
|
type: input.dataset.paramType || 'string',
|
|
required: input.dataset.paramRequired === '1',
|
|
value: input.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>
|
|
`;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
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
|
|
const memberCard = this.container.closest('.member-card');
|
|
const memberIp = memberCard.dataset.memberIp;
|
|
|
|
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;
|