// 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(); }); // 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);