v0.2.0 — pre-beta: per-MAC bindings, /metrics, themes, animated forge

This is the bulk pre-beta cleanup pass. Bumps the workspace to 0.2.0.
Test count is 56 -> 66 (+10), clippy is fully clean across the
workspace (was several dozen warnings).

## New features

**Per-MAC host bindings** (Tinkerbell smee pattern). New
`HostBindings` registry maps a MAC -> preferred boot target, persisted
to <work_dir>/hosts.json. The DHCP reply now embeds `?mac=${mac}` in
the boot.ipxe URL; iPXE substitutes the literal MAC client-side, so
the HTTP layer can short-circuit straight to the bound target instead
of rendering the menu. Reserved menu shortcuts (`_local`, `_gate`,
`_tools_menu`) are valid targets too. New /api/hosts CRUD + a Hosts
tab in the sidebar.

**Prometheus `/metrics`** endpoint. Tiny lock-free implementation —
just AtomicU64s and a Display impl, no `prometheus` / `metrics-rs`
dep. Counters: DHCP replies (per arch label), DHCP declined, TFTP
transfers (per status), TFTP bytes, HTTP requests (per route).
Gauges: ISO count, client count, gate count, gate-imaging, NFS active
mounts, uptime, build info. Plain text exposition format,
text/plain;version=0.0.4 content-type, no auth (all metric values are
non-sensitive counts).

**Light + dark themes**. CSS tokens on `:root` and
`:root[data-theme=light]`, swap by toggle button (top-right) or `T`
hotkey. Persisted in localStorage; pre-paint inline script avoids
dark<->light flash. Light palette designed against the Netbox Labs
reference screenshot — near-white surfaces, soft grey dividers,
accent unchanged for brand consistency. Terminal pane stays dark in
both themes (it's a console, that's the right read).

**Animated SVG logo + forge widget**. New `logo.svg` is a refined
silver/grey anvil. New `anvil-forge.svg` adds rising sparks and a
pulsing underglow via SMIL — pure SVG, no GIF, no JS animation loop.
Used:
  - in the **forge progress** widget on Dashboard + Forge Gate, paired
    with a `linear-gradient(warn -> accent)` bar with a moving sheen;
    goes idle (greyscale, no sheen) at zero imaging load
  - in the page-load `<div class=loader>` that replaces the old
    "Loading..." text

## Code cleanup pass

`cargo clippy --workspace --all-targets` is now warning-free. Spot
fixes across the tree:
  - `format!()`-into-`String` -> `std::fmt::Write::write!`
  - manual reverse comparators -> `Reverse`
  - `map_or(false, ...)` -> `is_some_and`
  - redundant closures -> method references
  - `r#"..."#` raw strings without `"` -> `r"..."`
  - `std::io::Error::new(Other, ...)` -> `Error::other`
  - `as i32` on `c.id()` -> `cast_signed()`
  - merged identical match arms

## Windows workflow validation

New integration test synthesizes an ISO9660 with the SOURCES\\BOOT.WIM
sentinel, uploads it, asserts:
  1. introspection labels it `windows_pe` with has_boot_wim=true,
  2. the boot entry is `BootKind::Wimboot` with all five canonical
     files (bootmgr, bootmgr.efi, bcd, boot.sdi, boot.wim),
  3. the rendered iPXE script chains wimboot with `initrd --name`
     entries for each file, and
  4. NO trust-store strings appear in the rendered output: bcdedit,
     testsigning, certutil, httpdisk, and test-signed are all
     explicitly forbidden as a hard guarantee.

WinPE bootstrap (startnet.cmd) picks up the Bootimus v0.1.58 lessons:
explicit `net start Workstation` before `net use` to avoid the SMB
client lazy-init race, and surfaces errors instead of blind retries.

## Docs

architecture.md gains a "Phase 5" section explaining the host-bindings
+ metrics + theming + Windows-test work, plus a refreshed "deferred
to Phase 6" list (real-hardware integration, autounattend library,
distro profile manifest, WoL trigger, syslog receiver, IPv6).
README updates the status line, the "what it does" list, and adds
the new Hosts/Terminal tab names.
This commit is contained in:
Miles Ward
2026-04-30 02:28:10 -04:00
parent a9c4f408a9
commit 49d0b00a8a
31 changed files with 1651 additions and 209 deletions
+81
View File
@@ -0,0 +1,81 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" fill="none">
<title>PXEForge — forging</title>
<defs>
<linearGradient id="afBody" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#aab3c2"/>
<stop offset="55%" stop-color="#7d8696"/>
<stop offset="100%" stop-color="#525a6b"/>
</linearGradient>
<linearGradient id="afFace" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#cdd5e1"/>
<stop offset="100%" stop-color="#9aa3b3"/>
</linearGradient>
<linearGradient id="afBase" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3b4252"/>
<stop offset="100%" stop-color="#252a36"/>
</linearGradient>
<radialGradient id="afSpark" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#fff5b8" stop-opacity="1"/>
<stop offset="40%" stop-color="#ff9a3a" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#ff5a18" stop-opacity="0"/>
</radialGradient>
<radialGradient id="afEmber" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#ffd47a" stop-opacity="1"/>
<stop offset="100%" stop-color="#ff7322" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- Anvil shifted down so sparks have room to rise above -->
<g transform="translate(0,40)">
<!-- Horn + face -->
<path d="M14 36 L72 30 L162 30 L162 46 L72 46 Z"
fill="url(#afFace)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
<!-- Body / waist -->
<path d="M70 46 L160 46 L142 70 L88 70 Z"
fill="url(#afBody)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
<!-- Pillar -->
<rect x="92" y="70" width="46" height="26" fill="url(#afBody)"
stroke="#1d2330" stroke-width="2.4"/>
<!-- Base -->
<path d="M62 96 L168 96 L160 110 L70 110 Z"
fill="url(#afBase)" stroke="#0d1018" stroke-width="2.4" stroke-linejoin="round"/>
<line x1="74" y1="34" x2="158" y2="34" stroke="#e6ecf5" stroke-width="1.2" opacity="0.7"/>
<!-- Soft underglow on top face where the sparks land -->
<ellipse cx="115" cy="33" rx="42" ry="6" fill="url(#afEmber)" opacity="0.55">
<animate attributeName="opacity" values="0.35;0.7;0.35"
dur="1.6s" repeatCount="indefinite"/>
</ellipse>
</g>
<!-- Sparks. SMIL animations only — no JS, no CSS needed. Each spark
rises, fades, restarts at a staggered delay for an organic feel. -->
<g class="sparks">
<circle cx="116" cy="62" r="3.4" fill="url(#afSpark)" opacity="0">
<animate attributeName="cy" from="62" to="14" dur="1.4s" repeatCount="indefinite"/>
<animate attributeName="cx" values="116;112;120;116" dur="1.4s" repeatCount="indefinite"/>
<animate attributeName="r" values="2;3.6;1.4" dur="1.4s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;0" dur="1.4s" repeatCount="indefinite"/>
</circle>
<circle cx="105" cy="62" r="2.4" fill="url(#afSpark)" opacity="0">
<animate attributeName="cy" from="62" to="22" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
<animate attributeName="cx" values="105;101;108;104" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
<animate attributeName="r" values="1.6;2.8;1" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;0" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
</circle>
<circle cx="125" cy="62" r="2.8" fill="url(#afSpark)" opacity="0">
<animate attributeName="cy" from="62" to="6" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
<animate attributeName="cx" values="125;130;121;127" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
<animate attributeName="r" values="1.8;3.2;1.2" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;0" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
</circle>
<circle cx="113" cy="62" r="2" fill="url(#afEmber)" opacity="0">
<animate attributeName="cy" from="62" to="32" dur="1.2s" begin="0.9s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;0.9;0" dur="1.2s" begin="0.9s" repeatCount="indefinite"/>
</circle>
<circle cx="132" cy="62" r="2.2" fill="url(#afSpark)" opacity="0">
<animate attributeName="cy" from="62" to="20" dur="1.5s" begin="1.2s" repeatCount="indefinite"/>
<animate attributeName="cx" values="132;138;128" dur="1.5s" begin="1.2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;0" dur="1.5s" begin="1.2s" repeatCount="indefinite"/>
</circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

+194 -100
View File
@@ -1,35 +1,66 @@
/* 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. */
/* PXEForge web UI — Netbox-style minimal layout, fully offline.
*
* Theme tokens live on `:root` (dark default) and `:root[data-theme=light]`.
* Both palettes share variable *names*, so component CSS uses
* `var(--bg)` regardless and the toggle in the topbar just flips the
* data-attribute. No JS-side recolouring, no React re-renders, no FOUC
* (the inline script in index.html paints the right theme before main
* CSS lands). */
: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;
/* Dark palette (default). */
--bg: #0b1018;
--bg-panel: #121826;
--bg-panel-2: #1a2334;
--bg-elev: #223047;
--fg: #e4e8ef;
--fg-dim: #8a94a7;
--fg-dimmer: #5a6379;
--accent: #00d4b4; /* Netbox-ish teal */
--accent-dim: #07a38c;
--warn: #ffb347;
--err: #ef6e6e;
--ok: #4ade80;
--border: #223047;
--border-soft: #172033;
--radius: 6px;
--terminal-bg: #06090e;
--shadow-card: 0 1px 0 rgba(255,255,255,0.02), 0 8px 24px rgba(0,0,0,0.25);
--radius: 6px;
--radius-lg: 10px;
--sidebar-w: 240px;
--topbar-h: 54px;
--topbar-h: 56px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
}
:root[data-theme="light"] {
/* Light palette — high-contrast neutral, accent unchanged for brand
consistency. Designed against Netbox Labs's reference screenshot:
near-white surfaces, soft grey dividers, dark text. */
--bg: #f6f8fb;
--bg-panel: #ffffff;
--bg-panel-2: #f0f3f8;
--bg-elev: #e6ebf2;
--fg: #1c2330;
--fg-dim: #5a6377;
--fg-dimmer: #95a0b3;
--accent: #00b89c;
--accent-dim: #008b73;
--warn: #b67016;
--err: #c63a3a;
--ok: #1f9b54;
--border: #d8dde6;
--border-soft: #e7eaf0;
--terminal-bg: #0d1219; /* terminal stays dark even in light mode */
--shadow-card: 0 1px 0 rgba(0,0,0,0.02), 0 6px 18px rgba(20,28,52,0.06);
}
* { 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);
transition: background 0.16s ease, color 0.16s ease;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
@@ -62,15 +93,11 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
.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;
padding: 8px 18px; color: var(--fg); font-size: 13.5px;
border-left: 2px solid transparent;
cursor: pointer;
}
.sidebar nav a:hover { background: var(--bg-panel-2); text-decoration: none; }
.sidebar nav a.active {
@@ -96,7 +123,7 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
.topbar {
grid-area: topbar;
display: flex; align-items: center;
padding: 0 20px; gap: 18px;
padding: 0 20px; gap: 14px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
}
@@ -104,23 +131,31 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
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;
white-space: nowrap;
}
.topbar .chip strong { color: var(--fg); font-weight: 600; }
/* Theme toggle button. Two glyphs stacked; CSS swaps which is visible
based on the active theme. Keeps the layout stable when toggling. */
.theme-toggle {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 32px;
background: transparent; color: var(--fg);
border: 1px solid var(--border); border-radius: 8px;
cursor: pointer; padding: 0;
transition: background 0.15s ease, border-color 0.15s ease;
}
.theme-toggle:hover { background: var(--bg-panel-2); border-color: var(--accent); }
.theme-toggle .t-sun { display: none; }
.theme-toggle .t-moon { display: inline; }
:root[data-theme="light"] .theme-toggle .t-sun { display: inline; }
:root[data-theme="light"] .theme-toggle .t-moon { display: none; }
/* ── Main content ─────────────────────────────────────────────────── */
.main {
@@ -142,6 +177,7 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-card);
}
.card > header {
padding: 12px 16px;
@@ -153,9 +189,7 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
.card > header .sub { color: var(--fg-dim); font-size: 12px; margin-left: auto; }
.card .body { padding: 16px; }
.stat {
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; }
@@ -179,12 +213,12 @@ td.num { text-align: right; font-variant-numeric: tabular-nums; }
display: inline-block;
padding: 2px 8px; border-radius: 10px;
font-size: 11px; font-weight: 600;
background: #1b3148; color: #a2c5e8;
background: var(--bg-elev); color: var(--fg-dim);
}
.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.ok { background: color-mix(in srgb, var(--ok) 22%, transparent); color: var(--ok); }
.tag.warn { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); }
.tag.err { background: color-mix(in srgb, var(--err) 22%, transparent); color: var(--err); }
.tag.accent { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent); }
.tag.arch { text-transform: uppercase; }
/* ── Forms ────────────────────────────────────────────────────────── */
@@ -194,12 +228,13 @@ button, .btn {
border: 0; border-radius: var(--radius);
padding: 7px 14px; font: inherit; font-weight: 600;
cursor: pointer;
transition: background 0.12s ease;
}
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); }
button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); }
button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); }
label.field {
display: grid; gap: 4px; margin-bottom: 14px;
@@ -238,12 +273,114 @@ label.check input { accent-color: var(--accent); }
background: var(--bg-panel-2);
}
.drop strong { color: var(--accent); }
/* Plain progress bar (used for ISO uploads). */
.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 ───────────────────────────────── */
/* ── Forge progress widget ─────────────────────────────────────────
Anvil-with-sparks animation paired with a horizontal progress bar.
Used on the Forge Gate tab to give a sense of the "in-flight"
imaging count without having to read a number. */
.forge-progress {
display: flex; align-items: center; gap: 16px;
padding: 16px;
}
.forge-progress .anvil {
width: 64px; height: 64px; flex: none;
background: url("/assets/anvil-forge.svg") no-repeat center / contain;
filter: drop-shadow(0 0 14px color-mix(in srgb, var(--warn) 40%, transparent));
}
.forge-progress .info { flex: 1; min-width: 0; }
.forge-progress .info .label {
font-size: 12.5px; color: var(--fg-dim); margin-bottom: 6px;
}
.forge-progress .bar-track {
height: 8px; background: var(--bg-elev); border-radius: 4px;
overflow: hidden; position: relative;
}
.forge-progress .bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--warn), var(--accent));
width: 0%;
transition: width 0.4s ease;
position: relative;
}
.forge-progress .bar-fill::after {
/* Subtle moving sheen so the bar feels alive even at 0% movement. */
content: ""; position: absolute; inset: 0;
background: linear-gradient(
90deg,
rgba(255,255,255,0) 0%,
rgba(255,255,255,0.18) 50%,
rgba(255,255,255,0) 100%);
animation: forge-sheen 1.6s linear infinite;
}
@keyframes forge-sheen {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
.forge-progress.idle .anvil { filter: none; opacity: 0.45; }
.forge-progress.idle .bar-fill::after { animation: none; }
/* ── Page-load anvil ──────────────────────────────────────────────── */
.loader {
display: flex; flex-direction: column; align-items: center; gap: 12px;
padding: 40px 20px;
color: var(--fg-dim);
}
.loader .anvil {
width: 110px; height: 110px;
background: url("/assets/anvil-forge.svg") no-repeat center / contain;
}
/* ── Top bar readiness chip ──────────────────────────────────────── */
.chip.ready { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, transparent); }
.chip.notready { background: color-mix(in srgb, var(--err) 18%, transparent); color: var(--err); border-color: color-mix(in srgb, var(--err) 35%, transparent); }
.chip.warming { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 35%, transparent); }
/* ── 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 ────────────────── */
tr.unbootable td { background: color-mix(in srgb, var(--warn) 7%, transparent) !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: color-mix(in srgb, #7cd3ff 18%, var(--bg-elev));
color: color-mix(in srgb, #7cd3ff 90%, var(--fg)); }
/* ── NFS rows ────────────────────────────────────────────────────── */
.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. */
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; }
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
/* ── Gate queue "horse race" visual ──────────────────────────────── */
.gate-track {
display: grid; gap: 6px;
padding: 10px 0;
@@ -266,59 +403,16 @@ label.check input { accent-color: var(--accent); }
.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;
background: var(--terminal-bg);
overflow: hidden;
height: calc(100vh - var(--topbar-h) - 90px);
min-height: 480px;
box-shadow: var(--shadow-card);
}
.terminal .pane {
flex: 1; overflow: auto;
@@ -330,37 +424,37 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.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 .lvl-debug { color: #8b94a8; }
.terminal .pane .lvl-trace { color: #5a6379; }
.terminal .pane .ts { color: #5a6379; }
.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);
border-top: 1px solid #1d2330;
}
.terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); }
.terminal .input-row input {
flex: 1; background: transparent; border: 0; color: var(--fg);
flex: 1; background: transparent; border: 0; color: #e4e8ef;
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);
background: #0a0e15;
border-bottom: 1px solid #1d2330;
font-size: 12px; color: #8a94a7;
}
.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);
background: transparent; color: #8a94a7; border: 1px solid #1d2330;
font-weight: 500;
}
.terminal .toolbar button:hover { color: var(--fg); background: var(--bg-elev); }
.terminal .toolbar button:hover { color: #e4e8ef; background: #1d2330; }
/* ── About card ─────────────────────────────────────────────────── */
.about-hero { padding: 20px 24px; }
+160 -6
View File
@@ -75,6 +75,26 @@
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
}
// Animated "anvil + progress bar" widget. Built once here and inlined
// wherever a card wants to convey "an image is being forged onto the
// bench right now." Used on the dashboard and on the Forge Gate.
function forgeProgressWidget(imaging, gateTotal) {
const total = Math.max(gateTotal, imaging, 1);
const pct = imaging > 0 ? Math.round((imaging / total) * 100) : 0;
const root = el('div', {class: 'forge-progress' + (imaging === 0 ? ' idle' : '')}, [
el('div', {class: 'anvil', 'aria-hidden': 'true'}),
el('div', {class: 'info'}, [
el('div', {class: 'label'},
imaging === 0
? 'No active imaging'
: (imaging + ' of ' + total + ' device' + (total === 1 ? '' : 's') + ' being forged')),
el('div', {class: 'bar-track'},
el('div', {class: 'bar-fill', style: 'width:' + pct + '%'})),
]),
]);
return root;
}
// 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) {
@@ -112,11 +132,14 @@
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', style: 'padding-bottom:0'}, [
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'),
]),
forgeProgressWidget(status.imaging_count || 0, status.gate_count || 0),
]),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Images available'),
el('div', {class: 'value'}, String(isos.length)),
@@ -268,7 +291,13 @@
: el('div', {class:'empty'},
'No clients at the gate. Boot a client and choose "Gated Deployment" in the PXE menu.');
const imaging = gates.filter(g => g.assigned_target).length;
return el('div', {class:'grid'}, [
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Forge')),
forgeProgressWidget(imaging, gates.length),
]),
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Launch an image across the gate')),
el('div', {class:'body'}, [
@@ -476,6 +505,99 @@
]);
},
hosts: async () => {
const [{ hosts = [] }, isos] = await Promise.all([
getJSON('/api/hosts'), getJSON('/api/isos'),
]);
const targets = isos.flatMap(i => i.boot_entries.map(e => ({
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family),
})));
// Reserved menu shortcuts that the operator might want to bind.
const reserved = [
{id: '_local', title: '↳ Boot from Local HDD (built-in)'},
{id: '_gate', title: '↳ Gated Deployment (built-in)'},
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
];
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff', spellcheck:'false'});
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
const targetSel = el('select', {},
[el('option', {value:''}, '— choose a target —')]
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
.concat(targets.map(t => el('option', {value: t.id}, t.title))));
const msg = el('div', {class:'msg'});
const upsertBtn = el('button', {onclick: async () => {
if (!macInput.value || !targetSel.value) {
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
}
const r = await postJSON('/api/hosts', {
mac: macInput.value, target: targetSel.value, label: labelInput.value,
});
if (r.ok) {
msg.textContent = 'Saved.'; msg.className = 'msg ok';
render('hosts');
} else {
const t = await r.text();
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
}
}}, 'Bind MAC to target');
const rows = hosts.map(h => el('tr', {}, [
el('td', {class:'mono'}, h.mac),
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
el('td', {class:'mono'}, h.target),
el('td', {}, fmtAgo(h.updated_at)),
el('td', {style:'text-align:right'},
el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove binding for ' + h.mac + '?')) return;
await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
render('hosts');
}}, 'Remove')),
]));
const table = hosts.length
? el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th',{},'MAC'), el('th',{},'Label'),
el('th',{},'Target'), el('th',{},'Updated'), el('th',{},''),
])),
el('tbody', {}, rows),
])
: el('div', {class:'empty'}, 'No host bindings yet. Pin a MAC to a boot target to skip the menu for that machine.');
return el('div', {class:'grid'}, [
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
el('div', {class:'body'}, [
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
el('label', {class:'field', style:'grid-column:1 / -1'}, [
el('span', {class:'name'}, 'Target'),
targetSel,
el('span', {class:'hint'},
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
]),
]),
upsertBtn, msg,
el('p', {class:'msg', style:'margin-top:14px'},
'When a client with a bound MAC requests boot.ipxe, PXEForge ' +
'short-circuits past the interactive menu and chains directly. ' +
'Inspired by Tinkerbell smee\'s MAC-prepended URL pattern.'),
]),
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Bound hosts'),
el('span', {class:'sub'}, hosts.length + ' binding' + (hosts.length === 1 ? '' : 's')),
]),
table,
]),
]);
},
terminal: async () => {
// Two-pane layout: live log on top (auto-scrolling), command line
// on bottom. Mirrors the Minecraft-server console feel from the
@@ -638,10 +760,35 @@
network: 'Network',
gate: 'Forge Gate',
storage: 'Storage',
hosts: 'Hosts',
terminal: 'Terminal',
about: 'About',
};
// Theme toggle. The data-attribute is set on <html> by the inline
// script in index.html before paint; we just flip it here and persist.
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('pxeforge-theme', theme); } catch {}
}
function currentTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
document.addEventListener('DOMContentLoaded', () => {
const btn = $('#theme-toggle');
if (btn) {
btn.addEventListener('click', () => {
applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
});
}
});
// Keyboard shortcut: T toggles theme (skip when typing in an input).
document.addEventListener('keydown', (e) => {
if (e.key !== 't' && e.key !== 'T') return;
if (/^(INPUT|TEXTAREA|SELECT)$/.test((e.target && e.target.tagName) || '')) return;
applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
});
let currentBody = null;
async function render(view) {
@@ -654,7 +801,13 @@
try { currentBody._cleanup(); } catch (e) { /* ignore */ }
}
root.innerHTML = '';
root.appendChild(el('div', {class:'msg'}, 'Loading…'));
// Animated anvil loader. Replaces the old text "Loading…" so the
// user gets a sense of "the forge is heating up" instead of a flat
// spinner. The SVG itself drives all animation via SMIL, no JS.
root.appendChild(el('div', {class:'loader'}, [
el('div', {class:'anvil'}),
el('div', {}, 'Heating the forge…'),
]));
try {
const body = await views[view]();
root.innerHTML = '';
@@ -674,6 +827,7 @@
$$('[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));
$$('[data-bind=host_count]').forEach(n => n.textContent = String(s.host_bindings || 0));
const chip = $('[data-bind=ready_chip]');
if (chip) {
if (r.ok) { chip.textContent = '● ready'; chip.className = 'chip ready'; }
+40 -1
View File
@@ -3,9 +3,24 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>PXEForge</title>
<link rel="stylesheet" href="/assets/app.css" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
<!-- Theme is read from localStorage *before* paint to avoid the
dark→light flash on every navigation. Falls back to the OS
preference and finally to dark. -->
<script>
(function() {
try {
var stored = localStorage.getItem('pxeforge-theme');
var theme = stored || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head>
<body>
<div class="shell">
@@ -14,7 +29,7 @@
<img src="/assets/logo.svg" alt="" />
<div>
<strong>PXEForge</strong>
<div class="sub">v<span data-bind="version">0.1.0</span></div>
<div class="sub">v<span data-bind="version">0.2.0</span></div>
</div>
</div>
<nav>
@@ -28,6 +43,10 @@
Storage
<span class="count" data-bind="iso_count">0</span>
</a>
<a data-view="hosts">
Hosts
<span class="count" data-bind="host_count">0</span>
</a>
<a data-view="terminal">Terminal</a>
<a data-view="about">About</a>
</nav>
@@ -44,6 +63,26 @@
<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>
<button id="theme-toggle" class="theme-toggle" type="button"
aria-label="Toggle light/dark theme" title="Toggle theme (T)">
<!-- Two glyphs; CSS shows whichever matches the active theme. -->
<svg class="t-sun" viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="4.2"/>
<line x1="12" y1="2.5" x2="12" y2="5.5"/>
<line x1="12" y1="18.5" x2="12" y2="21.5"/>
<line x1="2.5" y1="12" x2="5.5" y2="12"/>
<line x1="18.5" y1="12" x2="21.5" y2="12"/>
<line x1="5.2" y1="5.2" x2="7.3" y2="7.3"/>
<line x1="16.7" y1="16.7" x2="18.8" y2="18.8"/>
<line x1="5.2" y1="18.8" x2="7.3" y2="16.7"/>
<line x1="16.7" y1="7.3" x2="18.8" y2="5.2"/>
</svg>
<svg class="t-moon" viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.5 14A8 8 0 0 1 10 3.5 a8 8 0 1 0 10.5 10.5z"/>
</svg>
</button>
</header>
<main class="main" id="view-root"></main>
+11 -4
View File
@@ -23,7 +23,14 @@ 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");
/// Animated forging anvil — sparks rise + glow pulse. Used for the
/// imaging-progress widget and any "I'm working" loading state. Pure
/// SVG + SMIL, no JS, no GIF.
#[must_use]
pub fn anvil_forge_svg() -> &'static str { ANVIL_FORGE_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");
const ANVIL_FORGE_SVG: &str = include_str!("anvil-forge.svg");
+30 -12
View File
@@ -1,14 +1,32 @@
<svg viewBox="0 0 96 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 200 130" xmlns="http://www.w3.org/2000/svg" fill="none">
<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"/>
<defs>
<linearGradient id="anvilBody" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#aab3c2"/>
<stop offset="55%" stop-color="#7d8696"/>
<stop offset="100%" stop-color="#525a6b"/>
</linearGradient>
<linearGradient id="anvilFace" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#cdd5e1"/>
<stop offset="100%" stop-color="#9aa3b3"/>
</linearGradient>
<linearGradient id="anvilBase" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3b4252"/>
<stop offset="100%" stop-color="#252a36"/>
</linearGradient>
</defs>
<!-- Horn (left point) + face (top) -->
<path d="M14 36 L72 30 L162 30 L162 46 L72 46 Z"
fill="url(#anvilFace)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
<!-- Body / waist -->
<path d="M70 46 L160 46 L142 70 L88 70 Z"
fill="url(#anvilBody)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
<!-- Base plinth -->
<path d="M62 96 L168 96 L160 110 L70 110 Z"
fill="url(#anvilBase)" stroke="#0d1018" stroke-width="2.4" stroke-linejoin="round"/>
<!-- Pillar between body and base -->
<rect x="92" y="70" width="46" height="26" fill="url(#anvilBody)"
stroke="#1d2330" stroke-width="2.4"/>
<!-- Highlight along top face -->
<line x1="74" y1="34" x2="158" y2="34" stroke="#e6ecf5" stroke-width="1.2" opacity="0.7"/>
</svg>

Before

Width:  |  Height:  |  Size: 650 B

After

Width:  |  Height:  |  Size: 1.5 KiB