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
+46
-23
@@ -8,23 +8,26 @@
|
||||
* CSS lands). */
|
||||
|
||||
:root {
|
||||
/* Dark palette (default). */
|
||||
--bg: #0b1018;
|
||||
--bg-panel: #121826;
|
||||
--bg-panel-2: #1a2334;
|
||||
--bg-elev: #223047;
|
||||
--fg: #e4e8ef;
|
||||
--fg-dim: #8a94a7;
|
||||
--fg-dimmer: #5a6379;
|
||||
--accent: #00d4b4; /* Netbox-ish teal */
|
||||
/* Jet-black dark palette (default). Modelled on Netbox Labs's
|
||||
near-black product chrome — surfaces step from #000 → #0d → #16 → #1c
|
||||
rather than the previous blue-tinted ramp, so the UI reads as a
|
||||
genuine "dark" rather than "dim navy". */
|
||||
--bg: #000000;
|
||||
--bg-panel: #0a0a0a;
|
||||
--bg-panel-2: #141414;
|
||||
--bg-elev: #1c1c1c;
|
||||
--fg: #e8eaed;
|
||||
--fg-dim: #9aa0a6;
|
||||
--fg-dimmer: #6b7077;
|
||||
--accent: #00d4b4; /* Netbox-ish teal — kept for brand */
|
||||
--accent-dim: #07a38c;
|
||||
--warn: #ffb347;
|
||||
--err: #ef6e6e;
|
||||
--ok: #4ade80;
|
||||
--border: #223047;
|
||||
--border-soft: #172033;
|
||||
--terminal-bg: #06090e;
|
||||
--shadow-card: 0 1px 0 rgba(255,255,255,0.02), 0 8px 24px rgba(0,0,0,0.25);
|
||||
--border: #1f1f1f;
|
||||
--border-soft: #141414;
|
||||
--terminal-bg: #000000;
|
||||
--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;
|
||||
--sidebar-w: 240px;
|
||||
@@ -113,10 +116,30 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
|
||||
}
|
||||
.sidebar nav a.active .count { background: var(--accent); color: #002923; }
|
||||
.sidebar .footer {
|
||||
padding: 10px 18px; border-top: 1px solid var(--border);
|
||||
padding: 12px 18px; border-top: 1px solid var(--border);
|
||||
color: var(--fg-dimmer); font-size: 11px;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.sidebar .footer code { background: transparent; color: var(--fg-dim); padding: 0; }
|
||||
.sidebar .footer code { background: transparent; color: var(--fg-dim); padding: 0;
|
||||
font-size: 11px; word-break: break-all; }
|
||||
.sidebar .footer .status-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.sidebar .footer .status-row .dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
|
||||
background: var(--fg-dimmer); flex: none;
|
||||
}
|
||||
.sidebar .footer .status-row .dot.ok { background: var(--ok);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--ok) 60%, transparent); }
|
||||
.sidebar .footer .status-row .dot.err { background: var(--err); }
|
||||
.sidebar .footer .status-row .dot.warn { background: var(--warn); }
|
||||
.sidebar .footer .status-label { color: var(--fg-dim); }
|
||||
.sidebar .footer .status-value { color: var(--fg); font-weight: 600; }
|
||||
.sidebar .footer .status-value.ok { color: var(--ok); }
|
||||
.sidebar .footer .status-value.err { color: var(--err); }
|
||||
.sidebar .footer .status-value.warn { color: var(--warn); }
|
||||
.sidebar .footer .footer-sub { color: var(--fg-dimmer); margin-top: 2px; }
|
||||
|
||||
/* ── Top bar ───────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -431,29 +454,29 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
||||
.terminal .input-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: #0a0e15;
|
||||
border-top: 1px solid #1d2330;
|
||||
background: #050505;
|
||||
border-top: 1px solid #181818;
|
||||
}
|
||||
.terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); }
|
||||
.terminal .input-row input {
|
||||
flex: 1; background: transparent; border: 0; color: #e4e8ef;
|
||||
flex: 1; background: transparent; border: 0; color: var(--fg);
|
||||
font: inherit; font-family: var(--mono); font-size: 13px;
|
||||
outline: none; padding: 4px 0;
|
||||
}
|
||||
.terminal .toolbar {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
padding: 8px 14px;
|
||||
background: #0a0e15;
|
||||
border-bottom: 1px solid #1d2330;
|
||||
font-size: 12px; color: #8a94a7;
|
||||
background: #050505;
|
||||
border-bottom: 1px solid #181818;
|
||||
font-size: 12px; color: var(--fg-dim);
|
||||
}
|
||||
.terminal .toolbar .right { margin-left: auto; display: flex; gap: 6px; }
|
||||
.terminal .toolbar button {
|
||||
padding: 3px 9px; font-size: 11px;
|
||||
background: transparent; color: #8a94a7; border: 1px solid #1d2330;
|
||||
background: transparent; color: var(--fg-dim); border: 1px solid #181818;
|
||||
font-weight: 500;
|
||||
}
|
||||
.terminal .toolbar button:hover { color: #e4e8ef; background: #1d2330; }
|
||||
.terminal .toolbar button:hover { color: var(--fg); background: #181818; }
|
||||
|
||||
/* ── About card ─────────────────────────────────────────────────── */
|
||||
.about-hero { padding: 20px 24px; }
|
||||
|
||||
+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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<img src="/assets/logo.svg" alt="" />
|
||||
<div>
|
||||
<strong>OpenPXE</strong>
|
||||
<div class="sub">v<span data-bind="version">0.3.2</span></div>
|
||||
<div class="sub">v<span data-bind="version">0.4.0</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<nav>
|
||||
@@ -51,7 +51,12 @@
|
||||
<a data-view="about">About</a>
|
||||
</nav>
|
||||
<div class="footer">
|
||||
Advertised to clients<br/>
|
||||
<div class="status-row">
|
||||
<span class="dot" data-bind="ready_dot" title="Server readiness"></span>
|
||||
<span class="status-label">Service status:</span>
|
||||
<span class="status-value" data-bind="ready_label">checking…</span>
|
||||
</div>
|
||||
<div class="footer-sub">Advertised to clients</div>
|
||||
<code>{{BASE_URL}}</code>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -59,7 +64,6 @@
|
||||
<header class="topbar">
|
||||
<h1 data-bind="view_title">Dashboard</h1>
|
||||
<div class="spacer"></div>
|
||||
<span class="chip" data-bind="ready_chip" title="Server readiness">checking…</span>
|
||||
<span class="chip"><strong data-bind="iso_count2">0</strong> images</span>
|
||||
<span class="chip"><strong data-bind="client_count2">0</strong> clients</span>
|
||||
<span class="chip"><strong data-bind="queue_count2">0</strong> in queue</span>
|
||||
|
||||
Reference in New Issue
Block a user