diff --git a/webui/web/explorer.html b/webui/web/explorer.html index 5309c72f..f0572c1e 100644 --- a/webui/web/explorer.html +++ b/webui/web/explorer.html @@ -2965,6 +2965,41 @@ under the License. // Upload // ============================================ + // Persistent widget (unlike showToast's fire-and-forget toasts) tracking + // overall byte progress across every file/part of the current upload + // batch, so a large file no longer looks stalled with no feedback. + let uploadProgressEl = null; + + function showUploadProgress(fileName, fileIndex, fileCount, bytesDone, bytesTotal) { + if (!uploadProgressEl) { + uploadProgressEl = document.createElement('div'); + uploadProgressEl.id = 'upload-progress-indicator'; + uploadProgressEl.className = 'fixed bottom-4 right-4 z-50 w-80 bg-white rounded-lg shadow-lg border border-gray-200 p-4'; + document.body.appendChild(uploadProgressEl); + } + + const percent = bytesTotal > 0 ? Math.min(100, Math.round((bytesDone / bytesTotal) * 100)) : 0; + const label = fileCount > 1 ? `File ${fileIndex} of ${fileCount}: ${fileName}` : fileName; + + uploadProgressEl.innerHTML = ` +
+

${escapeHtml(label)}

+ ${percent}% +
+
+
+
+

${formatSize(bytesDone)} / ${formatSize(bytesTotal)}

+ `; + } + + function hideUploadProgress() { + if (uploadProgressEl) { + uploadProgressEl.remove(); + uploadProgressEl = null; + } + } + function openUploadDialog() { if (!currentBucket) { showToast('Please select a bucket first', 'warning'); @@ -3009,23 +3044,38 @@ under the License. showToast(`Uploading ${files.length} files...`, 'info'); } - for (const file of files) { - const key = currentPrefix + file.name; + const totalBytes = files.reduce((sum, f) => sum + f.size, 0); + let bytesDoneBeforeCurrentFile = 0; - try { - if (file.size >= MULTIPART_THRESHOLD) { - // Use multipart upload for large files - await uploadMultipart(file, key); - } else { - // Use simple PUT for small files - await api.putObject(currentBucket, key, file); + try { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const key = currentPrefix + file.name; + + try { + if (file.size >= MULTIPART_THRESHOLD) { + // Use multipart upload for large files + await uploadMultipart(file, key, (bytesDoneInFile) => { + showUploadProgress(file.name, i + 1, files.length, bytesDoneBeforeCurrentFile + bytesDoneInFile, totalBytes); + }); + } else { + // Use simple PUT for small files (no per-chunk hook to report progress + // through, so report it in one step once the PUT actually resolves — + // matches the multipart path only firing onProgress after each part is + // confirmed uploaded, never optimistically before) + await api.putObject(currentBucket, key, file); + showUploadProgress(file.name, i + 1, files.length, bytesDoneBeforeCurrentFile + file.size, totalBytes); + } + bytesDoneBeforeCurrentFile += file.size; + successCount++; + } catch (error) { + console.error('Upload error:', error); + showToast(`Failed to upload ${file.name}: ${error.message}`, 'error'); + failCount++; } - successCount++; - } catch (error) { - console.error('Upload error:', error); - showToast(`Failed to upload ${file.name}: ${error.message}`, 'error'); - failCount++; } + } finally { + hideUploadProgress(); } if (successCount > 0) { @@ -3034,7 +3084,7 @@ under the License. } } - async function uploadMultipart(file, key) { + async function uploadMultipart(file, key, onProgress) { const contentType = file.type || 'application/octet-stream'; // Calculate optimal part size for this file @@ -3056,6 +3106,8 @@ under the License. const etag = await api.uploadPart(currentBucket, key, uploadId, partNumber, chunkBuffer); parts.push({ partNumber, etag }); + + if (onProgress) onProgress(end); } // Complete the multipart upload