Initial commit: PXEForge Phases 1-4

Container-native PXE boot server in Rust, designed as a clean-room
alternative to iVentoy that never touches the client OS trust store.
This is the first commit of the project; it lands the full output of
Phases 1, 2, 3, and 4 in one shot.

## Phase 1 — protocol stack

- 8-crate workspace (core, dhcp-proxy, tftp, http-api, iso-store,
  ipxe-assets, webui, pxeforge bin).
- DHCP proxy (RFC 4578): replies with boot info only, never leases —
  sidesteps CAP_NET_RAW. Architecture-aware bootfile selection from
  option 93 (BIOS, IA32, x64-UEFI alias 0x0007/0x0009, ARM64).
- TFTP server with full OACK negotiation: blksize, tsize, windowsize.
  Without it a 1 MiB iPXE binary takes 2000 packets and unusably long.
- Two-stage iPXE chain: firmware PXE -> TFTP iPXE binary -> iPXE
  re-DHCPs with user-class iPXE -> HTTP /boot.ipxe -> kernel+initrd.
- HTTP server (axum) with byte-Range ISO streaming and an in-place
  ISO9660 lookup so kernel/initrd are served from inside the ISO
  without ever extracting it to disk.
- Linux ISOs boot via kernel+initrd extraction (memdisk/sanboot fail
  for >1-2 GiB modern distros). Distro-family detection drives the
  cmdline (Debian/Ubuntu, RHEL/Fedora, openSUSE, Arch, Alpine).

## Phase 2 — UX + Windows

- Hierarchical PXE menu (Default / Installers / Tools / Gated
  Deployment) generated from settings — no hand-written .ipxe paths
  surface in the UI. Number-key + letter hotkeys, BIOS+UEFI variants
  for some RHEL ISOs.
- Gated Deployment "horse-race" queue: clients join, operator picks
  one ISO, every gate launches simultaneously via tokio::sync::Notify.
- Bootimus-pattern Windows: WimPatcher injects a CRLF startnet.cmd
  into boot.wim so vanilla WinPE net-uses an SMB share and runs
  setup.exe. All Microsoft-signed; no test certs, no testsigning,
  no httpdisk.sys. SmbManager supervises smbd start/stop/SIGHUP.
- Netbox-style dark UI, fully offline (no CDN, no external fonts).

## Phase 3 — MVP hardening

- TFTP retransmit rewrite with explicit window tracking — UEFI SNP
  clients no longer hang on files that end mid-window. 4 new tests.
- DHCP broadcast-flag honored per RFC 2131 §4.1.
- Multi-arch container (linux/amd64 + linux/arm64). Entrypoint chowns
  bind-mounts as root then drops to uid 10001 via gosu.
- /healthz + /readyz split from /api/status — readyz fails if no
  iPXE binaries are bundled.
- pxeforge seed --from <path> CLI: same pipeline as web upload (slug,
  sha256, introspection, boot-entry).
- All timestamps RFC 3339 (browser Date couldn't parse the 9-tuple).
- Gate poll retains assignment until operator releases — clients that
  retry on transient network errors reuse the assignment instead of
  falling back to the menu.
- Custom OpenShift SCC: hostNetwork + NET_BIND_SERVICE only, no
  NET_RAW.

## Phase 4 — UI restructure + remote storage

- Web UI rebuilt around six tabs inspired by the iVentoy layout:
  Dashboard / Network / Forge Gate / Storage / Terminal / About.
  Old "Monitoring/Content/Configuration" sidebar groups are gone.
- NFS share manager (crates/iso-store/src/nfs.rs): mount NFSv3 or
  NFSv4.1 shares as ISO sources instead of uploading every file
  into the PVC. New IsoSource enum on IsoMeta lets the store resolve
  Local vs NFS lazily. Persisted to <work_dir>/nfs.json; failed
  mounts surface in the UI rather than blocking startup.
- Dockerfile gains nfs-common + iproute2; mounting NFS in-container
  also requires CAP_SYS_ADMIN. Documented in docs/architecture.md.
- LogBus + tracing layer in core: 500-line ring buffer + broadcast
  channel feed an SSE endpoint at /api/log/stream.
- Operator terminal at /api/terminal: whitelisted commands (status,
  isos, clients, gate, nfs, smb, log) — deliberately not a shell.
  Output mirrored onto the LogBus so the live tail and the terminal
  pane share one timeline.
- Network tab: read-only nic_name / subnet_mask / gateway probed
  from `ip` at startup; only DNS server is editable. Editing IP/mask
  on a hot UI would silently break PXE for every client mid-boot.
- Bootimus parity (releases v0.1.55 -> v0.1.62): amber row tint on
  un-bootable ISOs with inline reasons, dashboard "won't boot" panel.

## Tests

56 tests passing across the workspace:
- 16 core (LogBus, gate, settings, arch, client)
- 1 dhcp-proxy (raw option-93 extraction)
- 8 http-api unit (range parsing, terminal split/format)
- 13 http-api integration (gated deployment, range, settings, NFS,
  terminal, log SSE, network endpoint, ui assets, no-external-urls)
- 12 iso-store (introspect, slugify, smb, windows wim, NFS options)
- 6 tftp (RRQ parsing, plan_window edges)

cargo build --workspace and cargo clippy --workspace --all-targets
both finish clean (warnings only, no errors).
This commit is contained in:
Miles Ward
2026-04-29 02:47:00 -04:00
commit cc309da062
67 changed files with 9032 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "pxeforge-webui"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Embedded single-file web UI for PXEForge"
[lints]
workspace = true
[dependencies]
+371
View File
@@ -0,0 +1,371 @@
/* PXEForge web UI — Netbox-style layout, fully offline.
* Design tokens are CSS variables so a later phase can re-theme without
* touching markup or JS. */
:root {
--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 */
--accent-dim: #07a38c;
--warn: #ffb347;
--err: #ef6e6e;
--ok: #4ade80;
--border: #223047;
--border-soft: #172033;
--radius: 6px;
--radius-lg: 10px;
--sidebar-w: 240px;
--topbar-h: 54px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0; font-family: var(--sans); font-size: 14px; line-height: 1.5;
background: var(--bg); color: var(--fg);
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code, kbd { font-family: var(--mono); font-size: 12.5px;
background: var(--bg-panel-2); padding: 1px 5px; border-radius: 3px; }
/* ── Shell ─────────────────────────────────────────────────────────── */
.shell {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
grid-template-rows: var(--topbar-h) 1fr;
grid-template-areas:
"sidebar topbar"
"sidebar main";
height: 100vh;
}
.sidebar {
grid-area: sidebar;
background: var(--bg-panel);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
}
.sidebar .brand {
display: flex; align-items: center; gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
}
.sidebar .brand img { width: 40px; height: auto; }
.sidebar .brand strong { font-size: 16px; letter-spacing: 0.4px; }
.sidebar .brand .sub { color: var(--fg-dim); font-size: 11px; }
.sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; }
.sidebar nav .group {
padding: 10px 18px 6px;
font-size: 10.5px; color: var(--fg-dimmer); text-transform: uppercase;
letter-spacing: 1px;
}
.sidebar nav a {
display: flex; align-items: center; gap: 10px;
padding: 7px 18px; color: var(--fg); font-size: 13.5px;
border-left: 2px solid transparent;
}
.sidebar nav a:hover { background: var(--bg-panel-2); text-decoration: none; }
.sidebar nav a.active {
background: var(--bg-panel-2);
border-left-color: var(--accent);
color: var(--accent);
}
.sidebar nav a .count {
margin-left: auto;
background: var(--bg-elev); color: var(--fg-dim);
padding: 1px 7px; font-size: 11px; border-radius: 10px;
font-variant-numeric: tabular-nums;
}
.sidebar nav a.active .count { background: var(--accent); color: #002923; }
.sidebar .footer {
padding: 10px 18px; border-top: 1px solid var(--border);
color: var(--fg-dimmer); font-size: 11px;
}
.sidebar .footer code { background: transparent; color: var(--fg-dim); padding: 0; }
/* ── Top bar ───────────────────────────────────────────────────────── */
.topbar {
grid-area: topbar;
display: flex; align-items: center;
padding: 0 20px; gap: 18px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
}
.topbar h1 {
margin: 0; font-size: 15px; font-weight: 600;
color: var(--fg); letter-spacing: 0.2px;
}
.topbar .tabs { display: flex; gap: 4px; margin-left: 24px; }
.topbar .tabs button {
background: transparent; border: 0;
color: var(--fg-dim); font: inherit;
padding: 10px 14px; cursor: pointer;
border-bottom: 2px solid transparent;
}
.topbar .tabs button:hover { color: var(--fg); }
.topbar .tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
.topbar .spacer { flex: 1; }
.topbar .chip {
background: var(--bg-panel-2); border: 1px solid var(--border);
color: var(--fg-dim); font-size: 12px;
padding: 4px 10px; border-radius: 12px;
}
.topbar .chip strong { color: var(--fg); font-weight: 600; }
/* ── Main content ─────────────────────────────────────────────────── */
.main {
grid-area: main;
overflow: auto;
padding: 22px 26px 40px;
}
.grid { display: grid; gap: 20px; }
.grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
.grid.cols-2 { grid-template-columns: repeat(2, 1fr); }
@media (max-width: 1024px) {
.grid.cols-3, .grid.cols-2 { grid-template-columns: 1fr; }
}
/* ── Cards / panels ───────────────────────────────────────────────── */
.card {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.card > header {
padding: 12px 16px;
background: var(--bg-panel-2);
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 10px;
}
.card > header h2 { margin: 0; font-size: 13.5px; font-weight: 600; color: var(--fg); }
.card > header .sub { color: var(--fg-dim); font-size: 12px; margin-left: auto; }
.card .body { padding: 16px; }
.stat {
padding: 16px;
}
.stat .label { color: var(--fg-dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.8px; }
.stat .value { font-size: 28px; font-weight: 600; line-height: 1.1; margin-top: 4px; color: var(--fg); }
.stat .trend { font-size: 12px; color: var(--fg-dim); margin-top: 4px; }
/* ── Tables ───────────────────────────────────────────────────────── */
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 9px 16px; border-bottom: 1px solid var(--border-soft); }
th {
color: var(--fg-dim); font-weight: 500; font-size: 11px;
text-transform: uppercase; letter-spacing: 1px;
background: var(--bg-panel-2);
}
tr:hover td { background: var(--bg-panel-2); }
td.mono { font-family: var(--mono); font-size: 12.5px; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
/* ── Tags / pills ─────────────────────────────────────────────────── */
.tag {
display: inline-block;
padding: 2px 8px; border-radius: 10px;
font-size: 11px; font-weight: 600;
background: #1b3148; color: #a2c5e8;
}
.tag.ok { background: #103428; color: var(--ok); }
.tag.warn { background: #3a2a10; color: var(--warn); }
.tag.err { background: #3a1515; color: var(--err); }
.tag.accent { background: #072f29; color: var(--accent); }
.tag.arch { text-transform: uppercase; }
/* ── Forms ────────────────────────────────────────────────────────── */
button, .btn {
background: var(--accent); color: #002923;
border: 0; border-radius: var(--radius);
padding: 7px 14px; font: inherit; font-weight: 600;
cursor: pointer;
}
button:hover, .btn:hover { background: var(--accent-dim); color: #fff; }
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 #4a1f1f; }
button.danger:hover { background: #2a0b0b; color: var(--err); }
label.field {
display: grid; gap: 4px; margin-bottom: 14px;
}
label.field .name { color: var(--fg-dim); font-size: 12px; }
label.field .hint { color: var(--fg-dimmer); font-size: 11px; }
label.field input[type="text"],
label.field input[type="number"],
label.field select,
label.field textarea {
width: 100%; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 7px 10px; font: inherit;
}
label.field input:focus, label.field select:focus, label.field textarea:focus {
outline: none; border-color: var(--accent);
}
label.check {
display: flex; gap: 10px; align-items: center;
padding: 8px 10px; margin-bottom: 6px;
border: 1px solid var(--border-soft); border-radius: var(--radius);
}
label.check input { accent-color: var(--accent); }
/* ── Drop zone ────────────────────────────────────────────────────── */
.drop {
border: 2px dashed var(--border);
border-radius: var(--radius-lg);
padding: 32px; text-align: center;
color: var(--fg-dim); cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.drop.hover, .drop:hover {
border-color: var(--accent); color: var(--fg);
background: var(--bg-panel-2);
}
.drop strong { color: var(--accent); }
.progress { height: 6px; background: var(--bg-panel-2); border-radius: 3px; overflow: hidden; margin-top: 12px; display: none; }
.progress.active { display: block; }
.progress .bar { height: 100%; width: 0%; background: var(--accent); transition: width .25s; }
/* ── Gate queue "horse race" visual ───────────────────────────────── */
.gate-track {
display: grid; gap: 6px;
padding: 10px 0;
}
.gate-row {
display: grid; grid-template-columns: 32px 1fr auto auto; align-items: center;
gap: 14px;
padding: 8px 14px;
background: var(--bg-panel-2); border-radius: var(--radius);
border-left: 3px solid var(--accent);
}
.gate-row.assigned { border-left-color: var(--ok); }
.gate-row .pos { font-family: var(--mono); font-size: 15px; color: var(--accent); font-weight: 600; }
.gate-row.assigned .pos { color: var(--ok); }
.gate-row .mac { font-family: var(--mono); font-size: 13px; }
.gate-row .meta { color: var(--fg-dim); font-size: 12px; }
.empty { color: var(--fg-dim); padding: 30px; text-align: center; }
.msg { color: var(--fg-dim); font-size: 12.5px; margin-top: 8px; }
.msg.err { color: var(--err); }
.msg.ok { color: var(--ok); }
/* ── Top bar readiness chip ──────────────────────────────────────── */
.chip.ready { background: #103428; color: var(--ok); border-color: #1a4f3c; }
.chip.notready { background: #3a1515; color: var(--err); border-color: #5a1f1f; }
.chip.warming { background: #3a2a10; color: var(--warn); border-color: #4a3a18; }
/* ── Dashboard stat strip ────────────────────────────────────────── */
.statstrip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
@media (max-width: 1100px) { .statstrip { grid-template-columns: repeat(2, 1fr); } }
.kv { display: grid; grid-template-columns: 160px 1fr; gap: 6px 14px;
padding: 4px 0; font-size: 13px; }
.kv .k { color: var(--fg-dim); }
.kv .v { font-family: var(--mono); color: var(--fg); word-break: break-all; }
.kv .v.warn { color: var(--warn); }
.kv .v.err { color: var(--err); }
.kv .v.ok { color: var(--ok); }
/* ── Image rows: amber tint on un-bootable images (Bootimus pattern) ── */
tr.unbootable td { background: rgba(255, 179, 71, 0.07) !important; }
tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.row-warn { color: var(--warn); font-size: 11.5px; margin-top: 2px; }
/* ── Table source badge ──────────────────────────────────────────── */
.src-badge { font-family: var(--mono); font-size: 11px; padding: 1px 6px;
border-radius: 4px; background: var(--bg-elev); color: var(--fg-dim); }
.src-badge.nfs { background: #122a3a; color: #7cd3ff; }
/* ── NFS modal-ish add form ──────────────────────────────────────── */
.nfs-row { display: grid; grid-template-columns: 32px 1fr auto auto auto; align-items: center;
gap: 14px; padding: 10px 14px; background: var(--bg-panel-2);
border-left: 3px solid var(--accent); border-radius: var(--radius); }
.nfs-row.down { border-left-color: var(--err); }
.nfs-row .id { font-family: var(--mono); font-size: 12.5px; color: var(--fg); }
.nfs-row .meta { color: var(--fg-dim); font-size: 12px; }
.nfs-row .err { color: var(--err); font-size: 11.5px; word-break: break-all; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.dot.ok { background: var(--ok); }
.dot.err { background: var(--err); }
.dot.warn { background: var(--warn); }
/* ── Inline form rows (used by Network + NFS add) ───────────────── */
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; }
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
/* ── Terminal pane ──────────────────────────────────────────────── */
.terminal {
display: flex; flex-direction: column;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: #06090e;
overflow: hidden;
height: calc(100vh - var(--topbar-h) - 90px);
min-height: 480px;
}
.terminal .pane {
flex: 1; overflow: auto;
padding: 10px 14px;
font-family: var(--mono); font-size: 12.5px; line-height: 1.5;
color: #cfd6e2;
white-space: pre-wrap; word-break: break-word;
}
.terminal .pane .lvl-error { color: var(--err); }
.terminal .pane .lvl-warn { color: var(--warn); }
.terminal .pane .lvl-info { color: #cfd6e2; }
.terminal .pane .lvl-debug { color: var(--fg-dim); }
.terminal .pane .lvl-trace { color: var(--fg-dimmer); }
.terminal .pane .ts { color: var(--fg-dimmer); }
.terminal .pane .tg { color: #7cd3ff; }
.terminal .pane .echo { color: var(--accent); }
.terminal .input-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 14px;
background: #0a0e15;
border-top: 1px solid var(--border);
}
.terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); }
.terminal .input-row input {
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: var(--bg-panel-2);
border-bottom: 1px solid var(--border);
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: var(--fg-dim); border: 1px solid var(--border);
font-weight: 500;
}
.terminal .toolbar button:hover { color: var(--fg); background: var(--bg-elev); }
/* ── About card ─────────────────────────────────────────────────── */
.about-hero { padding: 20px 24px; }
.about-hero h2 { font-size: 22px; margin: 0 0 8px; color: var(--fg); }
.about-hero .lead { color: var(--fg-dim); font-size: 14px; max-width: 60ch; }
.about-hero .who { margin-top: 18px; font-size: 13px; }
.about-hero .who span { color: var(--fg-dim); }
.about-hero .who strong { color: var(--accent); }
+696
View File
@@ -0,0 +1,696 @@
// PXEForge web UI — vanilla JS, no build step, no framework, no network
// dependencies. Uses fetch() + EventSource only.
//
// Tabs (Phase 4): Dashboard / Network / Forge Gate / Storage / Terminal /
// About. The shell swaps a single view into #view-root.
//
// Keep this readable — nobody wants to debug a clever vanilla-JS
// framework at 3 AM. Plain dumb DOM construction is the design.
(function () {
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const el = (tag, attrs = {}, children = []) => {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') e.className = v;
else if (k === 'html') e.innerHTML = v;
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v);
else if (v !== false && v != null) e.setAttribute(k, v);
}
for (const c of [].concat(children)) {
if (c == null || c === false) continue;
if (typeof c === 'string') e.appendChild(document.createTextNode(c));
else e.appendChild(c);
}
return e;
};
const fmtBytes = (n) => {
const u = ['B','KB','MB','GB','TB']; let i = 0;
while (n >= 1024 && i < u.length-1) { n /= 1024; i++; }
return n.toFixed(n >= 10 || i === 0 ? 0 : 1) + ' ' + u[i];
};
const fmtAgo = (ts) => {
const d = (ts instanceof Date) ? ts : new Date(ts);
if (isNaN(d.getTime())) return '-';
const ds = Math.floor((Date.now() - d.getTime()) / 1000);
if (ds < 0) return 'in ' + Math.abs(ds) + 's';
if (ds < 60) return ds + 's ago';
if (ds < 3600) return Math.floor(ds/60) + 'm ago';
if (ds < 86400) return Math.floor(ds/3600) + 'h ago';
return d.toLocaleString();
};
const fmtUptime = (secs) => {
secs = Math.max(0, Math.floor(secs || 0));
const h = Math.floor(secs/3600), m = Math.floor((secs%3600)/60), s = secs%60;
if (h) return h + 'h ' + m + 'm';
if (m) return m + 'm ' + s + 's';
return s + 's';
};
const familyLabel = (f) => ({
debian_ubuntu: 'Debian / Ubuntu', rhel_fedora: 'RHEL family',
open_suse: 'openSUSE', arch: 'Arch', alpine: 'Alpine',
windows_pe: 'Windows', unknown: 'Unknown',
}[f] || f);
const archLabel = (a) => {
if (!a) return '—';
if (typeof a === 'string') return a;
if (a && typeof a === 'object') {
if ('Unknown' in a) return 'unknown(0x' + a.Unknown.toString(16) + ')';
return JSON.stringify(a);
}
return String(a);
};
// ── network helpers ──────────────────────────────────────────────
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(url + ': ' + r.status);
return r.json();
}
async function putJSON(url, body) {
return fetch(url, {method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
}
async function postJSON(url, body) {
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
}
// Categorize an ISO row's "bootable now" status — drives the amber
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
function bootability(iso, settings) {
const fam = iso.introspection.family;
const isWin = fam === 'windows_pe';
if (isWin && !settings.windows_enabled) {
return { ok: false, reason: 'Windows boot disabled in Settings' };
}
if (!isWin && !iso.introspection.kernel_path && fam !== 'windows_pe') {
// Linux without a detected kernel falls through to sanboot which
// rarely works for >1 GiB ISOs.
if (iso.size_bytes > 1.5 * 1024 * 1024 * 1024) {
return { ok: false, reason: 'no kernel/initrd detected; ISO too large for sanboot fallback' };
}
return { ok: true, warn: 'no kernel detected — sanboot fallback may not work' };
}
return { ok: true };
}
// ── views ────────────────────────────────────────────────────────
const views = {
dashboard: async () => {
const status = await getJSON('/api/status');
const isos = await getJSON('/api/isos');
const clients = (await getJSON('/api/clients')).clients || [];
const gates = (await getJSON('/api/gate')).gates || [];
const ipxeOk = (status.ipxe_assets || []).length > 0;
const stats = el('div', {class: 'statstrip'}, [
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Server status'),
el('div', {class: 'value', style: 'font-size:18px;color:' + (ipxeOk ? 'var(--ok)' : 'var(--err)')},
ipxeOk ? 'Ready' : 'Not ready'),
el('div', {class: 'trend'},
ipxeOk ? 'Bootloaders bundled, accepting clients'
: 'No iPXE binaries bundled'),
])),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Imaging now'),
el('div', {class: 'value'}, String(status.imaging_count || 0)),
el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting at gate'),
])),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Images available'),
el('div', {class: 'value'}, String(isos.length)),
el('div', {class: 'trend'},
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
(status.nfs_active || 0) + ' NFS active'),
])),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Uptime'),
el('div', {class: 'value', style: 'font-size:22px'}, fmtUptime(status.uptime_secs)),
el('div', {class: 'trend'}, 'PXEForge ' + status.version),
])),
]);
// Recent connections — the operator's at-a-glance "who tried to
// boot" log. Use last_seen desc (already sorted by API).
const recent = clients.slice(0, 8);
const recentRows = recent.map(c => {
const g = gates.find(g => g.mac === c.mac);
let status = el('span', {class: 'tag ok'}, 'active');
if (g && g.assigned_target) status = el('span', {class:'tag ok'}, 'assigned: ' + g.assigned_target);
else if (g) status = el('span', {class:'tag accent'}, '#' + g.position + ' at gate');
return el('tr', {}, [
el('td', {class: 'mono'}, c.mac),
el('td', {}, c.last_ip ? String(c.last_ip) : '-'),
el('td', {}, el('span', {class:'tag arch'}, archLabel(c.arch))),
el('td', {}, fmtAgo(c.last_seen)),
el('td', {}, status),
]);
});
const recentBlock = el('div', {class: 'card'}, [
el('header', {}, [
el('h2', {}, 'Recent connections'),
el('span', {class: 'sub'}, clients.length + ' total'),
]),
recent.length
? el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th',{},'MAC'), el('th',{},'IP'), el('th',{},'Arch'),
el('th',{},'Last seen'), el('th',{},'Status'),
])),
el('tbody', {}, recentRows),
])
: el('div', {class: 'empty'}, 'No PXE clients have contacted this server yet.'),
]);
// At-a-glance pool of problem images — Bootimus-style early warning.
const settings = status.settings;
const problems = isos.map(i => ({i, b: bootability(i, settings)})).filter(x => !x.b.ok);
const problemsBlock = problems.length ? el('div', {class:'card'}, [
el('header', {}, [el('h2', {}, 'Images that won\'t boot with current settings')]),
el('div', {class:'body'},
problems.map(({i, b}) => el('div', {class:'row-warn'},
'⚠ ' + i.filename + ' — ' + b.reason)))
]) : null;
return el('div', {class:'grid'}, [stats, recentBlock, problemsBlock].filter(Boolean));
},
network: async () => {
const net = await getJSON('/api/network');
const dns = el('input', {type:'text', value: net.dns_server || '',
placeholder: 'Optional, e.g. 8.8.8.8 or 1.1.1.1'});
const msg = el('div', {class:'msg'});
const save = el('button', {onclick: async () => {
const r = await putJSON('/api/network', { dns_server: dns.value });
if (r.status === 204) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; }
else { msg.textContent = 'Save failed: ' + r.status; msg.className = 'msg err'; }
}}, 'Save');
const networkCard = el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Network')),
el('div', {class:'body'}, [
el('div', {class:'kv'}, [
el('div', {class:'k'}, 'Server IP'),
el('div', {class:'v'}, net.server_ip || '?'),
el('div', {class:'k'}, 'NIC name'),
el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'),
el('div', {class:'k'}, 'Subnet mask'),
el('div', {class:'v'}, net.subnet_mask || '?'),
el('div', {class:'k'}, 'Gateway'),
el('div', {class:'v'}, net.gateway || '?'),
el('div', {class:'k'}, 'Public base URL'),
el('div', {class:'v'}, net.public_base_url),
]),
el('p', {class:'msg'},
'Server IP, NIC, mask, and gateway are auto-detected at startup. ' +
'To change them, set PXEFORGE_PUBLIC_IP and restart — editing them ' +
'from a hot UI would silently break PXE for every client mid-boot.'),
el('label', {class:'field', style:'margin-top:18px'}, [
el('span', {class:'name'}, 'DNS server (optional, informational)'),
dns,
el('span', {class:'hint'},
'PXEForge does not run a DNS server itself; this field records ' +
'what your upstream DNS is so you don\'t have to dig it out at ' +
'3 AM during a deployment.'),
]),
save, msg,
]),
]);
return el('div', {class:'grid'}, [networkCard]);
},
gate: async () => {
const [{ gates = [] }, isos] = await Promise.all([
getJSON('/api/gate'), getJSON('/api/isos'),
]);
const targets = isos.flatMap(i => i.boot_entries.map(e => ({
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family)
})));
const pick = el('select', {},
[el('option', {value: ''}, '— choose an image —')]
.concat(targets.map(t => el('option', {value: t.id}, t.title)))
);
const launch = el('button', {}, 'Launch for all waiting');
const msg = el('div', {class:'msg'});
launch.onclick = async () => {
if (!pick.value) { msg.textContent = 'Pick an image first.'; msg.className='msg err'; return; }
const r = await postJSON('/api/gate/assign', { target: pick.value, gate_ids: [] });
if (!r.ok) { msg.textContent = 'Assign failed: ' + r.status; msg.className='msg err'; return; }
const j = await r.json();
if (!j.ok) { msg.textContent = 'Assign failed: ' + (j.error || 'unknown'); msg.className='msg err'; return; }
msg.textContent = 'Launched ' + j.assigned + ' client' + (j.assigned===1?'':'s') + ' → ' + j.target;
msg.className = 'msg ok';
render('gate');
};
const track = gates.length
? el('div', {class:'gate-track'},
gates.map(g => el('div', {class:'gate-row' + (g.assigned_target ? ' assigned' : '')}, [
el('div', {class:'pos'}, '#' + g.position),
el('div', {}, [
el('div', {class:'mac'}, g.mac),
el('div', {class:'meta'},
(g.ip ? String(g.ip) + ' · ' : '') + archLabel(g.arch) + ' · joined ' + fmtAgo(g.joined_at)),
]),
el('div', {}, g.assigned_target
? el('span', {class:'tag ok'}, '→ ' + g.assigned_target)
: el('span', {class:'tag accent'}, 'waiting')),
el('button', {class:'ghost', onclick: async () => {
await fetch('/api/gate/' + encodeURIComponent(g.id), {method:'DELETE'});
render('gate');
}}, 'Release'),
]))
)
: el('div', {class:'empty'},
'No clients at the gate. Boot a client and choose "Gated Deployment" in the PXE menu.');
return el('div', {class:'grid'}, [
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Launch an image across the gate')),
el('div', {class:'body'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Target image'),
pick,
el('span', {class:'hint'},
'Selecting "Launch" starts every waiting client on the chosen image simultaneously.'),
]),
launch, msg,
]),
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Gate positions'),
el('span', {class:'sub'}, gates.length + ' waiting'),
]),
el('div', {class:'body'}, track),
]),
]);
},
storage: async () => {
const [isos, settings, nfsRes] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'),
]);
const mounts = nfsRes.mounts || [];
// ── Upload card ──
const drop = el('div', {class:'drop', id:'drop'}, [
el('div', {}, ['Drop an ', el('strong', {}, '.iso'), ' here, or click to choose.']),
el('div', {style:'font-size:12px;margin-top:6px'},
'Linux + Windows installers auto-detected on upload. Streaming, no 502s on big files.'),
]);
const file = el('input', {type:'file', accept:'.iso,application/octet-stream',
style:'display:none', id:'file'});
const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'}));
const upMsg = el('div', {class:'msg', id:'upmsg'});
drop.onclick = () => file.click();
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
drop.addEventListener('dragleave', () => drop.classList.remove('hover'));
drop.addEventListener('drop', e => {
e.preventDefault(); drop.classList.remove('hover');
if (e.dataTransfer.files[0]) upload(e.dataTransfer.files[0]);
});
file.onchange = () => { if (file.files[0]) upload(file.files[0]); };
function upload(f) {
upMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…';
upMsg.className = 'msg';
prog.classList.add('active');
const fd = new FormData(); fd.append('file', f);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = e => {
if (e.lengthComputable) $('#bar').style.width = (e.loaded/e.total*100).toFixed(1) + '%';
};
xhr.onload = () => {
prog.classList.remove('active');
$('#bar').style.width = '0';
if (xhr.status >= 200 && xhr.status < 300) {
upMsg.textContent = 'Uploaded & analyzed.'; upMsg.className = 'msg ok';
render('storage');
} else {
upMsg.textContent = 'Upload failed: ' + xhr.status + ' ' + xhr.responseText;
upMsg.className = 'msg err';
}
};
xhr.onerror = () => { upMsg.textContent = 'Network error.'; upMsg.className = 'msg err'; };
xhr.open('POST', '/api/isos');
xhr.send(fd);
}
// ── ISO table (mixed local + NFS) ──
const rows = isos.map(i => {
const b = bootability(i, settings);
const isNfs = i.source && i.source.kind === 'nfs';
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
el('td', {}, [
el('div', {}, i.filename),
!b.ok ? el('div', {class:'row-warn'}, '⚠ ' + b.reason)
: (b.warn ? el('div', {class:'row-warn'}, '⚠ ' + b.warn) : null),
]),
el('td', {}, el('span', {class:'tag'}, familyLabel(i.introspection.family))),
el('td', {class:'num'}, fmtBytes(i.size_bytes)),
el('td', {},
el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')},
isNfs ? ('nfs:' + i.source.mount_id) : 'local')),
el('td', {}, fmtAgo(i.uploaded_at)),
el('td', {style:'text-align:right'},
isNfs
? el('span', {class:'tag', style:'opacity:.6'}, 'manage on NFS share')
: el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove this image?')) return;
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
render('storage');
}}, 'Remove')),
]);
return tr;
});
const isoTable = isos.length
? el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th',{},'Name'), el('th',{},'Type'),
el('th',{class:'num'},'Size'),
el('th',{},'Source'), el('th',{},'Uploaded'), el('th',{},''),
])),
el('tbody', {}, rows),
])
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or mount an NFS share.');
// ── NFS section ──
const nfsMsg = el('div', {class:'msg'});
const nfsServer = el('input', {type:'text', placeholder:'10.0.0.20'});
const nfsExport = el('input', {type:'text', placeholder:'/srv/isos'});
const nfsVer = el('select', {}, [
el('option', {value:'v41'}, 'NFSv4.1 (default)'),
el('option', {value:'v3'}, 'NFSv3'),
]);
const nfsRo = el('input', {type:'checkbox'}); nfsRo.checked = true;
const addNfs = el('button', {onclick: async () => {
if (!nfsServer.value || !nfsExport.value) {
nfsMsg.textContent = 'Server and export are required.'; nfsMsg.className='msg err'; return;
}
nfsMsg.textContent = 'Mounting…'; nfsMsg.className = 'msg';
const r = await postJSON('/api/nfs', {
server: nfsServer.value, export: nfsExport.value,
version: nfsVer.value, read_only: nfsRo.checked,
});
if (r.ok) {
nfsMsg.textContent = 'Mounted.'; nfsMsg.className = 'msg ok';
render('storage');
} else {
const t = await r.text();
nfsMsg.textContent = 'Mount failed: ' + t; nfsMsg.className = 'msg err';
}
}}, 'Mount share');
const nfsRows = mounts.length ? mounts.map(m => el('div', {class: 'nfs-row' + (m.mounted ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.mounted ? 'ok' : 'err')}),
el('div', {}, [
el('div', {class:'id'}, m.server + ':' + m.export),
el('div', {class:'meta'},
(m.version === 'v3' ? 'NFSv3' : 'NFSv4.1') + ' · ' +
(m.read_only ? 'read-only' : 'read-write') + ' · ' +
(m.mounted ? m.iso_count + ' isos' : 'not mounted')),
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
]),
el('button', {class:'ghost', onclick: async () => {
const r = await postJSON('/api/nfs/' + encodeURIComponent(m.id) + '/scan', {});
if (r.ok) render('storage');
}}, 'Re-scan'),
el('button', {class:'danger', onclick: async () => {
if (!confirm('Unmount ' + m.server + ':' + m.export + '?')) return;
await fetch('/api/nfs/' + encodeURIComponent(m.id), {method:'DELETE'});
render('storage');
}}, 'Unmount'),
el('span'),
])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')];
return el('div', {class:'grid'}, [
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Upload ISO')),
el('div', {class:'body'}, [drop, file, prog, upMsg]),
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'NFS shares'),
el('span', {class:'sub'}, mounts.length + ' configured'),
]),
el('div', {class:'body'}, [
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'NFS server'),
nfsServer,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Export path'),
nfsExport,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Version'),
nfsVer,
]),
el('label', {class:'check', style:'margin-top:18px'}, [
nfsRo, el('span', {}, 'Read-only'),
]),
]),
addNfs, nfsMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
el('p', {class:'msg', style:'margin-top:14px'},
'Mounting NFS inside a container requires CAP_SYS_ADMIN and the ' +
'mount.nfs binary (bundled in the default Docker image). On ' +
'OpenShift, your SCC must allow CAP_SYS_ADMIN or you can run ' +
'NFS mounts as a CSI driver outside the pod.'),
]),
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Available images'),
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
]),
isoTable,
]),
]);
},
terminal: async () => {
// Two-pane layout: live log on top (auto-scrolling), command line
// on bottom. Mirrors the Minecraft-server console feel from the
// brief — output and input share one continuous timeline.
const pane = el('div', {class:'pane'});
const input = el('input', {type:'text', placeholder:'type a command, or "help"', spellcheck:'false', autocapitalize:'off', autocomplete:'off'});
const auto = el('input', {type:'checkbox'}); auto.checked = true;
const clearBtn = el('button', {onclick: () => { pane.innerHTML = ''; }}, 'Clear pane');
const tailBtn = el('button', {onclick: () => { auto.checked = !auto.checked; }}, 'Auto-scroll');
const term = el('div', {class:'terminal'}, [
el('div', {class:'toolbar'}, [
el('span', {}, 'Live log + operator console'),
el('div', {class:'right'}, [
el('label', {class:'check', style:'border:0;padding:0;margin:0;background:transparent'},
[auto, el('span', {style:'color:var(--fg-dim);font-size:11px'}, 'Auto-scroll')]),
clearBtn,
]),
]),
pane,
el('div', {class:'input-row'}, [
el('span', {class:'prompt'}, '>'),
input,
]),
]);
function append(line, kind) {
const lvl = (line.level || 'info').toLowerCase();
const ts = (line.timestamp || new Date().toISOString()).replace(/\.\d+/, '').replace('T', ' ').replace('Z', '');
const span = el('span', {class: 'lvl-' + lvl}, [
el('span', {class:'ts'}, ts + ' '),
el('span', {class:'tg'}, '[' + (line.target || 'pxeforge') + '] '),
line.message,
'\n',
]);
if (kind === 'echo') {
span.firstChild.nextSibling.textContent = '';
span.firstChild.textContent = '';
span.classList.add('echo');
}
pane.appendChild(span);
if (auto.checked) pane.scrollTop = pane.scrollHeight;
}
// Initial fetch — show recent buffer in case SSE is slow to open.
try {
const r = await getJSON('/api/log/recent');
for (const l of (r.lines || [])) append(l);
} catch (e) {
append({timestamp: new Date().toISOString(), level:'warn', target:'pxeforge::ui',
message: 'failed to load recent logs: ' + e.message});
}
// Live SSE stream. EventSource auto-reconnects on disconnect.
const es = new EventSource('/api/log/stream');
es.onmessage = (ev) => {
try { append(JSON.parse(ev.data)); }
catch { append({timestamp: new Date().toISOString(), level:'debug', target:'pxeforge::ui', message: ev.data}); }
};
es.addEventListener('lagged', (ev) => {
const j = JSON.parse(ev.data || '{}');
append({timestamp: new Date().toISOString(), level:'warn',
target:'pxeforge::ui',
message: 'log stream lagged: ' + (j.skipped || '?') + ' lines skipped'});
});
es.onerror = () => {
// EventSource quietly retries; surface a hint without spamming.
// We only append once, on transition from connected → erroring.
if (!term._notedErr) {
term._notedErr = true;
append({timestamp: new Date().toISOString(), level:'warn', target:'pxeforge::ui',
message: 'log stream connection lost — auto-reconnecting'});
setTimeout(() => { term._notedErr = false; }, 5000);
}
};
// Close the SSE when the view changes — avoids piling up streams.
term._cleanup = () => es.close();
// Command history (in-memory only, ↑/↓ to recall).
const history = [];
let hi = -1;
input.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') {
const cmd = input.value;
if (!cmd.trim()) return;
input.value = '';
history.unshift(cmd); if (history.length > 100) history.pop();
hi = -1;
// The echo also comes back from the server in the live tail,
// so we don't append it locally — keeps the order consistent.
try {
const r = await postJSON('/api/terminal', {command: cmd});
const j = await r.json();
const out = j.output || '';
if (out === '\f') { pane.innerHTML = ''; return; }
// Output also gets pushed onto the LogBus by the server, but
// include it locally so even if the SSE stream dropped we
// see it. Tagged "echo" so it stands out from regular logs.
append({timestamp: new Date().toISOString(), level: j.ok ? 'info' : 'warn',
target: 'terminal-output', message: out});
} catch (err) {
append({timestamp: new Date().toISOString(), level:'error',
target:'pxeforge::ui', message: 'command failed: ' + err.message});
}
} else if (e.key === 'ArrowUp') {
if (history.length === 0) return;
hi = Math.min(hi + 1, history.length - 1);
input.value = history[hi];
e.preventDefault();
} else if (e.key === 'ArrowDown') {
hi = Math.max(hi - 1, -1);
input.value = hi < 0 ? '' : history[hi];
e.preventDefault();
}
});
// Welcome banner.
append({timestamp: new Date().toISOString(), level:'info', target:'pxeforge::terminal',
message: 'Connected. Type "help" for available commands.'});
// Focus the input on next tick (after view swap completes).
setTimeout(() => input.focus(), 50);
return term;
},
about: async () => {
const status = await getJSON('/api/status');
return el('div', {class:'card'}, [
el('div', {class:'about-hero'}, [
el('h2', {}, 'PXEForge'),
el('p', {class:'lead'},
'Air-gapped network PXE boot, container-native, that anyone can run. ' +
'No CDN calls, no telemetry, no surprise external dependencies — ship ' +
'the image once, run it forever.'),
el('div', {class:'who'}, [
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
el('span', {}, 'Base URL: '), el('strong', {}, status.public_base_url),
]),
el('p', {class:'msg', style:'margin-top:18px'},
'iPXE is an internal implementation detail. Everything the firmware ' +
'executes is generated from the settings on these tabs — there is no ' +
'hand-written .ipxe path anywhere in this product.'),
el('p', {class:'msg'},
'Vision: a deployment-grade tool that works on first try in the most ' +
'awkward environments — air-gapped labs, customer sites without ' +
'internet, OpenShift clusters with strict SCCs — without ever asking ' +
'an operator to install drivers signed with test certificates or to ' +
'flip "testsigning" on a target machine.'),
]),
]);
},
};
// ── shell ────────────────────────────────────────────────────────
const viewTitles = {
dashboard: 'Dashboard',
network: 'Network',
gate: 'Forge Gate',
storage: 'Storage',
terminal: 'Terminal',
about: 'About',
};
let currentBody = null;
async function render(view) {
view = view || 'dashboard';
$$('.sidebar nav a').forEach(a => a.classList.toggle('active', a.dataset.view === view));
$('[data-bind=view_title]').textContent = viewTitles[view] || view;
const root = $('#view-root');
// Clean up any per-view resources (e.g. terminal SSE) before swap.
if (currentBody && typeof currentBody._cleanup === 'function') {
try { currentBody._cleanup(); } catch (e) { /* ignore */ }
}
root.innerHTML = '';
root.appendChild(el('div', {class:'msg'}, 'Loading…'));
try {
const body = await views[view]();
root.innerHTML = '';
root.appendChild(body);
currentBody = body;
} catch (e) {
root.innerHTML = '';
root.appendChild(el('div', {class:'msg err'}, 'Error: ' + e.message));
}
}
async function refreshChips() {
try {
const s = await getJSON('/api/status');
const r = await fetch('/readyz');
$$('[data-bind=version]').forEach(n => n.textContent = s.version);
$$('[data-bind=iso_count],[data-bind=iso_count2]').forEach(n => n.textContent = String(s.iso_count));
$$('[data-bind=client_count],[data-bind=client_count2]').forEach(n => n.textContent = String(s.client_count));
$$('[data-bind=gate_count],[data-bind=gate_count2]').forEach(n => n.textContent = String(s.gate_count));
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'; }
}
} catch {
const chip = $('[data-bind=ready_chip]');
if (chip) { chip.textContent = '● unreachable'; chip.className = 'chip notready'; }
}
}
document.addEventListener('click', (e) => {
const a = e.target.closest('.sidebar nav a[data-view]');
if (a) { e.preventDefault(); render(a.dataset.view); }
});
render('dashboard');
refreshChips();
setInterval(refreshChips, 3000);
})();
+54
View File
@@ -0,0 +1,54 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PXEForge</title>
<link rel="stylesheet" href="/assets/app.css" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<img src="/assets/logo.svg" alt="" />
<div>
<strong>PXEForge</strong>
<div class="sub">v<span data-bind="version">0.1.0</span></div>
</div>
</div>
<nav>
<a data-view="dashboard" class="active">Dashboard</a>
<a data-view="network">Network</a>
<a data-view="gate">
Forge Gate
<span class="count" data-bind="gate_count">0</span>
</a>
<a data-view="storage">
Storage
<span class="count" data-bind="iso_count">0</span>
</a>
<a data-view="terminal">Terminal</a>
<a data-view="about">About</a>
</nav>
<div class="footer">
Advertised to clients<br/>
<code>{{BASE_URL}}</code>
</div>
</aside>
<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>&nbsp;images</span>
<span class="chip"><strong data-bind="client_count2">0</strong>&nbsp;clients</span>
<span class="chip"><strong data-bind="gate_count2">0</strong>&nbsp;at gate</span>
</header>
<main class="main" id="view-root"></main>
</div>
<script src="/assets/app.js"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
//! Offline-only web UI. Everything the browser needs (HTML, CSS, JS, SVG
//! logo) is embedded in the compiled binary via `include_str!` /
//! `include_bytes!`. No CDN, no external fonts, no remote images —
//! PXEForge renders identically on an air-gapped network.
//!
//! Layout follows the Netbox Labs pattern: dark left sidebar with primary
//! nav, top bar with secondary tabs, card-dense content panels.
#![forbid(unsafe_code)]
/// Render the top-level page. `base_url` is interpolated into the footer
/// so operators can see at a glance what URL clients are PXE-booting from.
#[must_use]
pub fn index_html(base_url: &str) -> String {
INDEX_HTML.replace("{{BASE_URL}}", base_url)
}
#[must_use]
pub fn app_js() -> &'static str { APP_JS }
#[must_use]
pub fn app_css() -> &'static str { APP_CSS }
#[must_use]
pub fn logo_svg() -> &'static str { LOGO_SVG }
const INDEX_HTML: &str = include_str!("index.html");
const APP_CSS: &str = include_str!("app.css");
const APP_JS: &str = include_str!("app.js");
const LOGO_SVG: &str = include_str!("logo.svg");
+14
View File
@@ -0,0 +1,14 @@
<svg viewBox="0 0 96 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>PXEForge</title>
<!-- Anvil body -->
<path d="M6 22 H82 L70 36 H46 V44 H58 V50 H30 V44 H42 V36 H22 Z" fill="#f0823a" stroke="#3a1f08" stroke-width="1.2"/>
<!-- Horn highlight -->
<path d="M6 22 L20 22 L14 28 L6 28 Z" fill="#ffb066"/>
<!-- Stand + base -->
<rect x="34" y="50" width="20" height="4" fill="#3a1f08"/>
<rect x="22" y="54" width="44" height="6" fill="#1c1107"/>
<!-- Subtle spark -->
<circle cx="86" cy="16" r="1.5" fill="#ffd79a"/>
<circle cx="90" cy="22" r="1" fill="#ffd79a"/>
<circle cx="82" cy="12" r="1" fill="#ffd79a"/>
</svg>

After

Width:  |  Height:  |  Size: 650 B