Name update
This commit is contained in:
@@ -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]
|
||||
@@ -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); }
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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> images</span>
|
||||
<span class="chip"><strong data-bind="client_count2">0</strong> clients</span>
|
||||
<span class="chip"><strong data-bind="gate_count2">0</strong> at gate</span>
|
||||
</header>
|
||||
|
||||
<main class="main" id="view-root"></main>
|
||||
</div>
|
||||
|
||||
<script src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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");
|
||||
@@ -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 |
Reference in New Issue
Block a user