// 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 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 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); } catch (error) { console.error('Failed to fetch cluster members:', error); container.innerHTML = `
Error loading cluster members:
${error.message}
`; } } // 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(); setupNavigation(); setupFirmwareView(); }); // 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', handleGlobalFirmwareUpload); } // 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'; } }); }); } // Handle global firmware upload async function handleGlobalFirmwareUpload(event) { const file = event.target.files[0]; if (!file) return; const targetType = document.querySelector('input[name="target-type"]:checked').value; const specificNode = document.getElementById('specific-node-select').value; if (targetType === 'specific' && !specificNode) { alert('Please select a specific node to update.'); return; } try { if (targetType === 'all') { await uploadFirmwareToAllNodes(file); } else { await uploadFirmwareToSpecificNode(file, specificNode); } } catch (error) { console.error('Global firmware upload failed:', error); alert(`Upload failed: ${error.message}`); } // Clear file input event.target.value = ''; } // Upload firmware to all nodes async function uploadFirmwareToAllNodes(file) { 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; // TODO: Implement batch upload logic alert(`Firmware upload to all ${nodes.length} nodes initiated. This feature is coming soon!`); } // Upload firmware to specific node async function uploadFirmwareToSpecificNode(file, nodeIp) { const confirmed = confirm(`Upload firmware to node ${nodeIp}?`); if (!confirmed) return; // TODO: Implement single node upload logic alert(`Firmware upload to node ${nodeIp} initiated. This feature is coming soon!`); } // 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(); } // 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 }