Files
spore-ui/public/script.js

1263 lines
48 KiB
JavaScript

// Frontend API client - calls our Express backend
class FrontendApiClient {
constructor() {
this.baseUrl = ''; // Same origin as the current page
}
async getClusterMembers() {
try {
const response = await fetch('/api/cluster/members', {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
}
async getDiscoveryInfo() {
try {
const response = await fetch('/api/discovery/nodes', {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
}
async selectRandomPrimaryNode() {
try {
const response = await fetch('/api/discovery/random-primary', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
timestamp: new Date().toISOString()
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
}
async getNodeStatus(ip) {
try {
// Create a proxy endpoint that forwards the request to the specific node
const response = await fetch(`/api/node/status/${encodeURIComponent(ip)}`, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
}
async getTasksStatus() {
try {
const response = await fetch('/api/tasks/status', {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
}
}
// Global client instance
const client = new FrontendApiClient();
// Function to update primary node display
async function updatePrimaryNodeDisplay() {
const primaryNodeElement = document.getElementById('primary-node-ip');
if (!primaryNodeElement) return;
try {
// Set discovering state
primaryNodeElement.textContent = 'Discovering...';
primaryNodeElement.className = 'primary-node-ip discovering';
const discoveryInfo = await client.getDiscoveryInfo();
if (discoveryInfo.primaryNode) {
const status = discoveryInfo.clientInitialized ? '✅' : '⚠️';
const nodeCount = discoveryInfo.totalNodes > 1 ? ` (${discoveryInfo.totalNodes} nodes)` : '';
primaryNodeElement.textContent = `${status} ${discoveryInfo.primaryNode}${nodeCount}`;
primaryNodeElement.className = 'primary-node-ip';
} else if (discoveryInfo.totalNodes > 0) {
// If we have nodes but no primary, show the first one
const firstNode = discoveryInfo.nodes[0];
primaryNodeElement.textContent = `⚠️ ${firstNode.ip} (No Primary)`;
primaryNodeElement.className = 'primary-node-ip error';
} else {
primaryNodeElement.textContent = '🔍 No Nodes Found';
primaryNodeElement.className = 'primary-node-ip error';
}
} catch (error) {
console.error('Failed to fetch discovery info:', error);
primaryNodeElement.textContent = '❌ Discovery Failed';
primaryNodeElement.className = 'primary-node-ip error';
}
}
// Function to randomly select a new primary node
async function selectRandomPrimaryNode() {
const primaryNodeElement = document.getElementById('primary-node-ip');
if (!primaryNodeElement) return;
try {
// Store current primary node for reference
const currentText = primaryNodeElement.textContent;
const currentPrimary = currentText.includes('✅') || currentText.includes('⚠️') ?
currentText.split(' ')[1] : 'unknown';
// Set selecting state
primaryNodeElement.textContent = '🎲 Selecting...';
primaryNodeElement.className = 'primary-node-ip selecting';
// Call the random selection API
const result = await client.selectRandomPrimaryNode();
if (result.success) {
// Show success message briefly
primaryNodeElement.textContent = `🎯 ${result.primaryNode}`;
primaryNodeElement.className = 'primary-node-ip';
// Update the display after a short delay
setTimeout(() => {
updatePrimaryNodeDisplay();
}, 1500);
console.log(`Randomly selected new primary node: ${result.primaryNode}`);
} else {
throw new Error(result.message || 'Random selection failed');
}
} catch (error) {
console.error('Failed to select random primary node:', error);
primaryNodeElement.textContent = '❌ Selection Failed';
primaryNodeElement.className = 'primary-node-ip error';
// Revert to normal display after error
setTimeout(() => {
updatePrimaryNodeDisplay();
}, 2000);
}
}
// Function to refresh cluster members
async function refreshClusterMembers() {
const container = document.getElementById('cluster-members-container');
// Store the currently expanded cards BEFORE showing loading state
const expandedCards = new Map();
const existingCards = container.querySelectorAll('.member-card');
existingCards.forEach(card => {
if (card.classList.contains('expanded')) {
const memberIp = card.dataset.memberIp;
const memberDetails = card.querySelector('.member-details');
if (memberDetails) {
expandedCards.set(memberIp, memberDetails.innerHTML);
console.log(`Storing expanded state for ${memberIp}`);
}
}
});
console.log(`Stored ${expandedCards.size} expanded cards for restoration`);
// Show loading state
container.innerHTML = `
<div class="loading">
<div>Loading cluster members...</div>
</div>
`;
try {
const response = await client.getClusterMembers();
console.log(response);
displayClusterMembers(response.members, expandedCards);
// Update primary node display after cluster refresh
await updatePrimaryNodeDisplay();
} catch (error) {
console.error('Failed to fetch cluster members:', error);
container.innerHTML = `
<div class="error">
<strong>Error loading cluster members:</strong><br>
${error.message}
</div>
`;
// Still try to update primary node display even if cluster fails
await updatePrimaryNodeDisplay();
}
}
// Function to load detailed node information
async function loadNodeDetails(card, memberIp) {
console.log('Loading node details for IP:', memberIp);
const memberDetails = card.querySelector('.member-details');
console.log('Member details element:', memberDetails);
try {
console.log('Fetching node status...');
const nodeStatus = await client.getNodeStatus(memberIp);
console.log('Node status received:', nodeStatus);
displayNodeDetails(memberDetails, nodeStatus);
} catch (error) {
console.error('Failed to load node details:', error);
memberDetails.innerHTML = `
<div class="error">
<strong>Error loading node details:</strong><br>
${error.message}
</div>
`;
}
}
// Function to display node details
function displayNodeDetails(container, nodeStatus) {
console.log('Displaying node details in container:', container);
console.log('Node status data:', nodeStatus);
container.innerHTML = `
<div class="tabs-container">
<div class="tabs-header">
<button class="tab-button active" data-tab="status">Status</button>
<button class="tab-button" data-tab="endpoints">Endpoints</button>
<button class="tab-button" data-tab="tasks">Tasks</button>
<button class="tab-button" data-tab="firmware">Firmware</button>
</div>
<div class="tab-content 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" id="endpoints-tab">
<h4>Available API Endpoints:</h4>
${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" id="tasks-tab">
<div class="loading-tasks">Loading tasks...</div>
</div>
<div class="tab-content" id="firmware-tab">
<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>
</div>
</div>
`;
// Set up tab switching
setupTabs(container);
// Load tasks data for the tasks tab
loadTasksData(container, nodeStatus);
console.log('Node details HTML set successfully');
}
// Function to set up tab switching
function setupTabs(container) {
const tabButtons = container.querySelectorAll('.tab-button');
const tabContents = container.querySelectorAll('.tab-content');
tabButtons.forEach(button => {
button.addEventListener('click', (e) => {
// Prevent the click event from bubbling up to the card
e.stopPropagation();
const targetTab = button.dataset.tab;
// Remove active class from all buttons and contents
tabButtons.forEach(btn => btn.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
// Add active class to clicked button and corresponding content
button.classList.add('active');
const targetContent = container.querySelector(`#${targetTab}-tab`);
if (targetContent) {
targetContent.classList.add('active');
}
});
});
// Also prevent event propagation on tab content areas
tabContents.forEach(content => {
content.addEventListener('click', (e) => {
e.stopPropagation();
});
});
// Set up firmware upload button
const uploadBtn = container.querySelector('.upload-btn[data-action="select-file"]');
if (uploadBtn) {
uploadBtn.addEventListener('click', (e) => {
e.stopPropagation();
const fileInput = container.querySelector('#firmware-file');
if (fileInput) {
fileInput.click();
}
});
// Set up file input change handler
const fileInput = container.querySelector('#firmware-file');
if (fileInput) {
fileInput.addEventListener('change', async (e) => {
e.stopPropagation();
const file = e.target.files[0];
if (file) {
await uploadFirmware(file, container);
}
});
}
}
}
// Function to load tasks data
async function loadTasksData(container, nodeStatus) {
const tasksTab = container.querySelector('#tasks-tab');
if (!tasksTab) return;
try {
const response = await client.getTasksStatus();
console.log('Tasks data received:', response);
if (response && response.length > 0) {
const tasksHTML = response.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('');
tasksTab.innerHTML = `
<h4>Active Tasks</h4>
${tasksHTML}
`;
} else {
tasksTab.innerHTML = `
<div class="no-tasks">
<div>📋 No active tasks found</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">
This node has no running tasks
</div>
</div>
`;
}
} catch (error) {
console.error('Failed to load tasks:', error);
tasksTab.innerHTML = `
<div class="error">
<strong>Error loading tasks:</strong><br>
${error.message}
</div>
`;
}
}
// Function to upload firmware
async function uploadFirmware(file, container) {
const uploadStatus = container.querySelector('#upload-status');
const uploadBtn = container.querySelector('.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 = container.closest('.member-card');
const memberIp = memberCard.dataset.memberIp;
if (!memberIp) {
throw new Error('Could not determine target node IP address');
}
// Create FormData for multipart upload
const formData = new FormData();
formData.append('file', file);
// Upload to backend
const response = await fetch('/api/node/update?ip=' + encodeURIComponent(memberIp), {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
// Show success
uploadStatus.innerHTML = `
<div class="upload-success">
<div>✅ Firmware uploaded successfully!</div>
<div style="font-size: 0.8rem; opacity: 0.7;">Node: ${memberIp}</div>
<div style="font-size: 0.8rem; opacity: 0.7;">Size: ${(file.size / 1024).toFixed(1)}KB</div>
</div>
`;
console.log('Firmware upload successful:', result);
} catch (error) {
console.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 = container.querySelector('#firmware-file');
if (fileInput) {
fileInput.value = '';
}
}
}
// Function to display cluster members
function displayClusterMembers(members, expandedCards = new Map()) {
const container = document.getElementById('cluster-members-container');
if (!members || members.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🌐</div>
<div>No cluster members found</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">
The cluster might be empty or not yet discovered
</div>
</div>
`;
return;
}
const membersHTML = members.map(member => {
const statusClass = member.status === 'active' ? 'status-online' : 'status-offline';
const statusText = member.status === 'active' ? 'Online' : 'Offline';
const statusIcon = member.status === 'active' ? '🟢' : '🔴';
return `
<div class="member-card" data-member-ip="${member.ip}">
<div class="member-header">
<div class="member-info">
<div class="member-name">${member.hostname || 'Unknown Device'}</div>
<div class="member-ip">${member.ip || 'No IP'}</div>
<div class="member-status ${statusClass}">
${statusIcon} ${statusText}
</div>
<div class="member-latency">
<span class="latency-label">Latency:</span>
<span class="latency-value">${member.latency ? member.latency + 'ms' : 'N/A'}</span>
</div>
</div>
<div class="expand-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
</div>
<div class="member-details">
<div class="loading-details">Loading detailed information...</div>
</div>
</div>
`;
}).join('');
container.innerHTML = membersHTML;
// Add event listeners for expand/collapse
console.log('Setting up event listeners for', members.length, 'member cards');
// Small delay to ensure DOM is ready
setTimeout(() => {
document.querySelectorAll('.member-card').forEach((card, index) => {
const expandIcon = card.querySelector('.expand-icon');
const memberDetails = card.querySelector('.member-details');
const memberIp = card.dataset.memberIp;
console.log(`Setting up card ${index} with IP: ${memberIp}`);
// Restore expanded state if this card was expanded before refresh
if (expandedCards.has(memberIp)) {
console.log(`Restoring expanded state for ${memberIp}`);
const restoredContent = expandedCards.get(memberIp);
console.log(`Restored content length: ${restoredContent.length} characters`);
memberDetails.innerHTML = restoredContent;
card.classList.add('expanded');
expandIcon.classList.add('expanded');
// Re-setup tabs for restored content
setupTabs(memberDetails);
console.log(`Successfully restored expanded state for ${memberIp}`);
} else {
console.log(`No expanded state to restore for ${memberIp}`);
}
// Make the entire card clickable
card.addEventListener('click', async (e) => {
// Don't trigger if clicking on the expand icon (to avoid double-triggering)
if (e.target === expandIcon) {
return;
}
console.log('Card clicked for IP:', memberIp);
const isExpanding = !card.classList.contains('expanded');
console.log('Is expanding:', isExpanding);
if (isExpanding) {
// Expanding - fetch detailed status
console.log('Starting to expand...');
await loadNodeDetails(card, memberIp);
card.classList.add('expanded');
expandIcon.classList.add('expanded');
console.log('Card expanded successfully');
} else {
// Collapsing
console.log('Collapsing...');
card.classList.remove('expanded');
expandIcon.classList.remove('expanded');
console.log('Card collapsed successfully');
}
});
// Keep the expand icon click handler for visual feedback
if (expandIcon) {
expandIcon.addEventListener('click', async (e) => {
e.stopPropagation();
console.log('Expand icon clicked for IP:', memberIp);
const isExpanding = !card.classList.contains('expanded');
console.log('Is expanding:', isExpanding);
if (isExpanding) {
// Expanding - fetch detailed status
console.log('Starting to expand...');
await loadNodeDetails(card, memberIp);
card.classList.add('expanded');
expandIcon.classList.add('expanded');
console.log('Card expanded successfully');
} else {
// Collapsing
console.log('Collapsing...');
card.classList.remove('expanded');
expandIcon.classList.remove('expanded');
console.log('Card collapsed successfully');
}
});
console.log(`Event listener added for expand icon on card ${index}`);
} else {
console.error(`No expand icon found for card ${index}`);
}
console.log(`Event listener added for card ${index}`);
});
}, 100);
}
// Load cluster members when page loads
document.addEventListener('DOMContentLoaded', function() {
refreshClusterMembers();
updatePrimaryNodeDisplay(); // Also update primary node display
setupNavigation();
setupFirmwareView();
// Set up periodic primary node updates (every 10 seconds)
setInterval(updatePrimaryNodeDisplay, 10000);
});
// Auto-refresh every 30 seconds
// FIXME not working properly: scroll position is not preserved, if there is an upload happening, this mus also be handled
//setInterval(refreshClusterMembers, 30000);
// Setup navigation menu
function setupNavigation() {
const navTabs = document.querySelectorAll('.nav-tab');
const viewContents = document.querySelectorAll('.view-content');
navTabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetView = tab.dataset.view;
// Update active tab
navTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Update active view
viewContents.forEach(view => view.classList.remove('active'));
const targetViewElement = document.getElementById(`${targetView}-view`);
if (targetViewElement) {
targetViewElement.classList.add('active');
}
// Refresh the active view
if (targetView === 'cluster') {
refreshClusterMembers();
} else if (targetView === 'firmware') {
refreshFirmwareView();
}
});
});
}
// Setup firmware view
function setupFirmwareView() {
// Setup global firmware file input
const globalFirmwareFile = document.getElementById('global-firmware-file');
if (globalFirmwareFile) {
globalFirmwareFile.addEventListener('change', handleGlobalFirmwareFileSelect);
}
// Setup target selection
const targetRadios = document.querySelectorAll('input[name="target-type"]');
const specificNodeSelect = document.getElementById('specific-node-select');
targetRadios.forEach(radio => {
radio.addEventListener('change', () => {
if (radio.value === 'specific') {
specificNodeSelect.style.visibility = 'visible';
specificNodeSelect.style.opacity = '1';
populateNodeSelect();
} else {
specificNodeSelect.style.visibility = 'hidden';
specificNodeSelect.style.opacity = '0';
}
updateDeployButton();
});
});
// Setup specific node select change handler
if (specificNodeSelect) {
specificNodeSelect.addEventListener('change', updateDeployButton);
}
// Setup deploy button
const deployBtn = document.getElementById('deploy-btn');
if (deployBtn) {
deployBtn.addEventListener('click', handleDeployFirmware);
}
// Initial button state
updateDeployButton();
}
// Handle file selection for the compact interface
function handleGlobalFirmwareFileSelect(event) {
const file = event.target.files[0];
const fileInfo = document.getElementById('file-info');
const deployBtn = document.getElementById('deploy-btn');
if (file) {
fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`;
fileInfo.classList.add('has-file');
deployBtn.disabled = false;
} else {
fileInfo.textContent = 'No file selected';
fileInfo.classList.remove('has-file');
deployBtn.disabled = true;
}
}
// Update deploy button state
function updateDeployButton() {
const deployBtn = document.getElementById('deploy-btn');
const fileInput = document.getElementById('global-firmware-file');
const targetType = document.querySelector('input[name="target-type"]:checked');
const specificNodeSelect = document.getElementById('specific-node-select');
if (!deployBtn || !fileInput) return;
const hasFile = fileInput.files && fileInput.files.length > 0;
const isValidTarget = targetType.value === 'all' ||
(targetType.value === 'specific' && specificNodeSelect.value);
deployBtn.disabled = !hasFile || !isValidTarget;
}
// Handle deploy firmware button click
async function handleDeployFirmware() {
const fileInput = document.getElementById('global-firmware-file');
const targetType = document.querySelector('input[name="target-type"]:checked').value;
const specificNode = document.getElementById('specific-node-select').value;
if (!fileInput.files || !fileInput.files[0]) {
alert('Please select a firmware file first.');
return;
}
const file = fileInput.files[0];
if (targetType === 'specific' && !specificNode) {
alert('Please select a specific node to update.');
return;
}
try {
// Disable deploy button during upload
const deployBtn = document.getElementById('deploy-btn');
deployBtn.disabled = true;
deployBtn.classList.add('loading');
deployBtn.textContent = '⏳ Deploying...';
if (targetType === 'all') {
await uploadFirmwareToAllNodes(file);
} else {
await uploadFirmwareToSpecificNode(file, specificNode);
}
// Reset interface after successful upload
fileInput.value = '';
document.getElementById('file-info').textContent = 'No file selected';
document.getElementById('file-info').classList.remove('has-file');
} catch (error) {
console.error('Firmware deployment failed:', error);
alert(`Deployment failed: ${error.message}`);
} finally {
// Re-enable deploy button
const deployBtn = document.getElementById('deploy-btn');
deployBtn.disabled = false;
deployBtn.classList.remove('loading');
deployBtn.textContent = '🚀 Deploy Firmware';
updateDeployButton();
}
}
// Upload firmware to all nodes
async function uploadFirmwareToAllNodes(file) {
try {
// Get current cluster members
const response = await client.getClusterMembers();
const nodes = response.members || [];
if (nodes.length === 0) {
alert('No nodes available for firmware update.');
return;
}
const confirmed = confirm(`Upload firmware to all ${nodes.length} nodes? This will update: ${nodes.map(n => n.hostname || n.ip).join(', ')}`);
if (!confirmed) return;
// Show upload progress area
showFirmwareUploadProgress(file, nodes);
// Start batch upload
const results = await performBatchFirmwareUpload(file, nodes);
// Display results
displayFirmwareUploadResults(results);
} catch (error) {
console.error('Failed to upload firmware to all nodes:', error);
alert(`Upload failed: ${error.message}`);
}
}
// Upload firmware to specific node
async function uploadFirmwareToSpecificNode(file, nodeIp) {
try {
const confirmed = confirm(`Upload firmware to node ${nodeIp}?`);
if (!confirmed) return;
// Show upload progress area
showFirmwareUploadProgress(file, [{ ip: nodeIp, hostname: nodeIp }]);
// Perform single node upload with progress tracking
const result = await performSingleFirmwareUploadWithProgress(file, nodeIp);
// Display results
displayFirmwareUploadResults([result]);
} catch (error) {
console.error(`Failed to upload firmware to node ${nodeIp}:`, error);
alert(`Upload failed: ${error.message}`);
}
}
// Perform batch firmware upload to multiple nodes
async function performBatchFirmwareUpload(file, nodes) {
const results = [];
const totalNodes = nodes.length;
let successfulUploads = 0;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const nodeIp = node.ip;
try {
// Update progress - show current node being processed
updateFirmwareUploadProgress(i + 1, totalNodes, nodeIp, 'Uploading...');
// Upload to this node
const result = await performSingleFirmwareUpload(file, nodeIp);
results.push(result);
successfulUploads++;
// Update progress - show completion and update progress bar with actual success rate
updateFirmwareUploadProgress(i + 1, totalNodes, nodeIp, 'Completed');
updateMultiNodeProgress(successfulUploads, totalNodes);
} catch (error) {
console.error(`Failed to upload to node ${nodeIp}:`, error);
const errorResult = {
nodeIp: nodeIp,
hostname: node.hostname || nodeIp,
success: false,
error: error.message,
timestamp: new Date().toISOString()
};
results.push(errorResult);
// Update progress - show failure and update progress bar with actual success rate
updateFirmwareUploadProgress(i + 1, totalNodes, nodeIp, 'Failed');
updateMultiNodeProgress(successfulUploads, totalNodes);
}
// Small delay between uploads to avoid overwhelming the network
if (i < nodes.length - 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Update final progress based on successful uploads
updateFinalProgress(successfulUploads, totalNodes);
return results;
}
// Perform single firmware upload to a specific node
async function performSingleFirmwareUpload(file, nodeIp) {
try {
// Create FormData for the upload
const formData = new FormData();
formData.append('file', file);
// Upload to backend
const response = await fetch(`/api/node/update?ip=${encodeURIComponent(nodeIp)}`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
return {
nodeIp: nodeIp,
hostname: nodeIp, // Will be updated if we have more info
success: true,
result: result,
timestamp: new Date().toISOString()
};
} catch (error) {
throw new Error(`Upload to ${nodeIp} failed: ${error.message}`);
}
}
// Perform single firmware upload to a specific node with progress tracking
async function performSingleFirmwareUploadWithProgress(file, nodeIp) {
try {
// Simulate upload progress for single node
await simulateUploadProgress(nodeIp);
// Create FormData for the upload
const formData = new FormData();
formData.append('file', file);
// Upload to backend
const response = await fetch(`/api/node/update?ip=${encodeURIComponent(nodeIp)}`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
return {
nodeIp: nodeIp,
hostname: nodeIp, // Will be updated if we have more info
success: true,
result: result,
timestamp: new Date().toISOString()
};
} catch (error) {
throw new Error(`Upload to ${nodeIp} failed: ${error.message}`);
}
}
// Simulate upload progress for single node uploads
async function simulateUploadProgress(nodeIp) {
const progressSteps = [10, 25, 50, 75, 90, 100];
const totalSteps = progressSteps.length;
for (let i = 0; i < totalSteps; i++) {
const progress = progressSteps[i];
updateSingleNodeProgress(progress, nodeIp);
// Wait a bit between progress updates (simulating actual upload time)
if (i < totalSteps - 1) {
await new Promise(resolve => setTimeout(resolve, 300));
}
}
}
// Update progress for single node uploads
function updateSingleNodeProgress(percentage, nodeIp) {
const progressBar = document.getElementById('overall-progress-bar');
const progressText = document.querySelector('.progress-text');
if (progressBar && progressText) {
progressBar.style.width = `${percentage}%`;
progressText.textContent = `${percentage}% Complete`;
// Update progress bar color based on completion
if (percentage === 100) {
progressBar.style.backgroundColor = '#4ade80';
} else if (percentage > 50) {
progressBar.style.backgroundColor = '#60a5fa';
} else {
progressBar.style.backgroundColor = '#fbbf24';
}
}
}
// Update final progress based on successful vs total uploads
function updateFinalProgress(successfulUploads, totalNodes) {
const progressBar = document.getElementById('overall-progress-bar');
const progressText = document.querySelector('.progress-text');
const progressHeader = document.querySelector('.progress-header h3');
if (progressBar && progressText) {
const successPercentage = Math.round((successfulUploads / totalNodes) * 100);
progressBar.style.width = `${successPercentage}%`;
if (successfulUploads === totalNodes) {
progressText.textContent = '100% Complete';
progressBar.style.backgroundColor = '#4ade80';
} else {
progressText.textContent = `${successfulUploads}/${totalNodes} Successful`;
progressBar.style.backgroundColor = '#f87171';
}
}
if (progressHeader) {
progressHeader.textContent = `📤 Firmware Upload Results (${successfulUploads}/${totalNodes} Successful)`;
}
}
// Show firmware upload progress area
function showFirmwareUploadProgress(file, nodes) {
const container = document.getElementById('firmware-nodes-list');
const progressHTML = `
<div class="firmware-upload-progress" id="firmware-upload-progress">
<div class="progress-header">
<h3>📤 Firmware Upload Progress</h3>
<div class="progress-info">
<span>File: ${file.name}</span>
<span>Size: ${(file.size / 1024).toFixed(1)}KB</span>
<span>Targets: ${nodes.length} node(s)</span>
</div>
<div class="overall-progress">
<div class="progress-bar-container">
<div class="progress-bar" id="overall-progress-bar" style="width: 0%; background-color: #fbbf24;"></div>
</div>
<span class="progress-text">0/0 Successful (0%)</span>
</div>
<div class="progress-summary" id="progress-summary">
<span>Status: Preparing upload...</span>
</div>
</div>
<div class="progress-list" id="progress-list">
${nodes.map(node => `
<div class="progress-item" data-node-ip="${node.ip}">
<div class="progress-node-info">
<span class="node-name">${node.hostname || node.ip}</span>
<span class="node-ip">${node.ip}</span>
</div>
<div class="progress-status">Pending...</div>
<div class="progress-time" id="time-${node.ip}"></div>
</div>
`).join('')}
</div>
</div>
`;
container.innerHTML = progressHTML;
}
// Update firmware upload progress
function updateFirmwareUploadProgress(current, total, nodeIp, status) {
const progressItem = document.querySelector(`[data-node-ip="${nodeIp}"]`);
if (progressItem) {
const statusElement = progressItem.querySelector('.progress-status');
const timeElement = progressItem.querySelector('.progress-time');
if (statusElement) {
statusElement.textContent = status;
// Add status-specific styling
statusElement.className = 'progress-status';
if (status === 'Completed') {
statusElement.classList.add('success');
if (timeElement) {
timeElement.textContent = new Date().toLocaleTimeString();
}
} else if (status === 'Failed') {
statusElement.classList.add('error');
if (timeElement) {
timeElement.textContent = new Date().toLocaleTimeString();
}
} else if (status === 'Uploading...') {
statusElement.classList.add('uploading');
if (timeElement) {
timeElement.textContent = 'Started: ' + new Date().toLocaleTimeString();
}
}
}
}
// Update progress header to show current node being processed
const progressHeader = document.querySelector('.progress-header h3');
if (progressHeader) {
progressHeader.textContent = `📤 Firmware Upload Progress (${current}/${total})`;
}
// Update progress summary
const progressSummary = document.getElementById('progress-summary');
if (progressSummary) {
if (status === 'Uploading...') {
progressSummary.innerHTML = `<span>Status: Uploading to ${nodeIp} (${current}/${total})</span>`;
} else if (status === 'Completed') {
// For multi-node uploads, show success rate
if (total > 1) {
const successfulNodes = document.querySelectorAll('.progress-status.success').length;
const totalNodes = total;
const successRate = Math.round((successfulNodes / totalNodes) * 100);
progressSummary.innerHTML = `<span>Status: Completed upload to ${nodeIp}. Overall: ${successfulNodes}/${totalNodes} successful (${successRate}%)</span>`;
} else {
progressSummary.innerHTML = `<span>Status: Completed upload to ${nodeIp} (${current}/${total})</span>`;
}
} else if (status === 'Failed') {
// For multi-node uploads, show success rate
if (total > 1) {
const successfulNodes = document.querySelectorAll('.progress-status.success').length;
const totalNodes = total;
const successRate = Math.round((successfulNodes / totalNodes) * 100);
progressSummary.innerHTML = `<span>Status: Failed upload to ${nodeIp}. Overall: ${successfulNodes}/${totalNodes} successful (${successRate}%)</span>`;
} else {
progressSummary.innerHTML = `<span>Status: Failed upload to ${nodeIp} (${current}/${total})</span>`;
}
}
}
// IMPORTANT: Do NOT update the progress bar here - let updateMultiNodeProgress handle it
// The progress bar should only reflect actual successful uploads, not nodes processed
}
// Update progress for multi-node uploads based on actual success rate
function updateMultiNodeProgress(successfulUploads, totalNodes) {
const progressBar = document.getElementById('overall-progress-bar');
const progressText = document.querySelector('.progress-text');
if (progressBar && progressText) {
const successPercentage = Math.round((successfulUploads / totalNodes) * 100);
progressBar.style.width = `${successPercentage}%`;
progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`;
// Update progress bar color based on completion
if (successPercentage === 100) {
progressBar.style.backgroundColor = '#4ade80';
} else if (successPercentage > 50) {
progressBar.style.backgroundColor = '#60a5fa';
} else {
progressBar.style.backgroundColor = '#fbbf24';
}
}
}
// Display firmware upload results
function displayFirmwareUploadResults(results) {
// No need to display separate results widget - the progress area already shows all the information
// Just update the progress area to show final status
const progressHeader = document.querySelector('.progress-header h3');
const progressSummary = document.getElementById('progress-summary');
if (progressHeader && progressSummary) {
const successCount = results.filter(r => r.success).length;
const totalCount = results.length;
const successRate = Math.round((successCount / totalCount) * 100);
if (successCount === totalCount) {
progressHeader.textContent = `📤 Firmware Upload Complete (${successCount}/${totalCount} Successful)`;
progressSummary.innerHTML = `<span>✅ All uploads completed successfully at ${new Date().toLocaleTimeString()}</span>`;
} else {
progressHeader.textContent = `📤 Firmware Upload Results (${successCount}/${totalCount} Successful)`;
progressSummary.innerHTML = `<span>⚠️ Upload completed with ${totalCount - successCount} failure(s) at ${new Date().toLocaleTimeString()}</span>`;
}
}
}
// Clear firmware upload results
function clearFirmwareResults() {
const container = document.getElementById('firmware-nodes-list');
container.innerHTML = '';
}
// Add refresh button to progress area
function addRefreshButtonToProgress() {
const progressHeader = document.querySelector('.progress-header');
if (progressHeader && !progressHeader.querySelector('.progress-refresh-btn')) {
const refreshBtn = document.createElement('button');
refreshBtn.className = 'progress-refresh-btn';
refreshBtn.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<path d="M1 4v6h6M23 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>
`;
refreshBtn.title = 'Refresh firmware view';
refreshBtn.onclick = refreshFirmwareView;
// Add the refresh button to the header
progressHeader.appendChild(refreshBtn);
}
}
// Populate node select dropdown
function populateNodeSelect() {
const select = document.getElementById('specific-node-select');
if (!select) return;
// Clear existing options
select.innerHTML = '<option value="">Select a node...</option>';
// Get current cluster members and populate
const container = document.getElementById('cluster-members-container');
const memberCards = container.querySelectorAll('.member-card');
memberCards.forEach(card => {
const memberIp = card.dataset.memberIp;
const hostname = card.querySelector('.member-name')?.textContent || memberIp;
const option = document.createElement('option');
option.value = memberIp;
option.textContent = `${hostname} (${memberIp})`;
select.appendChild(option);
});
}
// Refresh firmware view
function refreshFirmwareView() {
populateNodeSelect();
addRefreshButtonToProgress(); // Add refresh button after populating nodes
}