refactor(components): split components.js into separate files and add loader; app waits for components before init
This commit is contained in:
529
public/scripts/components/NodeDetailsComponent.js
Normal file
529
public/scripts/components/NodeDetailsComponent.js
Normal file
@@ -0,0 +1,529 @@
|
||||
// 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('capabilities', this.handleCapabilitiesUpdate.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('capabilities'));
|
||||
}
|
||||
}
|
||||
|
||||
// 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('capabilities'));
|
||||
}
|
||||
}
|
||||
|
||||
// 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 capabilities update with state preservation
|
||||
handleCapabilitiesUpdate(newCapabilities, previousCapabilities) {
|
||||
const nodeStatus = this.viewModel.get('nodeStatus');
|
||||
const tasks = this.viewModel.get('tasks');
|
||||
if (nodeStatus && !this.viewModel.get('isLoading')) {
|
||||
this.renderNodeDetails(nodeStatus, tasks, newCapabilities);
|
||||
}
|
||||
}
|
||||
|
||||
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 capabilities = this.viewModel.get('capabilities');
|
||||
|
||||
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, capabilities);
|
||||
}
|
||||
|
||||
renderNodeDetails(nodeStatus, tasks, capabilities) {
|
||||
// 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 === 'capabilities' ? 'active' : ''}" data-tab="capabilities">Capabilities</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">
|
||||
${nodeStatus.api ? nodeStatus.api.map(endpoint =>
|
||||
`<div class="endpoint-item">${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}</div>`
|
||||
).join('') : '<div class="endpoint-item">No API endpoints available</div>'}
|
||||
</div>
|
||||
|
||||
<div class="tab-content ${activeTab === 'capabilities' ? 'active' : ''}" id="capabilities-tab">
|
||||
${this.renderCapabilitiesTab(capabilities)}
|
||||
</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();
|
||||
}
|
||||
|
||||
renderCapabilitiesTab(capabilities) {
|
||||
if (!capabilities || !Array.isArray(capabilities.endpoints) || capabilities.endpoints.length === 0) {
|
||||
return `
|
||||
<div class="no-capabilities">
|
||||
<div>🧩 No capabilities reported</div>
|
||||
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">This node did not return any capabilities</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Sort endpoints by URI (name), then by method for stable ordering
|
||||
const endpoints = [...capabilities.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 = endpoints.length;
|
||||
|
||||
// Preserve selection based on a stable key of method+uri if available
|
||||
const selectedKey = String(this.getUIState('capSelectedKey') || '');
|
||||
let selectedIndex = endpoints.findIndex(ep => `${ep.method} ${ep.uri}` === selectedKey);
|
||||
if (selectedIndex === -1) {
|
||||
selectedIndex = Number(this.getUIState('capSelectedIndex'));
|
||||
if (Number.isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= total) {
|
||||
selectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute padding for aligned display in dropdown
|
||||
const maxMethodLen = endpoints.reduce((m, ep) => Math.max(m, String(ep.method || '').length), 0);
|
||||
|
||||
const selectorOptions = endpoints.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 = endpoints.map((ep, idx) => {
|
||||
const formId = `cap-form-${idx}`;
|
||||
const resultId = `cap-result-${idx}`;
|
||||
const params = Array.isArray(ep.params) && ep.params.length > 0
|
||||
? `<div class="capability-params">${ep.params.map((p, pidx) => `
|
||||
<label class="capability-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="${(Array.isArray(p.values) && p.values.length === 1) ? p.values[0] : ''}">`
|
||||
}
|
||||
</label>
|
||||
`).join('')}</div>`
|
||||
: '<div class="capability-params none">No parameters</div>';
|
||||
return `
|
||||
<div class="capability-item" data-cap-index="${idx}" style="display:${idx === selectedIndex ? '' : 'none'};">
|
||||
<div class="capability-header">
|
||||
<span class="cap-method">${ep.method}</span>
|
||||
<span class="cap-uri">${ep.uri}</span>
|
||||
<button class="cap-call-btn" data-action="call-capability" data-method="${ep.method}" data-uri="${ep.uri}" data-form-id="${formId}" data-result-id="${resultId}">Call</button>
|
||||
</div>
|
||||
<form id="${formId}" class="capability-form" onsubmit="return false;">
|
||||
${params}
|
||||
</form>
|
||||
<div id="${resultId}" class="capability-result" style="display:none;"></div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Attach events after render in setupCapabilitiesEvents()
|
||||
setTimeout(() => this.setupCapabilitiesEvents(), 0);
|
||||
|
||||
return `
|
||||
<div class="capability-selector">
|
||||
<label class="param-name" for="capability-select">Capability</label>
|
||||
<select id="capability-select" class="param-input" style="font-family: monospace;">${selectorOptions}</select>
|
||||
</div>
|
||||
<div class="capabilities-list">${items}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setupCapabilitiesEvents() {
|
||||
const selector = this.findElement('#capability-select');
|
||||
if (selector) {
|
||||
this.addEventListener(selector, 'change', (e) => {
|
||||
const selected = Number(e.target.value);
|
||||
const items = Array.from(this.findAllElements('.capability-item'));
|
||||
items.forEach((el, idx) => {
|
||||
el.style.display = (idx === selected) ? '' : 'none';
|
||||
});
|
||||
this.setUIState('capSelectedIndex', selected);
|
||||
const opt = e.target.selectedOptions && e.target.selectedOptions[0];
|
||||
if (opt) {
|
||||
const method = opt.dataset.method || '';
|
||||
const uri = opt.dataset.uri || '';
|
||||
this.setUIState('capSelectedKey', `${method} ${uri}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const buttons = this.findAllElements('.cap-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="cap-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.callCapability(method, uri, params);
|
||||
const pretty = typeof response?.data === 'object' ? JSON.stringify(response.data, null, 2) : String(response?.data ?? '');
|
||||
resultEl.innerHTML = `
|
||||
<div class="cap-call-success">
|
||||
<div>✅ Success</div>
|
||||
<pre class="cap-result-pre">${this.escapeHtml(pretty)}</pre>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
resultEl.innerHTML = `
|
||||
<div class="cap-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;
|
||||
Reference in New Issue
Block a user