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:
co-authored by
Claude Opus 4.7
parent
115ba779da
commit
ec171ede47
+104
-20
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user