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:
Miles Ward
2026-05-24 13:45:35 -04:00
parent ec171ede47
commit 2c1c80a7ca
19 changed files with 579 additions and 107 deletions
+5 -5
View File
@@ -9,10 +9,10 @@
:root {
/* Jet-black dark palette (default). Modelled on Netbox Labs's
near-black product chrome surfaces step from #000 → #0d → #16 → #1c
near-black product chrome, with surfaces stepping subtly upward
rather than the previous blue-tinted ramp, so the UI reads as a
genuine "dark" rather than "dim navy". */
--bg: #000000;
--bg: #030303;
--bg-panel: #0a0a0a;
--bg-panel-2: #141414;
--bg-elev: #1c1c1c;
@@ -26,7 +26,7 @@
--ok: #4ade80;
--border: #1f1f1f;
--border-soft: #141414;
--terminal-bg: #000000;
--terminal-bg: #050505;
--shadow-card: 0 1px 0 rgba(255,255,255,0.02), 0 8px 24px rgba(0,0,0,0.55);
--radius: 6px;
--radius-lg: 10px;
@@ -41,7 +41,7 @@
consistency. Designed against Netbox Labs's reference screenshot:
near-white surfaces, soft grey dividers, dark text. */
--bg: #f6f8fb;
--bg-panel: #ffffff;
--bg-panel: #fbfcfe;
--bg-panel-2: #f0f3f8;
--bg-elev: #e6ebf2;
--fg: #1c2330;
@@ -253,7 +253,7 @@ button, .btn {
cursor: pointer;
transition: background 0.12s ease;
}
button:hover, .btn:hover { background: var(--accent-dim); color: #fff; }
button:hover, .btn:hover { background: var(--accent-dim); color: #f4fffd; }
button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); }
button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); }
button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); }
+68 -52
View File
@@ -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]');
+1 -1
View File
@@ -29,7 +29,7 @@
<img src="/assets/logo.svg" alt="" />
<div>
<strong>OpenPXE</strong>
<div class="sub">v<span data-bind="version">0.4.0</span></div>
<div class="sub">v<span data-bind="version">0.4.1</span></div>
</div>
</div>
<nav>