feat: add webui concurrent uploads with progress tracking

Queue uploads with a concurrency limit, track per-file and aggregate progress,
and preserve batch bucket/prefix context.

Limit concurrent uploads to three. Display aggregate byte progress as a
percentage and bytesDone / bytesTotal, completion and failure totals, and the
number of active uploads in the message box. Display Queued, Uploading, and
Failed files with individual progress bars; completed uploads remain in the
aggregate totals without filling the live file list.

Allow queued and active uploads to be cancelled from the progress widget. Remove
queued uploads immediately, abort active object and multipart part requests, and
abort a multipart upload on the server after its upload ID is available.
This commit is contained in:
Ben McClelland
2026-09-21 09:52:41 -07:00
parent 97c997cb76
commit 7e9f556252
2 changed files with 225 additions and 67 deletions
+8 -4
View File
@@ -1395,7 +1395,7 @@ class VersityAPI {
/**
* Upload an object (PutObject) - for small files < 5MB
*/
async putObject(bucket, key, file, contentType = null) {
async putObject(bucket, key, file, contentType = null, signal = undefined) {
const finalContentType = contentType || file.type || 'application/octet-stream';
const path = `/${bucket}/${encodeS3Key(key)}`;
@@ -1408,6 +1408,7 @@ class VersityAPI {
method: 'PUT',
headers: signed.headers,
body: file,
signal,
});
if (!response.ok) {
@@ -1585,12 +1586,13 @@ class VersityAPI {
* Initiate a multipart upload
* Returns uploadId needed for subsequent parts
*/
async createMultipartUpload(bucket, key, contentType = 'application/octet-stream') {
async createMultipartUpload(bucket, key, contentType = 'application/octet-stream', signal = undefined) {
const fetchParams = await this.buildFetchParams('POST', `/${bucket}/${encodeS3Key(key)}`, { uploads: '' }, '', false, contentType);
const response = await fetch(fetchParams.url, {
method: 'POST',
headers: fetchParams.headers,
signal,
});
if (!response.ok) {
@@ -1614,7 +1616,7 @@ class VersityAPI {
* Upload a single part of a multipart upload
* Returns ETag needed for CompleteMultipartUpload
*/
async uploadPart(bucket, key, uploadId, partNumber, data) {
async uploadPart(bucket, key, uploadId, partNumber, data, signal = undefined) {
const arrayBuffer = data instanceof ArrayBuffer ? data : await data.arrayBuffer();
const path = `/${bucket}/${encodeS3Key(key)}`;
const queryParams = {
@@ -1635,6 +1637,7 @@ class VersityAPI {
method: 'PUT',
headers: signed.headers,
body: arrayBuffer,
signal,
});
if (!response.ok) {
@@ -1669,7 +1672,7 @@ class VersityAPI {
* Complete a multipart upload
* parts should be an array of { partNumber, etag }
*/
async completeMultipartUpload(bucket, key, uploadId, parts) {
async completeMultipartUpload(bucket, key, uploadId, parts, signal = undefined) {
let body = '<?xml version="1.0" encoding="UTF-8"?>\n<CompleteMultipartUpload>';
// Sort parts by partNumber
@@ -1690,6 +1693,7 @@ class VersityAPI {
method: 'POST',
headers: fetchParams.headers,
body: body,
signal,
});
if (!response.ok) {