v0.4.1: harden ISO uploads and beta UI polish
Add browser-safe chunked ISO uploads with progress, partial-file visibility, offset validation, and abort cleanup while keeping the legacy multipart endpoint for API clients. Record host-log validation coverage, keep the queue/status UI copy clean, move release docs to 0.4.1, and tighten the dark theme to a near-black Netbox-style palette.
This commit is contained in:
+68
-52
@@ -346,68 +346,84 @@
|
||||
});
|
||||
file.onchange = () => { if (file.files[0]) upload(file.files[0]); };
|
||||
|
||||
// Upload telemetry. We surface bytes-sent + percent + ETA so when
|
||||
// an upload stalls (e.g. a reverse proxy is buffering or rejecting
|
||||
// a >100MB body) the operator can see it instead of staring at a
|
||||
// 0% bar. We also tag the most common failure modes — timeout,
|
||||
// network drop, HTTP 413/502/504 — with hints so the path forward
|
||||
// is obvious from the UI.
|
||||
function upload(f) {
|
||||
// Chunked upload telemetry. The old browser path posted one huge
|
||||
// multipart body, which left operators staring at 0% when a reverse
|
||||
// proxy buffered or rejected the request before OpenPXE saw it. This
|
||||
// path writes small raw chunks; each acknowledged chunk advances the
|
||||
// bar and leaves a visible .partial file in the ISO directory.
|
||||
async function upload(f) {
|
||||
const started = Date.now();
|
||||
const bar = $('#bar');
|
||||
const setStatus = (text, cls) => { upMsg.textContent = text; upMsg.className = 'msg ' + (cls || ''); };
|
||||
setStatus('Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…');
|
||||
prog.classList.add('active');
|
||||
bar.style.width = '0%';
|
||||
const fd = new FormData(); fd.append('file', f);
|
||||
const xhr = new XMLHttpRequest();
|
||||
// 4-hour ceiling for very large ISOs over slow links. Browser
|
||||
// default is 0 (never time out); we set an explicit cap so a
|
||||
// stalled connection doesn't masquerade as "still uploading".
|
||||
xhr.timeout = 4 * 60 * 60 * 1000;
|
||||
xhr.upload.onprogress = e => {
|
||||
if (!e.lengthComputable) return;
|
||||
const pct = (e.loaded / e.total) * 100;
|
||||
const update = (loaded, total, phase) => {
|
||||
const pct = total > 0 ? Math.min(100, (loaded / total) * 100) : 100;
|
||||
bar.style.width = pct.toFixed(1) + '%';
|
||||
const elapsed = (Date.now() - started) / 1000;
|
||||
const rate = elapsed > 0 ? e.loaded / elapsed : 0;
|
||||
const remain = rate > 0 ? (e.total - e.loaded) / rate : 0;
|
||||
const elapsed = Math.max(0.001, (Date.now() - started) / 1000);
|
||||
const rate = loaded > 0 ? loaded / elapsed : 0;
|
||||
const remain = rate > 0 ? (total - loaded) / rate : 0;
|
||||
setStatus(
|
||||
'Uploading ' + f.name + ' — ' +
|
||||
fmtBytes(e.loaded) + ' of ' + fmtBytes(e.total) +
|
||||
phase + ' ' + f.name + ' - ' +
|
||||
fmtBytes(loaded) + ' of ' + fmtBytes(total) +
|
||||
' (' + pct.toFixed(1) + '%, ' + fmtBytes(rate) + '/s' +
|
||||
(remain > 0 ? ', ' + Math.ceil(remain) + 's left' : '') + ')');
|
||||
};
|
||||
xhr.onload = () => {
|
||||
prog.classList.remove('active');
|
||||
bar.style.width = '0';
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
setStatus('Uploaded & analyzed: ' + f.name + ' (' + fmtBytes(f.size) + ')', 'ok');
|
||||
render('storage');
|
||||
return;
|
||||
}
|
||||
const failText = async (r) => {
|
||||
const text = (await r.text()).slice(0, 240);
|
||||
let hint = '';
|
||||
if (xhr.status === 413) hint = ' — body too large. A reverse proxy in front of OpenPXE (Cloudflare free tier caps at 100 MB) likely rejected it. Try the LAN IP directly.';
|
||||
else if (xhr.status === 502) hint = ' — bad gateway. Reverse proxy lost the upstream mid-stream.';
|
||||
else if (xhr.status === 504) hint = ' — gateway timeout. The upload took longer than the proxy allows; try the LAN IP.';
|
||||
else if (xhr.status === 409) hint = ' — an ISO with this name already exists. Remove the old one or rename.';
|
||||
setStatus('Upload failed: HTTP ' + xhr.status + ' ' + (xhr.responseText || '').slice(0, 200) + hint, 'err');
|
||||
if (r.status === 413) hint = ' - body too large. A proxy likely rejected this chunk.';
|
||||
else if (r.status === 502) hint = ' - bad gateway. Proxy lost the upstream mid-stream.';
|
||||
else if (r.status === 504) hint = ' - gateway timeout. Try the LAN IP directly.';
|
||||
else if (r.status === 409) hint = ' - name conflict or offset mismatch. Remove the old ISO and retry.';
|
||||
return 'HTTP ' + r.status + ' ' + text + hint;
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
|
||||
let uploadId = null;
|
||||
setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')');
|
||||
prog.classList.add('active');
|
||||
bar.style.width = '1%';
|
||||
|
||||
try {
|
||||
const begin = await postJSON('/api/uploads', {
|
||||
filename: f.name,
|
||||
size_bytes: f.size,
|
||||
});
|
||||
if (!begin.ok) throw new Error(await failText(begin));
|
||||
const session = await begin.json();
|
||||
uploadId = session.upload_id;
|
||||
const chunkSize = Math.max(1024 * 1024, Number(session.chunk_size || 8 * 1024 * 1024));
|
||||
|
||||
let offset = Number(session.offset || 0);
|
||||
let finished = null;
|
||||
do {
|
||||
const end = Math.min(offset + chunkSize, f.size);
|
||||
const complete = end >= f.size;
|
||||
const r = await fetch('/api/uploads/' + encodeURIComponent(uploadId), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'x-openpxe-upload-offset': String(offset),
|
||||
'x-openpxe-upload-complete': complete ? 'true' : 'false',
|
||||
},
|
||||
body: f.slice(offset, end),
|
||||
});
|
||||
if (!r.ok) throw new Error(await failText(r));
|
||||
const j = await r.json();
|
||||
offset = Number(j.offset || end);
|
||||
update(offset, f.size, complete ? 'Analyzing' : 'Uploading');
|
||||
if (j.complete) finished = j.iso || true;
|
||||
} while (!finished);
|
||||
|
||||
setStatus('Uploaded and analyzed: ' + f.name + ' (' + fmtBytes(f.size) + ')', 'ok');
|
||||
render('storage');
|
||||
} catch (err) {
|
||||
if (uploadId) {
|
||||
try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); }
|
||||
catch {}
|
||||
}
|
||||
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
|
||||
} finally {
|
||||
prog.classList.remove('active');
|
||||
setStatus('Upload failed: network error or connection closed mid-stream. ' +
|
||||
'If you went through a reverse proxy, try the server\'s LAN IP directly.', 'err');
|
||||
};
|
||||
xhr.ontimeout = () => {
|
||||
prog.classList.remove('active');
|
||||
setStatus('Upload timed out after 4 hours.', 'err');
|
||||
};
|
||||
xhr.onabort = () => {
|
||||
prog.classList.remove('active');
|
||||
setStatus('Upload aborted.', 'err');
|
||||
};
|
||||
xhr.open('POST', '/api/isos');
|
||||
xhr.send(fd);
|
||||
if (!upMsg.className.includes('ok')) bar.style.width = '0';
|
||||
}
|
||||
}
|
||||
|
||||
// ── ISO table (mixed local + NFS) ──
|
||||
@@ -987,7 +1003,7 @@
|
||||
}
|
||||
|
||||
// Set the sidebar footer "Service status:" line. The chip itself moved
|
||||
// off the topbar in v0.4.0 — operators wanted readiness, advertised
|
||||
// off the topbar in v0.4.x: operators wanted readiness, advertised
|
||||
// URL, and the boot IP grouped together as the bottom-left summary.
|
||||
function setReady(state) {
|
||||
const dot = $('[data-bind=ready_dot]');
|
||||
|
||||
Reference in New Issue
Block a user