feat: add real-time upload speed monitor with ETA

Shows "XX% · Y.Y MB/s · ~Zm Zs" during uploads. Speed is smoothed
over a 5-second rolling window. Works for both small (single PUT)
and large (multipart chunked) uploads.
This commit is contained in:
Zac Gaetano 2026-04-09 22:08:33 -04:00
parent 009530416f
commit 5113adb635

View file

@ -1134,6 +1134,52 @@ async function startUpload() {
// Node only signs URLs; file data never touches the server. // Node only signs URLs; file data never touches the server.
// Falls back to server-proxied chunked upload if presigned fails. // Falls back to server-proxied chunked upload if presigned fails.
// ============================================================ // ============================================================
// Upload speed tracker — smoothed speed + ETA
class SpeedTracker {
constructor(totalBytes) {
this.total = totalBytes;
this.startTime = Date.now();
this.samples = []; // {time, bytes}
this.uploaded = 0;
}
update(bytes) {
this.uploaded = bytes;
const now = Date.now();
this.samples.push({ time: now, bytes });
// Keep last 5 seconds of samples for smoothing
const cutoff = now - 5000;
this.samples = this.samples.filter(s => s.time >= cutoff);
}
speed() {
if (this.samples.length < 2) return 0;
const first = this.samples[0];
const last = this.samples[this.samples.length - 1];
const dt = (last.time - first.time) / 1000;
if (dt < 0.5) return 0;
return (last.bytes - first.bytes) / dt;
}
eta() {
const s = this.speed();
if (s <= 0) return '';
const remaining = this.total - this.uploaded;
const secs = Math.round(remaining / s);
if (secs < 60) return secs + 's';
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
}
format(pct) {
const s = this.speed();
let txt = pct + '%';
if (s > 0) {
const mbps = (s / (1024 * 1024)).toFixed(1);
txt += ' \u00b7 ' + mbps + ' MB/s';
const e = this.eta();
if (e) txt += ' \u00b7 ~' + e;
}
return txt;
}
}
const UPLOAD_CONCURRENCY = 6; // concurrent file uploads const UPLOAD_CONCURRENCY = 6; // concurrent file uploads
const CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB per chunk const CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB per chunk
const CHUNKS_PARALLEL = 6; // concurrent chunks per file const CHUNKS_PARALLEL = 6; // concurrent chunks per file
@ -1161,6 +1207,8 @@ async function uploadFilePresigned(item, idx) {
const mime = item.file.type || 'application/octet-stream'; const mime = item.file.type || 'application/octet-stream';
const totalParts = Math.max(1, Math.ceil(item.file.size / CHUNK_SIZE)); const totalParts = Math.max(1, Math.ceil(item.file.size / CHUNK_SIZE));
const tracker = new SpeedTracker(item.file.size);
// Small files (<= 8 MB): single presigned PUT // Small files (<= 8 MB): single presigned PUT
if (totalParts === 1) { if (totalParts === 1) {
setFileStatus(idx, 'uploading', 'Getting URL\u2026'); setFileStatus(idx, 'uploading', 'Getting URL\u2026');
@ -1172,9 +1220,10 @@ async function uploadFilePresigned(item, idx) {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => { xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) { if (e.lengthComputable) {
tracker.update(e.loaded);
const pct = Math.round(e.loaded / e.total * 100); const pct = Math.round(e.loaded / e.total * 100);
if (pb) pb.style.width = pct + '%'; if (pb) pb.style.width = pct + '%';
setFileStatus(idx, 'uploading', pct + '%'); setFileStatus(idx, 'uploading', tracker.format(pct));
} }
}); });
xhr.addEventListener('load', async () => { xhr.addEventListener('load', async () => {
@ -1220,16 +1269,18 @@ async function uploadFilePresigned(item, idx) {
if (e.lengthComputable) { if (e.lengthComputable) {
const chunkPct = e.loaded / e.total; const chunkPct = e.loaded / e.total;
const totalDone = uploaded + chunkPct * chunkSize; const totalDone = uploaded + chunkPct * chunkSize;
tracker.update(totalDone);
const pct = Math.round(totalDone / item.file.size * 100); const pct = Math.round(totalDone / item.file.size * 100);
if (pb) pb.style.width = Math.min(pct, 99) + '%'; if (pb) pb.style.width = Math.min(pct, 99) + '%';
setFileStatus(idx, 'uploading', Math.min(pct, 99) + '%'); setFileStatus(idx, 'uploading', tracker.format(Math.min(pct, 99)));
} }
}); });
completedParts.push({ PartNumber: i + 1, ETag: etag }); completedParts.push({ PartNumber: i + 1, ETag: etag });
uploaded += chunkSize; uploaded += chunkSize;
tracker.update(uploaded);
const pct = Math.round(uploaded / item.file.size * 100); const pct = Math.round(uploaded / item.file.size * 100);
if (pb) pb.style.width = Math.min(pct, 99) + '%'; if (pb) pb.style.width = Math.min(pct, 99) + '%';
setFileStatus(idx, 'uploading', Math.min(pct, 99) + '%'); setFileStatus(idx, 'uploading', tracker.format(Math.min(pct, 99)));
} catch (e) { chunkError = e; } } catch (e) { chunkError = e; }
} }
} }