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:
+160
-6
@@ -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'; }
|
||||
|
||||
Reference in New Issue
Block a user