diff --git a/index.js b/index.js index 5ef6eaf..c14491e 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,6 @@ const express = require('express'); const path = require('path'); +const fs = require('fs'); const SporeApiClient = require('./src/client'); const app = express(); @@ -77,6 +78,112 @@ app.get('/api/node/status/:ip', async (req, res) => { } }); +// File upload endpoint for firmware updates +app.post('/api/node/update', express.raw({ type: 'multipart/form-data', limit: '50mb' }), async (req, res) => { + try { + const nodeIp = req.query.ip || req.headers['x-node-ip']; + + if (!nodeIp) { + return res.status(400).json({ + error: 'Node IP address is required', + message: 'Please provide the target node IP address' + }); + } + + // Parse multipart form data manually + const boundary = req.headers['content-type']?.split('boundary=')[1]; + if (!boundary) { + return res.status(400).json({ + error: 'Invalid content type', + message: 'Expected multipart/form-data with boundary' + }); + } + + // Parse the multipart data to extract the file + const fileData = parseMultipartData(req.body, boundary); + + if (!fileData.file) { + return res.status(400).json({ + error: 'No file data received', + message: 'Please select a firmware file to upload' + }); + } + + console.log(`Uploading firmware to node ${nodeIp}, file size: ${fileData.file.data.length} bytes, filename: ${fileData.file.filename}`); + + // Create a temporary client for the specific node + const nodeClient = new SporeApiClient(`http://${nodeIp}`); + + // Send the firmware data to the node using multipart form data + const updateResult = await nodeClient.updateFirmware(fileData.file.data, fileData.file.filename); + + res.json({ + success: true, + message: 'Firmware uploaded successfully', + nodeIp: nodeIp, + fileSize: fileData.file.data.length, + filename: fileData.file.filename, + result: updateResult + }); + + } catch (error) { + console.error('Error uploading firmware:', error); + res.status(500).json({ + error: 'Failed to upload firmware', + message: error.message + }); + } +}); + +// Helper function to parse multipart form data +function parseMultipartData(buffer, boundary) { + const data = {}; + const boundaryBuffer = Buffer.from(`--${boundary}`); + const endBoundaryBuffer = Buffer.from(`--${boundary}--`); + + let start = 0; + let end = 0; + + while (true) { + // Find the start of the next part + start = buffer.indexOf(boundaryBuffer, end); + if (start === -1) break; + + // Find the end of this part + end = buffer.indexOf(boundaryBuffer, start + boundaryBuffer.length); + if (end === -1) { + end = buffer.indexOf(endBoundaryBuffer, start + boundaryBuffer.length); + if (end === -1) break; + } + + // Extract the part content + const partBuffer = buffer.slice(start + boundaryBuffer.length + 2, end); + + // Parse headers and content + const headerEnd = partBuffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) continue; + + const headers = partBuffer.slice(0, headerEnd).toString(); + const content = partBuffer.slice(headerEnd + 4); + + // Parse the Content-Disposition header to get field name and filename + const nameMatch = headers.match(/name="([^"]+)"/); + const filenameMatch = headers.match(/filename="([^"]+)"/); + + if (nameMatch) { + const fieldName = nameMatch[1]; + const filename = filenameMatch ? filenameMatch[1] : null; + + data[fieldName] = { + data: content, + filename: filename + }; + } + } + + return data; +} + // Start the server app.listen(PORT, () => { console.log(`Server is running on http://localhost:${PORT}`); diff --git a/public/script.js b/public/script.js index 23e1ef7..12a27b3 100644 --- a/public/script.js +++ b/public/script.js @@ -187,6 +187,7 @@ function displayNodeDetails(container, nodeStatus) { 📁 Choose Firmware File
Select a .bin or .hex file to upload
+ @@ -244,6 +245,18 @@ function setupTabs(container) { 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); + } + }); + } } } @@ -297,6 +310,84 @@ async function loadTasksData(container, nodeStatus) { } } +// 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'); diff --git a/public/styles.css b/public/styles.css index c204cb8..1429e95 100644 --- a/public/styles.css +++ b/public/styles.css @@ -422,6 +422,43 @@ p { color: rgba(255, 255, 255, 0.8); } +/* Upload Status Styles */ +#upload-status { + margin-top: 1rem; + padding: 1rem; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.upload-progress { + text-align: center; + color: #ffa726; +} + +.upload-success { + text-align: center; + color: #4caf50; + background: rgba(76, 175, 80, 0.1); + border: 1px solid rgba(76, 175, 80, 0.2); + padding: 0.75rem; + border-radius: 6px; +} + +.upload-error { + text-align: center; + color: #f44336; + background: rgba(244, 67, 54, 0.1); + border: 1px solid rgba(244, 67, 54, 0.2); + padding: 0.75rem; + border-radius: 6px; +} + +.upload-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none !important; +} + .no-tasks { text-align: center; padding: 2rem; diff --git a/src/client/index.js b/src/client/index.js index 4c7acbe..cae5d9c 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -192,6 +192,22 @@ class SporeApiClient { async factoryReset() { return this.request('POST', '/api/device/factory-reset'); } + + /** + * Update firmware on the device + * @param {Buffer|Uint8Array} firmwareData - Firmware binary data + * @returns {Promise} Update response + */ + async updateFirmware(firmwareData, filename) { + // Send the raw firmware data directly to the SPORE device + // The SPORE device expects the file data, not re-encoded multipart + return this.request('POST', '/api/node/update', { + body: firmwareData, + headers: { + 'Content-Type': 'application/octet-stream' + } + }); + } } // Export the client class