Compare commits

...
1 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.8 a71057fce6 v0.7.2: UI polish — list search, unified Hosts form, NIC link details, About refresh, spacing
Storage:
- Filter inputs for the Available images table (matches filename,
  detected family, category, source) and the Unattended files list
  (name, kind). Pure client-side; shown when there's more than one
  entry. Forty-image libraries are now navigable.

Hosts — one form, one mental model:
- The separate Boot rules card is gone. The Pin form gains
  'Architecture (optional)' next to Label (plus the v0.7.1 boot-binary
  pin): a full MAC with no architecture saves a per-host pin exactly as
  before; a MAC prefix and/or architecture saves a first-match-wins
  group rule. Saved rules render as a compact read-only 'Group rules'
  card with remove buttons.
- The boot-decision webhook keeps working via /api/boot-rules but no
  longer has a UI knob (operator feedback: not needed in the UI).
- Per-machine auto-deploy fields are rejected on group rules with a
  clear message (they're per-host values).

Network:
- New 'Link' row under NIC name: operstate · speed · duplex · port MAC,
  read from sysfs at startup (detect_link_info). Empty-degrades on
  non-Linux dev builds and virtual NICs. Confirms WHICH physical port
  answers PXE in multi-NIC/trunked environments.

About:
- Hero copy rewritten: positioning lead, three-pillar feature grid
  (Boot anything / Adapt to every machine / Run it in production), and
  the privacy + no-test-cert principles restated crisply.

Spacing:
- label.field:has(+ button) collapse fixes the doubled 30px gap above
  Queue 'Launch for all waiting' and Network 'Save' (now the same 16px
  as every other card action).

Validation: clippy clean, fmt clean, 299 workspace tests green, webui
syntax-checked. No protocol or boot-path changes in this release.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 21:47:48 -04:00
8 changed files with 242 additions and 139 deletions
Generated
+8 -8
View File
@@ -2836,7 +2836,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "openpxe" name = "openpxe"
version = "0.7.1" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -2858,7 +2858,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-core" name = "openpxe-core"
version = "0.7.1" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -2885,7 +2885,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-dhcp-proxy" name = "openpxe-dhcp-proxy"
version = "0.7.1" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2902,7 +2902,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-http-api" name = "openpxe-http-api"
version = "0.7.1" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -2938,7 +2938,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-ipxe-assets" name = "openpxe-ipxe-assets"
version = "0.7.1" version = "0.7.2"
dependencies = [ dependencies = [
"openpxe-core", "openpxe-core",
"rust-embed", "rust-embed",
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-iso-store" name = "openpxe-iso-store"
version = "0.7.1" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bcrypt", "bcrypt",
@@ -2977,7 +2977,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-tftp" name = "openpxe-tftp"
version = "0.7.1" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2991,7 +2991,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-webui" name = "openpxe-webui"
version = "0.7.1" version = "0.7.2"
[[package]] [[package]]
name = "p256" name = "p256"
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.7.1" version = "0.7.2"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+1
View File
@@ -2744,6 +2744,7 @@ async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
.strip_prefix("http://") .strip_prefix("http://")
.unwrap_or(&state.public_base_url), .unwrap_or(&state.public_base_url),
"nic_name": state.nic_name, "nic_name": state.nic_name,
"nic_link": state.nic_link,
"subnet_mask": state.subnet_mask, "subnet_mask": state.subnet_mask,
"gateway": state.gateway, "gateway": state.gateway,
"dns_server": state.settings.snapshot().dns_server, "dns_server": state.settings.snapshot().dns_server,
+4
View File
@@ -116,6 +116,10 @@ pub struct AppState {
/// `enp1s0`). Surfaced read-only on the Network tab. Empty if the /// `enp1s0`). Surfaced read-only on the Network tab. Empty if the
/// interface couldn't be identified. /// interface couldn't be identified.
pub nic_name: String, pub nic_name: String,
/// v0.7.2: physical link summary for that NIC (operstate, speed,
/// duplex, port MAC) — read from sysfs at startup; empty where
/// unavailable. Helps confirm which port answers PXE.
pub nic_link: String,
/// Subnet mask of the public interface in dotted-quad form. /// Subnet mask of the public interface in dotted-quad form.
pub subnet_mask: String, pub subnet_mask: String,
/// Default gateway IPv4 address. /// Default gateway IPv4 address.
+1
View File
@@ -135,6 +135,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
public_base_url: "http://127.0.0.1".into(), public_base_url: "http://127.0.0.1".into(),
nic_name: "lo".into(), nic_name: "lo".into(),
nic_link: String::new(),
subnet_mask: "255.0.0.0".into(), subnet_mask: "255.0.0.0".into(),
gateway: "127.0.0.1".into(), gateway: "127.0.0.1".into(),
}; };
+42
View File
@@ -215,6 +215,7 @@ async fn main() -> anyhow::Result<()> {
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
public_base_url: public_base_url.clone(), public_base_url: public_base_url.clone(),
nic_name: net.nic_name, nic_name: net.nic_name,
nic_link: net.nic_link,
subnet_mask: net.subnet_mask, subnet_mask: net.subnet_mask,
gateway: net.gateway, gateway: net.gateway,
}; };
@@ -448,6 +449,12 @@ struct NetworkInfo {
nic_name: String, nic_name: String,
subnet_mask: String, subnet_mask: String,
gateway: String, gateway: String,
/// v0.7.2: physical link summary for the Network tab — operstate,
/// negotiated speed/duplex, and the port's own MAC. Helps operators
/// in multi-NIC / trunked environments confirm *which* port the PXE
/// server actually answers on. Empty when sysfs isn't available
/// (non-Linux dev builds) or the NIC wasn't identified.
nic_link: String,
} }
/// Best-effort population of the Network tab's read-only fields. We shell /// Best-effort population of the Network tab's read-only fields. We shell
@@ -508,9 +515,44 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
} }
} }
info.nic_link = detect_link_info(&info.nic_name);
info info
} }
/// v0.7.2: read the NIC's physical link details from sysfs. Every field
/// is optional — virtual NICs report no speed (`-1` or absent), and
/// non-Linux dev machines have no `/sys/class/net` at all — so the
/// result is whatever could be read, joined human-readably, or empty.
fn detect_link_info(nic: &str) -> String {
if nic.is_empty() {
return String::new();
}
let read = |file: &str| {
std::fs::read_to_string(format!("/sys/class/net/{nic}/{file}"))
.map(|s| s.trim().to_string())
.unwrap_or_default()
};
let mut parts: Vec<String> = Vec::new();
let state = read("operstate");
if !state.is_empty() {
parts.push(format!("link {state}"));
}
let speed = read("speed");
if !speed.is_empty() && speed != "-1" {
parts.push(format!("{speed} Mb/s"));
}
let duplex = read("duplex");
if !duplex.is_empty() && duplex != "unknown" {
parts.push(format!("{duplex} duplex"));
}
let mac = read("address");
if !mac.is_empty() {
parts.push(format!("port {mac}"));
}
parts.join(" · ")
}
fn prefix_to_dotted(prefix: u8) -> String { fn prefix_to_dotted(prefix: u8) -> String {
let prefix = prefix.min(32); let prefix = prefix.min(32);
let mask: u32 = if prefix == 0 { let mask: u32 = if prefix == 0 {
+11
View File
@@ -912,3 +912,14 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
} }
.modal-actions .submit { width: auto; padding: 8px 18px; } .modal-actions .submit { width: auto; padding: 8px 18px; }
/* v0.7.2: a label.field directly followed by the card's action button
stacked its own 14px bottom margin onto the button's 16px top margin
(30px total) — visible on Queue "Launch for all waiting" and the
Network "Save". Collapse the doubled gap so every primary action sits
the same 16px below its form. */
.card .body > label.field:has(+ button) { margin-bottom: 0; }
/* v0.7.2: inline list filter (Available images / Unattended files). */
.list-search { padding: 14px 16px 0; }
.list-search input { width: 100%; }
+167 -123
View File
@@ -199,109 +199,47 @@
})[k] || (k || 'Unknown'); })[k] || (k || 'Unknown');
} }
// v0.7.0: the Boot rules card — ordered first-match-wins rules // v0.7.2: compact read-out of saved group rules — created from the
// (MAC prefix / architecture → target) plus the optional // unified "Pin MAC" form on the Hosts tab (a prefix or an architecture
// boot-decision webhook. Saved as one config because rule order // there saves a rule instead of a pin). First match wins, top to
// matters. With no rules and no webhook, behavior is identical to // bottom. The boot-decision webhook remains available via the API
// before the feature existed. // (/api/boot-rules `webhook_url`) but no longer has a UI knob.
function bootRulesCard(cfg, targetOptions) { function groupRulesCard(cfg, targetOptions) {
const archChoices = [ const rules = (cfg && cfg.rules) || [];
['', 'any arch'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'], if (!rules.length) return null;
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'], const titleFor = id => {
]; const t = targetOptions.find(x => x.id === id);
// v0.7.1: optional first-boot binary pin. "Auto" lets the return t ? t.title : id;
// escalation ladder learn per machine; pinning skips the learning
// walk entirely (e.g. a rack known to run Secure Boot → shim).
const modeChoices = [
['', 'auto (learn)'], ['firmware', 'Firmware NIC'],
['builtin', 'iPXE drivers'], ['shim', 'Secure Boot (shim)'],
];
const rules = (cfg.rules || []).map(r => Object.assign({}, r));
const tbody = el('tbody', {});
const msg = el('div', {class:'msg'});
const webhookInput = el('input', {type:'text', spellcheck:'false',
placeholder:'http://automation.example/boot-decision (optional)',
value: cfg.webhook_url || ''});
const targetSelect = (val) => el('select', {},
[el('option', {value:''}, '— target —')]
.concat(targetOptions.map(t =>
el('option', Object.assign({value: t.id}, t.id === val ? {selected:''} : {}), t.title))));
const redraw = () => {
tbody.innerHTML = '';
if (!rules.length) {
tbody.appendChild(el('tr', {}, el('td', {colspan:'7', class:'empty', style:'padding:14px'},
'No rules. Add one to route whole groups of machines (an OUI, an architecture) to a target — or to pin a boot binary (e.g. Secure Boot racks → shim, zero failed cycles).')));
}
rules.forEach((r, i) => {
const macIn = el('input', {type:'text', spellcheck:'false', placeholder:'aa:bb:cc (prefix)',
value: r.mac_prefix || '', oninput: e => { r.mac_prefix = e.target.value; }});
const archSel = el('select', {onchange: e => { r.arch = e.target.value; }},
archChoices.map(([v, label]) =>
el('option', Object.assign({value: v}, v === (r.arch || '') ? {selected:''} : {}), label)));
const tgtSel = targetSelect(r.target || '');
tgtSel.onchange = e => { r.target = e.target.value; };
const modeSel = el('select', {onchange: e => { r.driver_mode = e.target.value; }},
modeChoices.map(([v, label]) =>
el('option', Object.assign({value: v}, v === (r.driver_mode || '') ? {selected:''} : {}), label)));
const noteIn = el('input', {type:'text', placeholder:'note',
value: r.note || '', oninput: e => { r.note = e.target.value; }});
const enabled = el('input', {type:'checkbox', onchange: e => { r.enabled = e.target.checked; }});
enabled.checked = r.enabled !== false;
tbody.appendChild(el('tr', {}, [
el('td', {}, macIn),
el('td', {}, archSel),
el('td', {}, tgtSel),
el('td', {}, modeSel),
el('td', {}, noteIn),
el('td', {style:'text-align:center'}, enabled),
el('td', {style:'text-align:right'},
el('button', {class:'danger', onclick: () => { rules.splice(i, 1); redraw(); }}, '✕')),
]));
});
}; };
redraw(); const modeLabel = {firmware:'Firmware NIC', builtin:'iPXE drivers', shim:'Secure Boot (shim)'};
const rows = rules.map((r, i) => el('tr', r.enabled === false ? {style:'opacity:.5'} : {}, [
const addBtn = el('button', {class:'ghost', onclick: () => { el('td', {class:'mono'}, r.mac_prefix || el('span', {class:'tag'}, 'any MAC')),
rules.push({mac_prefix:'', arch:'', target:'', enabled:true, note:''}); el('td', {}, r.arch || el('span', {class:'tag'}, 'any arch')),
redraw(); el('td', {}, r.target ? titleFor(r.target) : el('span', {class:'tag'}, '—')),
}}, '+ Add rule'); el('td', {}, r.driver_mode
const saveBtn = el('button', {onclick: async () => { ? el('span', {class:'tag accent'}, modeLabel[r.driver_mode] || r.driver_mode)
// A rule needs at least one effect: a target or a boot-binary pin. : el('span', {class:'tag'}, 'auto')),
const bad = rules.find(r => r.enabled !== false && !r.target && !r.driver_mode); el('td', {}, r.note || ''),
if (bad) { msg.textContent = 'Every enabled rule needs a target or a boot-binary pin.'; msg.className = 'msg err'; return; } el('td', {style:'text-align:right'},
const r = await putJSON('/api/boot-rules', {rules, webhook_url: webhookInput.value.trim()}); el('button', {class:'danger', onclick: async () => {
if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; } if (!confirm('Remove this group rule?')) return;
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; } const fresh = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
}}, 'Save rules'); (fresh.rules = fresh.rules || []).splice(i, 1);
await putJSON('/api/boot-rules', fresh);
render('hosts');
}}, 'Remove')),
]));
return el('div', {class:'card'}, [ return el('div', {class:'card'}, [
el('header', {}, [ el('header', {}, [
el('h2', {}, 'Boot rules'), el('h2', {}, 'Group rules'),
el('span', {class:'sub'}, 'first match wins · checked top to bottom'), el('span', {class:'sub'}, 'first match wins · checked top to bottom'),
]), ]),
el('div', {class:'body'}, [
el('table', {}, [ el('table', {}, [
el('thead', {}, el('tr', {}, [ el('thead', {}, el('tr', {}, [
el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'), el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'),
el('th',{},'Boot binary'), el('th',{},'Note'), el('th',{},'On'), el('th',{},''), el('th',{},'Boot binary'), el('th',{},'Note'), el('th',{},''),
])), ])),
tbody, el('tbody', {}, rows),
]),
el('div', {style:'margin-top:12px'}, [addBtn, saveBtn]),
el('label', {class:'field', style:'margin-top:16px;display:block'}, [
el('span', {class:'name'}, 'Boot-decision webhook (optional)'),
webhookInput,
el('span', {class:'hint'},
'When no pin or rule matches, OpenPXE GETs this URL with ?mac=…&arch=… ' +
'A 200 reply of {"target": "<entry-id>"} chains to that target; anything ' +
'else (404, timeout, error) falls through to the menu — a dead endpoint ' +
'can never block PXE.'),
]),
msg,
el('p', {class:'msg', style:'margin-top:10px'},
'Decision order per boot: exact MAC pin → first matching rule → webhook → interactive menu.'),
]), ]),
]); ]);
} }
@@ -501,6 +439,11 @@
el('div', {class:'v'}, net.server_ip || '?'), el('div', {class:'v'}, net.server_ip || '?'),
el('div', {class:'k'}, 'NIC name'), el('div', {class:'k'}, 'NIC name'),
el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'), el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'),
// v0.7.2: physical link details (operstate · speed · duplex ·
// port MAC) so the operator can confirm WHICH port answers
// PXE in multi-NIC / trunked environments.
el('div', {class:'k'}, 'Link'),
el('div', {class:'v'}, net.nic_link || '—'),
el('div', {class:'k'}, 'Subnet mask'), el('div', {class:'k'}, 'Subnet mask'),
el('div', {class:'v'}, net.subnet_mask || '?'), el('div', {class:'v'}, net.subnet_mask || '?'),
el('div', {class:'k'}, 'Gateway'), el('div', {class:'k'}, 'Gateway'),
@@ -896,6 +839,12 @@
render('storage'); render('storage');
}; };
// v0.7.2: searchable haystack for the list filter — filename,
// detected family, category, and source all match.
const searchText = [
i.filename, familyLabel(i.introspection.family), i.category || '',
isSmb ? 'smb' : isNfs ? 'nfs' : 'local', i.id,
].join(' ').toLowerCase();
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [ const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
el('td', {}, [ el('td', {}, [
el('div', {style:'display:flex;align-items:center;gap:8px'}, [ el('div', {style:'display:flex;align-items:center;gap:8px'}, [
@@ -940,8 +889,24 @@
}}, 'Remove'), }}, 'Remove'),
]), ]),
]); ]);
tr.dataset.search = searchText;
rowsAndEditors.push(tr, editorRow); rowsAndEditors.push(tr, editorRow);
}); });
// v0.7.2: client-side filter over the image table. Rows travel in
// (row, password-editor) pairs; filtering hides both, and an open
// editor stays closed for filtered-out rows.
const isoSearch = el('input', {type:'search', placeholder:'Filter images… (name, family, category, source)',
spellcheck:'false', oninput: () => {
const q = isoSearch.value.trim().toLowerCase();
for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) {
const row = rowsAndEditors[k];
const editor = rowsAndEditors[k + 1];
const show = !q || (row.dataset.search || '').includes(q);
row.style.display = show ? '' : 'none';
if (!show) editor.style.display = 'none';
}
}});
const isoTable = isos.length const isoTable = isos.length
? el('table', {}, [ ? el('table', {}, [
el('thead', {}, el('tr', {}, [ el('thead', {}, el('tr', {}, [
@@ -1279,7 +1244,10 @@
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); }; unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
const unattRows = unattendedFiles.length const unattRows = unattendedFiles.length
? unattendedFiles.map(f => el('div', {class:'nfs-row'}, [ ? unattendedFiles.map(f => el('div', {
class:'nfs-row',
'data-search': (f.filename + ' ' + unattendedKindLabel(f.kind) + ' ' + f.id).toLowerCase(),
}, [
el('span', {class:'dot ok'}), el('span', {class:'dot ok'}),
el('div', {}, [ el('div', {}, [
el('div', {class:'id'}, [ el('div', {class:'id'}, [
@@ -1307,6 +1275,17 @@
]), ]),
el('div', {class:'body'}, [ el('div', {class:'body'}, [
unattDrop, unattFile, unattMsg, unattDrop, unattFile, unattMsg,
// v0.7.2: filter for big answer-file libraries.
unattendedFiles.length > 1 ? (() => {
const search = el('input', {type:'search', placeholder:'Filter files… (name, kind)',
spellcheck:'false', style:'margin-top:14px', oninput: () => {
const q = search.value.trim().toLowerCase();
unattRows.forEach(r => {
r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none';
});
}});
return search;
})() : null,
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows), el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
el('p', {class:'msg', style:'margin-top:14px'}, el('p', {class:'msg', style:'margin-top:14px'},
'These answer files drive unattended installs. Attach one to a ' + 'These answer files drive unattended installs. Attach one to a ' +
@@ -1353,6 +1332,7 @@
el('h2', {}, 'Available images'), el('h2', {}, 'Available images'),
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')), el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
]), ]),
isos.length > 1 ? el('div', {class:'list-search'}, isoSearch) : null,
isoTable, isoTable,
]), ]),
]), unattendedAdvanced]); ]), unattendedAdvanced]);
@@ -1378,8 +1358,22 @@
{id: '_tools_menu', title: '↳ Tools menu (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 macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff or prefix aa:bb:cc', spellcheck:'false'});
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'}); const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
// v0.7.2: the former separate "Boot rules" card folded into this
// form. A full MAC with no architecture saves a per-host pin
// exactly as before; a MAC *prefix* and/or an architecture saves a
// first-match-wins group rule instead. Same form, one mental model.
const archSel = el('select', {}, [
['', 'any (this exact MAC)'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'],
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'],
].map(([v, t]) => el('option', {value: v}, t)));
// v0.7.1's boot-binary pin keeps its home here too (auto = let the
// escalation ladder learn; shim = known Secure Boot fleet).
const binSel = el('select', {}, [
['', 'auto (learn per machine)'], ['firmware', 'Firmware NIC'],
['builtin', 'iPXE drivers'], ['shim', 'Secure Boot (shim)'],
].map(([v, t]) => el('option', {value: v}, t)));
const targetSel = el('select', {}, const targetSel = el('select', {},
[el('option', {value:''}, '— choose a target —')] [el('option', {value:''}, '— choose a target —')]
.concat(reserved.map(t => el('option', {value: t.id}, t.title))) .concat(reserved.map(t => el('option', {value: t.id}, t.title)))
@@ -1392,21 +1386,40 @@
// hostname/IP templated into the served answer file. // hostname/IP templated into the served answer file.
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3'); const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
const FULL_MAC = /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i;
const upsertBtn = el('button', {onclick: async () => { const upsertBtn = el('button', {onclick: async () => {
if (!macInput.value || !targetSel.value) { const mac = macInput.value.trim();
const isGroup = !!archSel.value || !!binSel.value || (mac !== '' && !FULL_MAC.test(mac));
if (!isGroup) {
// Exact-MAC pin — unchanged behavior.
if (!mac || !targetSel.value) {
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return; msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
} }
const r = await postJSON('/api/hosts', Object.assign({ const r = await postJSON('/api/hosts', Object.assign({
mac: macInput.value, target: targetSel.value, label: labelInput.value, mac, target: targetSel.value, label: labelInput.value,
}, profileFields.read())); }, profileFields.read()));
if (r.ok) { if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; render('hosts'); }
msg.textContent = 'Saved.'; msg.className = 'msg ok'; else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
render('hosts'); return;
} else {
const t = await r.text();
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
} }
}}, 'Bind MAC to target'); // Group rule (prefix and/or architecture). Per-host profile
// fields don't apply to a group — they're per-machine values.
if (!targetSel.value && !binSel.value) {
msg.textContent = 'A group rule needs a target or a boot binary.'; msg.className = 'msg err'; return;
}
const p = profileFields.read();
if (p.auto_hostname || p.auto_ip || p.unattended_file) {
msg.textContent = 'Auto-deploy fields are per-machine — clear them, or use a full MAC.'; msg.className = 'msg err'; return;
}
const cfg = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
(cfg.rules = cfg.rules || []).push({
mac_prefix: mac, arch: archSel.value, target: targetSel.value,
driver_mode: binSel.value, enabled: true, note: labelInput.value,
});
const r = await putJSON('/api/boot-rules', cfg);
if (r.ok) { msg.textContent = 'Group rule saved.'; msg.className = 'msg ok'; render('hosts'); }
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
}}, 'Bind to target');
const rows = hosts.map(h => { const rows = hosts.map(h => {
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole // v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
@@ -1463,23 +1476,38 @@
el('div', {class:'card'}, [ el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Pin MAC to boot target')), el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
el('div', {class:'body'}, [ el('div', {class:'body'}, [
el('div', {class:'form-row'}, [ el('div', {class:'form-row cols-3'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]), el('label', {class:'field'}, [
el('span', {class:'name'}, 'MAC address or prefix'),
macInput,
el('span', {class:'hint'}, 'Full MAC pins one machine; a prefix (OUI) makes a group rule.'),
]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]), el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Architecture (optional)'),
archSel,
el('span', {class:'hint'}, 'Selecting one makes a group rule for that firmware.'),
]),
el('label', {class:'field', style:'grid-column:1 / -1'}, [ el('label', {class:'field', style:'grid-column:1 / -1'}, [
el('span', {class:'name'}, 'Target'), el('span', {class:'name'}, 'Target'),
targetSel, targetSel,
el('span', {class:'hint'}, el('span', {class:'hint'},
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'), 'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
]), ]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Boot binary (optional)'),
binSel,
el('span', {class:'hint'}, 'Pin Secure Boot racks to "shim" — zero failed boot cycles.'),
]),
]), ]),
el('div', {style:'margin-top:16px'}, profileFields.wrap), el('div', {style:'margin-top:16px'}, profileFields.wrap),
upsertBtn, msg, upsertBtn, msg,
el('p', {class:'msg', style:'margin-top:14px'}, el('p', {class:'msg', style:'margin-top:14px'},
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' + 'When a matching client requests boot.ipxe, OpenPXE short-circuits ' +
'short-circuits past the interactive menu and chains directly. ' + 'past the interactive menu and chains directly. Decision order: ' +
'If an unattended file is selected, the matching kernel argument ' + 'exact MAC pin → first matching group rule → menu. ' +
'is injected and the hostname/IP are templated into the answer file.'), 'If an unattended file is selected on a pin, the matching kernel ' +
'argument is injected and the hostname/IP are templated into the answer file.'),
]), ]),
]), ]),
el('div', {class:'card'}, [ el('div', {class:'card'}, [
@@ -1489,7 +1517,7 @@
]), ]),
table, table,
]), ]),
bootRulesCard(rulesCfg, reserved.concat(targets)), groupRulesCard(rulesCfg, reserved.concat(targets)),
el('div', {class:'card'}, [ el('div', {class:'card'}, [
el('header', {}, [ el('header', {}, [
el('h2', {}, 'Host log'), el('h2', {}, 'Host log'),
@@ -2166,9 +2194,24 @@
el('div', {class:'about-hero'}, [ el('div', {class:'about-hero'}, [
el('h2', {}, 'OpenPXE'), el('h2', {}, 'OpenPXE'),
el('p', {class:'lead'}, el('p', {class:'lead'},
'Air-gapped network PXE boot, container-native, that anyone can run. ' + 'The network-boot platform for modern infrastructure. Drop in an ISO ' +
'No CDN calls, no telemetry, no surprise external dependencies — ship ' + 'and every machine on your network — BIOS, UEFI, Secure Boot — can ' +
'the image once, run it forever.'), 'boot it, image from it, and install unattended. One container, one ' +
'static binary, nothing installed on clients, nothing leaving your network.'),
el('div', {style:'display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:18px 0'}, [
['Boot anything', 'Linux, Windows, hypervisors, rescue tools — uploaded ' +
'ISOs become menu entries automatically, served on demand from local ' +
'disk or your existing NFS, SMB, or SFTP libraries.'],
['Adapt to every machine', 'Per-machine boot intelligence: firmware quirks, ' +
'NIC driver fallback, and a Microsoft-signed Secure Boot chain are ' +
'negotiated automatically and remembered — no toggles, no client prep.'],
['Run it in production', 'SAML single sign-on, token-scoped answer files, ' +
'fleet routing rules, Wake-on-LAN, queued mass deployment, Prometheus ' +
'metrics. Built in Rust for boot infrastructure that cannot flinch.'],
].map(([h, body]) => el('div', {}, [
el('h3', {style:'margin:0 0 6px;font-size:13.5px'}, h),
el('p', {class:'msg', style:'font-size:12px;margin:0'}, body),
]))),
el('div', {class:'who'}, [ el('div', {class:'who'}, [
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'), el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'), el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
@@ -2179,15 +2222,16 @@
]), ]),
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]), el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
el('p', {class:'msg', style:'margin-top:18px'}, el('p', {class:'msg', style:'margin-top:18px'},
'iPXE is an internal implementation detail. Everything the firmware ' + 'Private by design: no telemetry, no CDN calls, no runtime ' +
'executes is generated from the settings on these tabs — there is no ' + 'dependencies on the outside world. Air-gapped labs, customer sites ' +
'hand-written .ipxe path anywhere in this product.'), 'without internet, and locked-down OpenShift clusters run the same ' +
'image, the same way, indefinitely.'),
el('p', {class:'msg'}, el('p', {class:'msg'},
'Vision: a deployment-grade tool that works on first try in the most ' + 'Principled by default: OpenPXE never asks an operator to install ' +
'awkward environments — air-gapped labs, customer sites without ' + 'test-signed drivers, modify a clients trust store, or weaken ' +
'internet, OpenShift clusters with strict SCCs — without ever asking ' + 'Secure Boot. Everything the firmware executes is generated from the ' +
'an operator to install drivers signed with test certificates or to ' + 'settings on these tabs — there are no hand-written boot scripts to ' +
'flip "testsigning" on a target machine.'), 'maintain and no internals to learn.'),
]), ]),
]); ]);