v0.4.0: upload telemetry, host log, jet-black UI

- Upload reliability + diagnostics:
  - api_upload_iso now distinguishes clean EOF from mid-stream errors;
    a truncated multipart body (proxy buffer cap, network drop) returns
    400 with the cause and a "try the LAN IP" hint instead of silently
    finalising a partial file.
  - Per-stage tracing (begin/MB-watermark/finish/abort) so a stuck
    upload is debuggable from the Terminal tab.
  - Web upload UI surfaces bytes/total, percent, throughput, ETA, and
    maps 413/502/504/network-drop to actionable hints.
- New BootLog feature under Hosts:
  - openpxe-core::BootLog — bounded in-memory ring (500) + append-only
    JSONL on disk, recording (timestamp, mac, ip, target_id,
    target_title) every time a boot entry script is served.
  - iPXE per-entry chain URLs grow ?mac=${mac}; password prompt
    submission carries it through; host-binding short-circuit uses the
    bound MAC. ConnectInfo<SocketAddr> wired for peer IP capture (with
    optional fallback so tower::oneshot in tests still works).
  - GET /api/boot-log endpoint + Host log table under the Hosts tab.
- UI changes:
  - Queue card header "Forge" → "Status".
  - Removed Tinkerbell attribution sentence from Hosts tab.
  - Topbar readiness chip moved into the sidebar footer as
    "Service status: Ready / Advertised to clients / <url>", grouping
    advertised PXE URL with operator-relevant status.
  - Jet-black dark palette (#000 / #0a0a0a / #141414 / #1c1c1c)
    replacing the blue-tinted ramp; terminal toolbar/input recoloured
    to match.
- 89 tests passing (was 85 in v0.3.2); cargo clippy --workspace
  --all-targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Miles Ward
2026-05-24 13:10:40 -04:00
co-authored by Claude Opus 4.7
parent 115ba779da
commit ec171ede47
11 changed files with 767 additions and 78 deletions
+104 -20
View File
@@ -295,7 +295,7 @@
return el('div', {class:'grid'}, [
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Forge')),
el('header', {}, el('h2', {}, 'Status')),
queueProgressWidget(imaging, entries.length),
]),
el('div', {class:'card'}, [
@@ -346,27 +346,66 @@
});
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) {
upMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…';
upMsg.className = 'msg';
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) $('#bar').style.width = (e.loaded/e.total*100).toFixed(1) + '%';
if (!e.lengthComputable) return;
const pct = (e.loaded / e.total) * 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;
setStatus(
'Uploading ' + f.name + ' — ' +
fmtBytes(e.loaded) + ' of ' + fmtBytes(e.total) +
' (' + pct.toFixed(1) + '%, ' + fmtBytes(rate) + '/s' +
(remain > 0 ? ', ' + Math.ceil(remain) + 's left' : '') + ')');
};
xhr.onload = () => {
prog.classList.remove('active');
$('#bar').style.width = '0';
bar.style.width = '0';
if (xhr.status >= 200 && xhr.status < 300) {
upMsg.textContent = 'Uploaded & analyzed.'; upMsg.className = 'msg ok';
setStatus('Uploaded & analyzed: ' + f.name + ' (' + fmtBytes(f.size) + ')', 'ok');
render('storage');
} else {
upMsg.textContent = 'Upload failed: ' + xhr.status + ' ' + xhr.responseText;
upMsg.className = 'msg err';
return;
}
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');
};
xhr.onerror = () => {
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.onerror = () => { upMsg.textContent = 'Network error.'; upMsg.className = 'msg err'; };
xhr.open('POST', '/api/isos');
xhr.send(fd);
}
@@ -602,9 +641,11 @@
},
hosts: async () => {
const [{ hosts = [] }, isos] = await Promise.all([
const [{ hosts = [] }, isos, bootLogRes] = await Promise.all([
getJSON('/api/hosts'), getJSON('/api/isos'),
getJSON('/api/boot-log').catch(() => ({ events: [] })),
]);
const bootEvents = bootLogRes.events || [];
const targets = isos.flatMap(i => i.boot_entries.map(e => ({
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family),
})));
@@ -680,8 +721,7 @@
upsertBtn, msg,
el('p', {class:'msg', style:'margin-top:14px'},
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' +
'short-circuits past the interactive menu and chains directly. ' +
'Inspired by Tinkerbell smee\'s MAC-prepended URL pattern.'),
'short-circuits past the interactive menu and chains directly.'),
]),
]),
el('div', {class:'card'}, [
@@ -691,6 +731,37 @@
]),
table,
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Host log'),
el('span', {class:'sub'},
bootEvents.length + ' event' + (bootEvents.length === 1 ? '' : 's')),
]),
bootEvents.length
? el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th', {}, 'Time'),
el('th', {}, 'MAC'),
el('th', {}, 'IP'),
el('th', {}, 'Image'),
])),
el('tbody', {},
bootEvents.map(e => el('tr', {}, [
el('td', {}, fmtAgo(e.timestamp)),
el('td', {class:'mono'}, e.mac || el('span', {class:'tag'}, '(unknown)')),
el('td', {class:'mono'}, e.ip ? String(e.ip) : '—'),
el('td', {}, [
el('span', {style:'font-weight:600'}, e.target_title || e.target_id),
el('div', {class:'meta',
style:'color:var(--fg-dim);font-size:11.5px;margin-top:2px'},
e.target_id),
]),
]))),
])
: el('div', {class:'empty'},
'No boot events yet. When a PXE client chains a boot entry, ' +
'it lands here with the MAC, IP, and image it received.'),
]),
]);
},
@@ -915,6 +986,24 @@
}
}
// Set the sidebar footer "Service status:" line. The chip itself moved
// off the topbar in v0.4.0 — 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]');
const lbl = $('[data-bind=ready_label]');
if (!dot || !lbl) return;
const map = {
ready: { cls: 'ok', text: 'Ready' },
notready: { cls: 'err', text: 'Not ready' },
unreachable: { cls: 'err', text: 'Unreachable' },
};
const m = map[state] || { cls: 'warn', text: 'Checking…' };
dot.className = 'dot ' + m.cls;
lbl.className = 'status-value ' + m.cls;
lbl.textContent = m.text;
}
async function refreshChips() {
try {
const s = await getJSON('/api/status');
@@ -924,14 +1013,9 @@
$$('[data-bind=client_count],[data-bind=client_count2]').forEach(n => n.textContent = String(s.client_count));
$$('[data-bind=queue_count],[data-bind=queue_count2]').forEach(n => n.textContent = String(s.queue_count));
$$('[data-bind=host_count]').forEach(n => n.textContent = String(s.host_bindings || 0));
const chip = $('[data-bind=ready_chip]');
if (chip) {
if (r.ok) { chip.textContent = '● ready'; chip.className = 'chip ready'; }
else { chip.textContent = '● not ready'; chip.className = 'chip notready'; }
}
setReady(r.ok ? 'ready' : 'notready');
} catch {
const chip = $('[data-bind=ready_chip]');
if (chip) { chip.textContent = '● unreachable'; chip.className = 'chip notready'; }
setReady('unreachable');
}
}