// 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 = `
Loading cluster members...
`; 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 = `
Error loading cluster members:
${error.message}
`; // 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 = `
Error loading node details:
${error.message}
`; } } // 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 = `
Free Heap: ${Math.round(nodeStatus.freeHeap / 1024)}KB
Chip ID: ${nodeStatus.chipId}
SDK Version: ${nodeStatus.sdkVersion}
CPU Frequency: ${nodeStatus.cpuFreqMHz}MHz
Flash Size: ${Math.round(nodeStatus.flashChipSize / 1024)}KB

Available API Endpoints:

${nodeStatus.api ? nodeStatus.api.map(endpoint => `
${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}
` ).join('') : '
No API endpoints available
'}
Loading tasks...

Firmware Update

Select a .bin or .hex file to upload
`; // 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 => `
${task.name || 'Unknown Task'} ${task.running ? '🟢 Running' : '🔴 Stopped'}
Interval: ${task.interval}ms ${task.enabled ? '🟢 Enabled' : '🔴 Disabled'}
`).join(''); tasksTab.innerHTML = `

Active Tasks

${tasksHTML} `; } else { tasksTab.innerHTML = `
📋 No active tasks found
This node has no running tasks
`; } } catch (error) { console.error('Failed to load tasks:', error); tasksTab.innerHTML = `
Error loading tasks:
${error.message}
`; } } // 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 = `
📤 Uploading ${file.name}...
Size: ${(file.size / 1024).toFixed(1)}KB
`; // Disable upload button uploadBtn.disabled = true; uploadBtn.textContent = '⏳ Uploading...'; // Get the member IP from the card 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 = `
✅ Firmware uploaded successfully!
Node: ${memberIp}
Size: ${(file.size / 1024).toFixed(1)}KB
`; console.log('Firmware upload successful:', result); } catch (error) { console.error('Firmware upload failed:', error); // Show error uploadStatus.innerHTML = `
❌ Upload failed: ${error.message}
`; } finally { // Re-enable upload button uploadBtn.disabled = false; uploadBtn.textContent = originalText; // Clear file input const fileInput = 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 = `
🌐
No cluster members found
The cluster might be empty or not yet discovered
`; 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 `
${member.hostname || 'Unknown Device'}
${member.ip || 'No IP'}
${statusIcon} ${statusText}
Latency: ${member.latency ? member.latency + 'ms' : 'N/A'}
Loading detailed information...
`; }).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.display = 'block'; populateNodeSelect(); } else { specificNodeSelect.style.display = 'none'; } 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 = `

📤 Firmware Upload Progress

File: ${file.name} Size: ${(file.size / 1024).toFixed(1)}KB Targets: ${nodes.length} node(s)
0/0 Successful (0%)
Status: Preparing upload...
${nodes.map(node => `
${node.hostname || node.ip} ${node.ip}
Pending...
`).join('')}
`; 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 = `Status: Uploading to ${nodeIp} (${current}/${total})`; } 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 = `Status: Completed upload to ${nodeIp}. Overall: ${successfulNodes}/${totalNodes} successful (${successRate}%)`; } else { progressSummary.innerHTML = `Status: Completed upload to ${nodeIp} (${current}/${total})`; } } 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 = `Status: Failed upload to ${nodeIp}. Overall: ${successfulNodes}/${totalNodes} successful (${successRate}%)`; } else { progressSummary.innerHTML = `Status: Failed upload to ${nodeIp} (${current}/${total})`; } } } // 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 = `✅ All uploads completed successfully at ${new Date().toLocaleTimeString()}`; } else { progressHeader.textContent = `📤 Firmware Upload Results (${successCount}/${totalCount} Successful)`; progressSummary.innerHTML = `⚠️ Upload completed with ${totalCount - successCount} failure(s) at ${new Date().toLocaleTimeString()}`; } } } // 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 = ` `; 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 = ''; // 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() { updateFirmwareStats(); populateNodeSelect(); addRefreshButtonToProgress(); // Add refresh button after populating nodes } // Update firmware statistics function updateFirmwareStats() { const container = document.getElementById('cluster-members-container'); const memberCards = container.querySelectorAll('.member-card'); document.getElementById('total-nodes').textContent = memberCards.length; document.getElementById('available-updates').textContent = '0'; // TODO: Implement update checking document.getElementById('last-update').textContent = 'Never'; // TODO: Implement last update tracking }