v0.3.0 — rebrand: PXEForge → OpenPXE, Gated → Queued Deployment

Full rename to match the openpxe.com brand. The product now reads as a
polished open-source project rather than a personal-tool nickname:
the anvil/forge metaphor is gone, replaced with the rainbow-horizon
brand mark from the marketing site.

## Naming changes

**PXEForge → OpenPXE** everywhere it's user-visible or developer-
facing:
- All 8 crate package names (`pxeforge-*` → `openpxe-*`).
- The bin crate dir + binary (`crates/pxeforge` → `crates/openpxe`,
  `bin = "openpxe"`).
- Env vars: `PXEFORGE_*` → `OPENPXE_*` (no compat shim — pre-beta).
- Tracing targets: `pxeforge::*` → `openpxe::*`.
- Prometheus metrics: `pxeforge_*` → `openpxe_*` (pre-beta; nobody
  has dashboards on these yet).
- Container image: `gitea.milesward.dev/mward4/openpxe:0.3.0`.
- All in-tree paths: `/var/lib/openpxe/{isos,work,smb}`,
  `/usr/share/openpxe/ipxe`, `/etc/openpxe/...`.
- Unraid template renamed `pxeforge.xml` → `openpxe.xml`.
- README, NEXT_PHASE.md, architecture.md, comments, and the WebUI
  brand string.

**Gated Deployment → Queued Deployment** as the user-facing concept:
- `Settings::TimeoutAction::GatedDeployment` →
  `QueuedDeployment` (with `#[serde(alias = "gated_deployment")]`
  so v0.2.0 settings.json files keep deserializing).
- Rust types: `Gate` → `QueueEntry`, `GateQueue` → `DeploymentQueue`,
  `GateInner` → `QueueEntryInner`.
- File: `crates/core/src/gate.rs` → `crates/core/src/queue.rs`.
- HTTP routes: `/api/gate/*` → `/api/queue/*`. The JSON list key
  flipped from `"gates"` to `"entries"` to match.
- iPXE shortcut: `/boot/_gate.ipxe` → `/boot/_queue.ipxe`. The
  top-level menu's item id is now `queue` instead of `gate`.
- WebUI sidebar tab: "Forge Gate" → "Queue".
- Field on `AppState`: `gates` → `queue`.

## Brand assets

The anvil + forging-sparks logos are dropped:
- `logo.svg` is now a 24×24 medallion filled with the
  `rainbow-horizon` gradient from openpxe.com (sliding hue rotation
  via SMIL on the gradient stops, no JS needed).
- `anvil-forge.svg` renamed to `loader.svg` and rebuilt as a 64×64
  louder version of the same disc — used for page-load transitions
  and the imaging-progress widget. Adds a subtle scale pulse and a
  white inner-glow so it has dimensionality on either theme.

## CSS rename

- `.forge-progress` → `.queue-progress`
- `.forge-progress .anvil` → `.queue-progress .mark`
- `@keyframes forge-sheen` → `queue-sheen`
- `.loader .anvil` → `.loader .mark`
- "Heating the forge…" loader text → "Loading…"

The rest of the layout is untouched. Light/dark theme tokens and the
sidebar/topbar structure carry over from v0.2.0 unchanged — the
brief was "keeping the UI similar."

## Validation

- `cargo build --workspace` — clean.
- `cargo clippy --workspace --all-targets` — no warnings.
- `cargo test --workspace` — **66 tests passing**, same as v0.2.0.
- Local smoke run against the rebuilt release binary verifies:
  - `/boot.ipxe` emits `Queued Deployment` + `item queue` + chains
    `/boot/_queue.ipxe`
  - `/api/queue` returns `{count, entries}`
  - `/metrics` emits `openpxe_queue_count` (renamed)
  - `/assets/logo.svg` and `/assets/loader.svg` serve the new
    rainbow brand SVGs
  - `/api/status` reports version `0.3.0`

## Migration notes for operators on v0.2.0

- Container image path changed: pull
  `gitea.milesward.dev/mward4/openpxe:0.3.0` (not `pxeforge:`).
- Bind mounts: `/var/lib/openpxe/{isos,work,smb}` (not `pxeforge`).
  Move the host path or update the template.
- Env vars: replace `PXEFORGE_*` with `OPENPXE_*`. The Unraid
  template at `deploy/unraid/openpxe.xml` is already updated.
- `settings.json` carries over transparently — the
  `gated_deployment` value is accepted as an alias.
- HTTP API: any external scripts that hit `/api/gate/*` need to
  switch to `/api/queue/*`. The JSON envelope key is `entries`
  instead of `gates`.
This commit is contained in:
Miles Ward
2026-05-06 14:13:38 -04:00
parent c607f2e31c
commit e3452fe976
56 changed files with 755 additions and 802 deletions
+47 -47
View File
@@ -1,7 +1,7 @@
// PXEForge web UI — vanilla JS, no build step, no framework, no network
// OpenPXE 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 /
// Tabs (Phase 4): Dashboard / Network / Queue / Storage / Terminal /
// About. The shell swaps a single view into #view-root.
//
// Keep this readable — nobody wants to debug a clever vanilla-JS
@@ -75,19 +75,19 @@
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);
// Animated brand-mark + progress bar widget. Built once here and inlined
// wherever a card wants to convey "an image is being deployed onto a
// queued client right now." Used on the Dashboard and the Queue tab.
function queueProgressWidget(imaging, queueTotal) {
const total = Math.max(queueTotal, 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'}),
const root = el('div', {class: 'queue-progress' + (imaging === 0 ? ' idle' : '')}, [
el('div', {class: 'mark', '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')),
: (imaging + ' of ' + total + ' device' + (total === 1 ? '' : 's') + ' deploying')),
el('div', {class: 'bar-track'},
el('div', {class: 'bar-fill', style: 'width:' + pct + '%'})),
]),
@@ -120,7 +120,7 @@
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 entries = (await getJSON('/api/queue')).entries || [];
const ipxeOk = (status.ipxe_assets || []).length > 0;
const stats = el('div', {class: 'statstrip'}, [
@@ -136,9 +136,9 @@
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'),
el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting in queue'),
]),
forgeProgressWidget(status.imaging_count || 0, status.gate_count || 0),
queueProgressWidget(status.imaging_count || 0, status.queue_count || 0),
]),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Images available'),
@@ -151,7 +151,7 @@
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),
el('div', {class: 'trend'}, 'OpenPXE ' + status.version),
])),
]);
@@ -159,10 +159,10 @@
// 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);
const g = entries.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');
else if (g) status = el('span', {class:'tag accent'}, '#' + g.position + ' in queue');
return el('tr', {}, [
el('td', {class: 'mono'}, c.mac),
el('td', {}, c.last_ip ? String(c.last_ip) : '-'),
@@ -229,13 +229,13 @@
]),
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 ' +
'To change them, set OPENPXE_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 ' +
'OpenPXE 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.'),
]),
@@ -247,8 +247,8 @@
},
gate: async () => {
const [{ gates = [] }, isos] = await Promise.all([
getJSON('/api/gate'), getJSON('/api/isos'),
const [{ entries = [] }, isos] = await Promise.all([
getJSON('/api/queue'), getJSON('/api/isos'),
]);
const targets = isos.flatMap(i => i.boot_entries.map(e => ({
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family)
@@ -261,7 +261,7 @@
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: [] });
const r = await postJSON('/api/queue/assign', { target: pick.value, entry_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; }
@@ -270,9 +270,9 @@
render('gate');
};
const track = gates.length
? el('div', {class:'gate-track'},
gates.map(g => el('div', {class:'gate-row' + (g.assigned_target ? ' assigned' : '')}, [
const track = entries.length
? el('div', {class:'queue-track'},
entries.map(g => el('div', {class:'queue-row' + (g.assigned_target ? ' assigned' : '')}, [
el('div', {class:'pos'}, '#' + g.position),
el('div', {}, [
el('div', {class:'mac'}, g.mac),
@@ -283,20 +283,20 @@
? 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'});
await fetch('/api/queue/' + 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.');
'No clients at the gate. Boot a client and choose "Queued Deployment" in the PXE menu.');
const imaging = gates.filter(g => g.assigned_target).length;
const imaging = entries.filter(g => g.assigned_target).length;
return el('div', {class:'grid'}, [
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Forge')),
forgeProgressWidget(imaging, gates.length),
queueProgressWidget(imaging, entries.length),
]),
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Launch an image across the gate')),
@@ -313,7 +313,7 @@
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Gate positions'),
el('span', {class:'sub'}, gates.length + ' waiting'),
el('span', {class:'sub'}, entries.length + ' waiting'),
]),
el('div', {class:'body'}, track),
]),
@@ -516,7 +516,7 @@
// 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: '_queue', title: '↳ Queued Deployment (built-in)'},
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
];
@@ -583,7 +583,7 @@
]),
upsertBtn, msg,
el('p', {class:'msg', style:'margin-top:14px'},
'When a client with a bound MAC requests boot.ipxe, PXEForge ' +
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' +
'short-circuits past the interactive menu and chains directly. ' +
'Inspired by Tinkerbell smee\'s MAC-prepended URL pattern.'),
]),
@@ -629,7 +629,7 @@
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') + '] '),
el('span', {class:'tg'}, '[' + (line.target || 'openpxe') + '] '),
line.message,
'\n',
]);
@@ -647,7 +647,7 @@
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',
append({timestamp: new Date().toISOString(), level:'warn', target:'openpxe::ui',
message: 'failed to load recent logs: ' + e.message});
}
@@ -655,12 +655,12 @@
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}); }
catch { append({timestamp: new Date().toISOString(), level:'debug', target:'openpxe::ui', message: ev.data}); }
};
es.addEventListener('lagged', (ev) => {
const j = JSON.parse(ev.data || '{}');
append({timestamp: new Date().toISOString(), level:'warn',
target:'pxeforge::ui',
target:'openpxe::ui',
message: 'log stream lagged: ' + (j.skipped || '?') + ' lines skipped'});
});
es.onerror = () => {
@@ -668,7 +668,7 @@
// 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',
append({timestamp: new Date().toISOString(), level:'warn', target:'openpxe::ui',
message: 'log stream connection lost — auto-reconnecting'});
setTimeout(() => { term._notedErr = false; }, 5000);
}
@@ -701,7 +701,7 @@
target: 'terminal-output', message: out});
} catch (err) {
append({timestamp: new Date().toISOString(), level:'error',
target:'pxeforge::ui', message: 'command failed: ' + err.message});
target:'openpxe::ui', message: 'command failed: ' + err.message});
}
} else if (e.key === 'ArrowUp') {
if (history.length === 0) return;
@@ -716,7 +716,7 @@
});
// Welcome banner.
append({timestamp: new Date().toISOString(), level:'info', target:'pxeforge::terminal',
append({timestamp: new Date().toISOString(), level:'info', target:'openpxe::terminal',
message: 'Connected. Type "help" for available commands.'});
// Focus the input on next tick (after view swap completes).
@@ -729,7 +729,7 @@
const status = await getJSON('/api/status');
return el('div', {class:'card'}, [
el('div', {class:'about-hero'}, [
el('h2', {}, 'PXEForge'),
el('h2', {}, 'OpenPXE'),
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 ' +
@@ -758,7 +758,7 @@
const viewTitles = {
dashboard: 'Dashboard',
network: 'Network',
gate: 'Forge Gate',
gate: 'Queue',
storage: 'Storage',
hosts: 'Hosts',
terminal: 'Terminal',
@@ -769,7 +769,7 @@
// 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 {}
try { localStorage.setItem('openpxe-theme', theme); } catch {}
}
function currentTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
@@ -801,12 +801,12 @@
try { currentBody._cleanup(); } catch (e) { /* ignore */ }
}
root.innerHTML = '';
// 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.
// Animated rainbow-mark loader replacing the old plain-text
// \"Loading…\". The SVG drives all animation via SMIL — no JS, no
// CSS keyframes.
root.appendChild(el('div', {class:'loader'}, [
el('div', {class:'anvil'}),
el('div', {}, 'Heating the forge…'),
el('div', {class:'mark'}),
el('div', {}, 'Loading…'),
]));
try {
const body = await views[view]();
@@ -826,7 +826,7 @@
$$('[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));
$$('[data-bind=queue_count],[data-bind=queue_count2]').forEach(n => n.textContent = String(s.queue_count));
$$('[data-bind=host_count]').forEach(n => n.textContent = String(s.host_bindings || 0));
const chip = $('[data-bind=ready_chip]');
if (chip) {