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:
parent
009530416f
commit
5113adb635
1 changed files with 54 additions and 3 deletions
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue