diff --git a/platformio.ini b/platformio.ini index 703b934..17ec637 100644 --- a/platformio.ini +++ b/platformio.ini @@ -47,6 +47,7 @@ lib_deps = SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager ArduinoJson @ 7.4.2 QRCode @ 0.0.1 + links2004/WebSockets @ ^2.4.1 [env:default] extends = base diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index dde0561..35ad58b 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -83,9 +84,8 @@ void CrossPointWebServerActivity::onExit() { dnsServer = nullptr; } - // CRITICAL: Wait for LWIP stack to flush any pending packets - Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis()); - delay(500); + // Brief wait for LWIP stack to flush pending packets + delay(50); // Disconnect WiFi gracefully if (isApMode) { @@ -95,11 +95,11 @@ void CrossPointWebServerActivity::onExit() { Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis()); WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame } - delay(100); // Allow disconnect frame to be sent + delay(30); // Allow disconnect frame to be sent Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis()); WiFi.mode(WIFI_OFF); - delay(100); // Allow WiFi hardware to fully power down + delay(30); // Allow WiFi hardware to power down Serial.printf("[%lu] [WEBACT] [MEM] Free heap after WiFi disconnect: %d bytes\n", millis(), ESP.getFreeHeap()); @@ -283,8 +283,28 @@ void CrossPointWebServerActivity::loop() { dnsServer->processNextRequest(); } - // Handle web server requests - call handleClient multiple times per loop - // to improve responsiveness and upload throughput + // STA mode: Monitor WiFi connection health + if (!isApMode && webServer && webServer->isRunning()) { + static unsigned long lastWifiCheck = 0; + if (millis() - lastWifiCheck > 2000) { // Check every 2 seconds + lastWifiCheck = millis(); + const wl_status_t wifiStatus = WiFi.status(); + if (wifiStatus != WL_CONNECTED) { + Serial.printf("[%lu] [WEBACT] WiFi disconnected! Status: %d\n", millis(), wifiStatus); + // Show error and exit gracefully + state = WebServerActivityState::SHUTTING_DOWN; + updateRequired = true; + return; + } + // Log weak signal warnings + const int rssi = WiFi.RSSI(); + if (rssi < -75) { + Serial.printf("[%lu] [WEBACT] Warning: Weak WiFi signal: %d dBm\n", millis(), rssi); + } + } + } + + // Handle web server requests - maximize throughput with watchdog safety if (webServer && webServer->isRunning()) { const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; @@ -294,17 +314,32 @@ void CrossPointWebServerActivity::loop() { timeSinceLastHandleClient); } - // Call handleClient multiple times to process pending requests faster - // This is critical for upload performance - HTTP file uploads send data - // in chunks and each handleClient() call processes incoming data - constexpr int HANDLE_CLIENT_ITERATIONS = 10; - for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) { + // Reset watchdog BEFORE processing - HTTP header parsing can be slow + esp_task_wdt_reset(); + + // Process HTTP requests in tight loop for maximum throughput + // More iterations = more data processed per main loop cycle + constexpr int MAX_ITERATIONS = 500; + for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) { webServer->handleClient(); + // Reset watchdog every 32 iterations + if ((i & 0x1F) == 0x1F) { + esp_task_wdt_reset(); + } + // Yield and check for exit button every 64 iterations + if ((i & 0x3F) == 0x3F) { + yield(); + // Check for exit button inside loop for responsiveness + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onGoBack(); + return; + } + } } lastHandleClientTime = millis(); } - // Handle exit on Back button + // Handle exit on Back button (also check outside loop) if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onGoBack(); return; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 8703c2a..23ba36b 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -15,6 +16,18 @@ namespace { // Note: Items starting with "." are automatically hidden const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); + +// Static pointer for WebSocket callback (WebSocketsServer requires C-style callback) +CrossPointWebServer* wsInstance = nullptr; + +// WebSocket upload state +FsFile wsUploadFile; +String wsUploadFileName; +String wsUploadPath; +size_t wsUploadSize = 0; +size_t wsUploadReceived = 0; +unsigned long wsUploadStartTime = 0; +bool wsUploadInProgress = false; } // namespace // File listing page template - now using generated headers: @@ -86,12 +99,22 @@ void CrossPointWebServer::begin() { Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); server->begin(); + + // Start WebSocket server for fast binary uploads + Serial.printf("[%lu] [WEB] Starting WebSocket server on port %d...\n", millis(), wsPort); + wsServer.reset(new WebSocketsServer(wsPort)); + wsInstance = const_cast(this); + wsServer->begin(); + wsServer->onEvent(wsEventCallback); + Serial.printf("[%lu] [WEB] WebSocket server started\n", millis()); + running = true; Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); // Show the correct IP based on network mode const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), ipAddr.c_str()); + Serial.printf("[%lu] [WEB] WebSocket at ws://%s:%d/\n", millis(), ipAddr.c_str(), wsPort); Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap()); } @@ -107,16 +130,29 @@ void CrossPointWebServer::stop() { Serial.printf("[%lu] [WEB] [MEM] Free heap before stop: %d bytes\n", millis(), ESP.getFreeHeap()); - // Add delay to allow any in-flight handleClient() calls to complete - delay(100); - Serial.printf("[%lu] [WEB] Waited 100ms for handleClient to finish\n", millis()); + // Close any in-progress WebSocket upload + if (wsUploadInProgress && wsUploadFile) { + wsUploadFile.close(); + wsUploadInProgress = false; + } + + // Stop WebSocket server + if (wsServer) { + Serial.printf("[%lu] [WEB] Stopping WebSocket server...\n", millis()); + wsServer->close(); + wsServer.reset(); + wsInstance = nullptr; + Serial.printf("[%lu] [WEB] WebSocket server stopped\n", millis()); + } + + // Brief delay to allow any in-flight handleClient() calls to complete + delay(20); server->stop(); Serial.printf("[%lu] [WEB] [MEM] Free heap after server->stop(): %d bytes\n", millis(), ESP.getFreeHeap()); - // Add another delay before deletion to ensure server->stop() completes - delay(50); - Serial.printf("[%lu] [WEB] Waited 50ms before deleting server\n", millis()); + // Brief delay before deletion + delay(10); server.reset(); Serial.printf("[%lu] [WEB] Web server stopped and deleted\n", millis()); @@ -148,6 +184,11 @@ void CrossPointWebServer::handleClient() const { } server->handleClient(); + + // Handle WebSocket events + if (wsServer) { + wsServer->loop(); + } } void CrossPointWebServer::handleRoot() const { @@ -229,7 +270,8 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function 0 && uploadFile) { + esp_task_wdt_reset(); // Reset watchdog before potentially slow SD write + const unsigned long writeStart = millis(); + const size_t written = uploadFile.write(uploadBuffer, uploadBufferPos); + totalWriteTime += millis() - writeStart; + writeCount++; + esp_task_wdt_reset(); // Reset watchdog after SD write + + if (written != uploadBufferPos) { + Serial.printf("[%lu] [WEB] [UPLOAD] Buffer flush failed: expected %d, wrote %d\n", millis(), uploadBufferPos, + written); + uploadBufferPos = 0; + return false; + } + uploadBufferPos = 0; + } + return true; +} + void CrossPointWebServer::handleUpload() const { - static unsigned long lastWriteTime = 0; - static unsigned long uploadStartTime = 0; static size_t lastLoggedSize = 0; + // Reset watchdog at start of every upload callback - HTTP parsing can be slow + esp_task_wdt_reset(); + // Safety check: ensure server is still valid if (!running || !server) { Serial.printf("[%lu] [WEB] [UPLOAD] ERROR: handleUpload called but server not running!\n", millis()); @@ -315,13 +390,18 @@ void CrossPointWebServer::handleUpload() const { const HTTPUpload& upload = server->upload(); if (upload.status == UPLOAD_FILE_START) { + // Reset watchdog - this is the critical 1% crash point + esp_task_wdt_reset(); + uploadFileName = upload.filename; uploadSize = 0; uploadSuccess = false; uploadError = ""; uploadStartTime = millis(); - lastWriteTime = millis(); lastLoggedSize = 0; + uploadBufferPos = 0; + totalWriteTime = 0; + writeCount = 0; // Get upload path from query parameter (defaults to root if not specified) // Note: We use query parameter instead of form data because multipart form @@ -348,60 +428,82 @@ void CrossPointWebServer::handleUpload() const { if (!filePath.endsWith("/")) filePath += "/"; filePath += uploadFileName; - // Check if file already exists + // Check if file already exists - SD operations can be slow + esp_task_wdt_reset(); if (SdMan.exists(filePath.c_str())) { Serial.printf("[%lu] [WEB] [UPLOAD] Overwriting existing file: %s\n", millis(), filePath.c_str()); + esp_task_wdt_reset(); SdMan.remove(filePath.c_str()); } - // Open file for writing + // Open file for writing - this can be slow due to FAT cluster allocation + esp_task_wdt_reset(); if (!SdMan.openFileForWrite("WEB", filePath, uploadFile)) { uploadError = "Failed to create file on SD card"; Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str()); return; } + esp_task_wdt_reset(); Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str()); } else if (upload.status == UPLOAD_FILE_WRITE) { if (uploadFile && uploadError.isEmpty()) { - const unsigned long writeStartTime = millis(); - const size_t written = uploadFile.write(upload.buf, upload.currentSize); - const unsigned long writeEndTime = millis(); - const unsigned long writeDuration = writeEndTime - writeStartTime; + // Buffer incoming data and flush when buffer is full + // This reduces SD card write operations and improves throughput + const uint8_t* data = upload.buf; + size_t remaining = upload.currentSize; - if (written != upload.currentSize) { - uploadError = "Failed to write to SD card - disk may be full"; - uploadFile.close(); - Serial.printf("[%lu] [WEB] [UPLOAD] WRITE ERROR - expected %d, wrote %d\n", millis(), upload.currentSize, - written); - } else { - uploadSize += written; + while (remaining > 0) { + const size_t space = UPLOAD_BUFFER_SIZE - uploadBufferPos; + const size_t toCopy = (remaining < space) ? remaining : space; - // Log progress every 50KB or if write took >100ms - if (uploadSize - lastLoggedSize >= 51200 || writeDuration > 100) { - const unsigned long timeSinceStart = millis() - uploadStartTime; - const unsigned long timeSinceLastWrite = millis() - lastWriteTime; - const float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0); + memcpy(uploadBuffer + uploadBufferPos, data, toCopy); + uploadBufferPos += toCopy; + data += toCopy; + remaining -= toCopy; - Serial.printf( - "[%lu] [WEB] [UPLOAD] Progress: %d bytes (%.1f KB), %.1f KB/s, write took %lu ms, gap since last: %lu " - "ms\n", - millis(), uploadSize, uploadSize / 1024.0, kbps, writeDuration, timeSinceLastWrite); - lastLoggedSize = uploadSize; + // Flush buffer when full + if (uploadBufferPos >= UPLOAD_BUFFER_SIZE) { + if (!flushUploadBuffer()) { + uploadError = "Failed to write to SD card - disk may be full"; + uploadFile.close(); + return; + } } - lastWriteTime = millis(); + } + + uploadSize += upload.currentSize; + + // Log progress every 100KB + if (uploadSize - lastLoggedSize >= 102400) { + const unsigned long elapsed = millis() - uploadStartTime; + const float kbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0; + Serial.printf("[%lu] [WEB] [UPLOAD] %d bytes (%.1f KB), %.1f KB/s, %d writes\n", millis(), uploadSize, + uploadSize / 1024.0, kbps, writeCount); + lastLoggedSize = uploadSize; } } } else if (upload.status == UPLOAD_FILE_END) { if (uploadFile) { + // Flush any remaining buffered data + if (!flushUploadBuffer()) { + uploadError = "Failed to write final data to SD card"; + } uploadFile.close(); if (uploadError.isEmpty()) { uploadSuccess = true; - Serial.printf("[%lu] [WEB] Upload complete: %s (%d bytes)\n", millis(), uploadFileName.c_str(), uploadSize); + const unsigned long elapsed = millis() - uploadStartTime; + const float avgKbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0; + const float writePercent = (elapsed > 0) ? (totalWriteTime * 100.0 / elapsed) : 0; + Serial.printf("[%lu] [WEB] [UPLOAD] Complete: %s (%d bytes in %lu ms, avg %.1f KB/s)\n", millis(), + uploadFileName.c_str(), uploadSize, elapsed, avgKbps); + Serial.printf("[%lu] [WEB] [UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)\n", millis(), + writeCount, totalWriteTime, writePercent); } } } else if (upload.status == UPLOAD_FILE_ABORTED) { + uploadBufferPos = 0; // Discard buffered data if (uploadFile) { uploadFile.close(); // Try to delete the incomplete file @@ -555,3 +657,143 @@ void CrossPointWebServer::handleDelete() const { server->send(500, "text/plain", "Failed to delete item"); } } + +// WebSocket callback trampoline +void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { + if (wsInstance) { + wsInstance->onWebSocketEvent(num, type, payload, length); + } +} + +// WebSocket event handler for fast binary uploads +// Protocol: +// 1. Client sends TEXT message: "START:::" +// 2. Client sends BINARY messages with file data chunks +// 3. Server sends TEXT "PROGRESS::" after each chunk +// 4. Server sends TEXT "DONE" or "ERROR:" when complete +void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { + switch (type) { + case WStype_DISCONNECTED: + Serial.printf("[%lu] [WS] Client %u disconnected\n", millis(), num); + // Clean up any in-progress upload + if (wsUploadInProgress && wsUploadFile) { + wsUploadFile.close(); + // Delete incomplete file + String filePath = wsUploadPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += wsUploadFileName; + SdMan.remove(filePath.c_str()); + Serial.printf("[%lu] [WS] Deleted incomplete upload: %s\n", millis(), filePath.c_str()); + } + wsUploadInProgress = false; + break; + + case WStype_CONNECTED: { + Serial.printf("[%lu] [WS] Client %u connected\n", millis(), num); + break; + } + + case WStype_TEXT: { + // Parse control messages + String msg = String((char*)payload); + Serial.printf("[%lu] [WS] Text from client %u: %s\n", millis(), num, msg.c_str()); + + if (msg.startsWith("START:")) { + // Parse: START::: + int firstColon = msg.indexOf(':', 6); + int secondColon = msg.indexOf(':', firstColon + 1); + + if (firstColon > 0 && secondColon > 0) { + wsUploadFileName = msg.substring(6, firstColon); + wsUploadSize = msg.substring(firstColon + 1, secondColon).toInt(); + wsUploadPath = msg.substring(secondColon + 1); + wsUploadReceived = 0; + wsUploadStartTime = millis(); + + // Ensure path is valid + if (!wsUploadPath.startsWith("/")) wsUploadPath = "/" + wsUploadPath; + if (wsUploadPath.length() > 1 && wsUploadPath.endsWith("/")) { + wsUploadPath = wsUploadPath.substring(0, wsUploadPath.length() - 1); + } + + // Build file path + String filePath = wsUploadPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += wsUploadFileName; + + Serial.printf("[%lu] [WS] Starting upload: %s (%d bytes) to %s\n", millis(), wsUploadFileName.c_str(), + wsUploadSize, filePath.c_str()); + + // Check if file exists and remove it + esp_task_wdt_reset(); + if (SdMan.exists(filePath.c_str())) { + SdMan.remove(filePath.c_str()); + } + + // Open file for writing + esp_task_wdt_reset(); + if (!SdMan.openFileForWrite("WS", filePath, wsUploadFile)) { + wsServer->sendTXT(num, "ERROR:Failed to create file"); + wsUploadInProgress = false; + return; + } + esp_task_wdt_reset(); + + wsUploadInProgress = true; + wsServer->sendTXT(num, "READY"); + } else { + wsServer->sendTXT(num, "ERROR:Invalid START format"); + } + } + break; + } + + case WStype_BIN: { + if (!wsUploadInProgress || !wsUploadFile) { + wsServer->sendTXT(num, "ERROR:No upload in progress"); + return; + } + + // Write binary data directly to file + esp_task_wdt_reset(); + size_t written = wsUploadFile.write(payload, length); + esp_task_wdt_reset(); + + if (written != length) { + wsUploadFile.close(); + wsUploadInProgress = false; + wsServer->sendTXT(num, "ERROR:Write failed - disk full?"); + return; + } + + wsUploadReceived += written; + + // Send progress update (every 64KB or at end) + static size_t lastProgressSent = 0; + if (wsUploadReceived - lastProgressSent >= 65536 || wsUploadReceived >= wsUploadSize) { + String progress = "PROGRESS:" + String(wsUploadReceived) + ":" + String(wsUploadSize); + wsServer->sendTXT(num, progress); + lastProgressSent = wsUploadReceived; + } + + // Check if upload complete + if (wsUploadReceived >= wsUploadSize) { + wsUploadFile.close(); + wsUploadInProgress = false; + + unsigned long elapsed = millis() - wsUploadStartTime; + float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0; + + Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), + wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps); + + wsServer->sendTXT(num, "DONE"); + lastProgressSent = 0; + } + break; + } + + default: + break; + } +} diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 1be07b4..ecc2d3d 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include @@ -34,9 +35,15 @@ class CrossPointWebServer { private: std::unique_ptr server = nullptr; + std::unique_ptr wsServer = nullptr; bool running = false; bool apMode = false; // true when running in AP mode, false for STA mode uint16_t port = 80; + uint16_t wsPort = 81; // WebSocket port + + // WebSocket upload state + void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length); + static void wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length); // File scanning void scanFiles(const char* path, const std::function& callback) const; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 08c0a0b..bfdbe3c 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -816,6 +816,151 @@ } let failedUploadsGlobal = []; +let wsConnection = null; +const WS_PORT = 81; +const WS_CHUNK_SIZE = 4096; // 4KB chunks - smaller for ESP32 stability + +// Get WebSocket URL based on current page location +function getWsUrl() { + const host = window.location.hostname; + return `ws://${host}:${WS_PORT}/`; +} + +// Upload file via WebSocket (faster, binary protocol) +function uploadFileWebSocket(file, onProgress, onComplete, onError) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(getWsUrl()); + let uploadStarted = false; + let sendingChunks = false; + + ws.binaryType = 'arraybuffer'; + + ws.onopen = function() { + console.log('[WS] Connected, starting upload:', file.name); + // Send start message: START::: + ws.send(`START:${file.name}:${file.size}:${currentPath}`); + }; + + ws.onmessage = async function(event) { + const msg = event.data; + console.log('[WS] Message:', msg); + + if (msg === 'READY') { + uploadStarted = true; + sendingChunks = true; + + // Small delay to let connection stabilize + await new Promise(r => setTimeout(r, 50)); + + try { + // Send file in chunks + const totalSize = file.size; + let offset = 0; + + while (offset < totalSize && ws.readyState === WebSocket.OPEN) { + const chunkSize = Math.min(WS_CHUNK_SIZE, totalSize - offset); + const chunk = file.slice(offset, offset + chunkSize); + const buffer = await chunk.arrayBuffer(); + + // Wait for buffer to clear - more aggressive backpressure + while (ws.bufferedAmount > WS_CHUNK_SIZE * 2 && ws.readyState === WebSocket.OPEN) { + await new Promise(r => setTimeout(r, 5)); + } + + if (ws.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket closed during upload'); + } + + ws.send(buffer); + offset += chunkSize; + + // Update local progress - cap at 95% since server still needs to write + // Final 100% shown when server confirms DONE + if (onProgress) { + const cappedOffset = Math.min(offset, Math.floor(totalSize * 0.95)); + onProgress(cappedOffset, totalSize); + } + } + + sendingChunks = false; + console.log('[WS] All chunks sent, waiting for DONE'); + } catch (err) { + console.error('[WS] Error sending chunks:', err); + sendingChunks = false; + ws.close(); + reject(err); + } + } else if (msg.startsWith('PROGRESS:')) { + // Server confirmed progress - log for debugging but don't update UI + // (local progress is smoother, server progress causes jumping) + console.log('[WS] Server progress:', msg); + } else if (msg === 'DONE') { + // Show 100% when server confirms completion + if (onProgress) onProgress(file.size, file.size); + ws.close(); + if (onComplete) onComplete(); + resolve(); + } else if (msg.startsWith('ERROR:')) { + const error = msg.substring(6); + ws.close(); + if (onError) onError(error); + reject(new Error(error)); + } + }; + + ws.onerror = function(event) { + console.error('[WS] Error:', event); + if (!uploadStarted) { + reject(new Error('WebSocket connection failed')); + } else if (!sendingChunks) { + reject(new Error('WebSocket error during upload')); + } + }; + + ws.onclose = function(event) { + console.log('[WS] Connection closed, code:', event.code, 'reason:', event.reason); + if (sendingChunks) { + reject(new Error('WebSocket closed unexpectedly')); + } + }; + }); +} + +// Upload file via HTTP (fallback method) +function uploadFileHTTP(file, onProgress, onComplete, onError) { + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append('file', file); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true); + + xhr.upload.onprogress = function(e) { + if (e.lengthComputable && onProgress) { + onProgress(e.loaded, e.total); + } + }; + + xhr.onload = function() { + if (xhr.status === 200) { + if (onComplete) onComplete(); + resolve(); + } else { + const error = xhr.responseText || 'Upload failed'; + if (onError) onError(error); + reject(new Error(error)); + } + }; + + xhr.onerror = function() { + const error = 'Network error'; + if (onError) onError(error); + reject(new Error(error)); + }; + + xhr.send(formData); + }); +} function uploadFile() { const fileInput = document.getElementById('fileInput'); @@ -836,8 +981,9 @@ function uploadFile() { let currentIndex = 0; const failedFiles = []; + let useWebSocket = true; // Try WebSocket first - function uploadNextFile() { + async function uploadNextFile() { if (currentIndex >= files.length) { // All files processed - show summary if (failedFiles.length === 0) { @@ -845,67 +991,71 @@ function uploadFile() { progressText.textContent = 'All uploads complete!'; setTimeout(() => { closeUploadModal(); - hydrate(); // Refresh file list instead of reloading + hydrate(); }, 1000); } else { progressFill.style.backgroundColor = '#e74c3c'; const failedList = failedFiles.map(f => f.name).join(', '); progressText.textContent = `${files.length - failedFiles.length}/${files.length} uploaded. Failed: ${failedList}`; - - // Store failed files globally and show banner failedUploadsGlobal = failedFiles; - setTimeout(() => { closeUploadModal(); showFailedUploadsBanner(); - hydrate(); // Refresh file list to show successfully uploaded files + hydrate(); }, 2000); } return; } const file = files[currentIndex]; - const formData = new FormData(); - formData.append('file', file); - - const xhr = new XMLHttpRequest(); - // Include path as query parameter since multipart form data doesn't make - // form fields available until after file upload completes - xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true); - progressFill.style.width = '0%'; - progressFill.style.backgroundColor = '#4caf50'; - progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})`; + progressFill.style.backgroundColor = '#27ae60'; + const methodText = useWebSocket ? ' [WS]' : ' [HTTP]'; + progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText}`; - xhr.upload.onprogress = function (e) { - if (e.lengthComputable) { - const percent = Math.round((e.loaded / e.total) * 100); - progressFill.style.width = percent + '%'; - progressText.textContent = - `Uploading ${file.name} (${currentIndex + 1}/${files.length}) — ${percent}%`; - } + const onProgress = (loaded, total) => { + const percent = Math.round((loaded / total) * 100); + progressFill.style.width = percent + '%'; + const speed = ''; // Could calculate speed here + progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText} — ${percent}%`; }; - xhr.onload = function () { - if (xhr.status === 200) { - currentIndex++; - uploadNextFile(); // upload next file - } else { - // Track failure and continue with next file - failedFiles.push({ name: file.name, error: xhr.responseText, file: file }); - currentIndex++; - uploadNextFile(); - } - }; - - xhr.onerror = function () { - // Track network error and continue with next file - failedFiles.push({ name: file.name, error: 'network error', file: file }); + const onComplete = () => { currentIndex++; uploadNextFile(); }; - xhr.send(formData); + const onError = (error) => { + failedFiles.push({ name: file.name, error: error, file: file }); + currentIndex++; + uploadNextFile(); + }; + + try { + if (useWebSocket) { + await uploadFileWebSocket(file, onProgress, null, null); + onComplete(); + } else { + await uploadFileHTTP(file, onProgress, null, null); + onComplete(); + } + } catch (error) { + console.error('Upload error:', error); + if (useWebSocket && error.message === 'WebSocket connection failed') { + // Fall back to HTTP for all subsequent uploads + console.log('WebSocket failed, falling back to HTTP'); + useWebSocket = false; + // Retry this file with HTTP + try { + await uploadFileHTTP(file, onProgress, null, null); + onComplete(); + } catch (httpError) { + onError(httpError.message); + } + } else { + onError(error.message); + } + } } uploadNextFile();