From a71057fce6e619f01dd778b78e3e48cbe719548e Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Tue, 9 Jun 2026 21:47:48 -0400 Subject: [PATCH] =?UTF-8?q?v0.7.2:=20UI=20polish=20=E2=80=94=20list=20sear?= =?UTF-8?q?ch,=20unified=20Hosts=20form,=20NIC=20link=20details,=20About?= =?UTF-8?q?=20refresh,=20spacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Cargo.lock | 16 +- Cargo.toml | 2 +- crates/http-api/src/app.rs | 1 + crates/http-api/src/state.rs | 4 + crates/http-api/tests/full_flow.rs | 1 + crates/openpxe/src/main.rs | 42 ++++ crates/webui/src/app.css | 11 ++ crates/webui/src/app.js | 304 +++++++++++++++++------------ 8 files changed, 242 insertions(+), 139 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ae7c9ab..0f8ce95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2836,7 +2836,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openpxe" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "axum", @@ -2858,7 +2858,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "base64", @@ -2885,7 +2885,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "bytes", @@ -2902,7 +2902,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "axum", @@ -2938,7 +2938,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.7.1" +version = "0.7.2" dependencies = [ "openpxe-core", "rust-embed", @@ -2948,7 +2948,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "bcrypt", @@ -2977,7 +2977,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "bytes", @@ -2991,7 +2991,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.7.1" +version = "0.7.2" [[package]] name = "p256" diff --git a/Cargo.toml b/Cargo.toml index 7f206df..e054cf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.7.1" +version = "0.7.2" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index 4e7cd7f..b132fab 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -2744,6 +2744,7 @@ async fn api_network(State(state): State) -> Json { .strip_prefix("http://") .unwrap_or(&state.public_base_url), "nic_name": state.nic_name, + "nic_link": state.nic_link, "subnet_mask": state.subnet_mask, "gateway": state.gateway, "dns_server": state.settings.snapshot().dns_server, diff --git a/crates/http-api/src/state.rs b/crates/http-api/src/state.rs index 3f4661a..ed37349 100644 --- a/crates/http-api/src/state.rs +++ b/crates/http-api/src/state.rs @@ -116,6 +116,10 @@ pub struct AppState { /// `enp1s0`). Surfaced read-only on the Network tab. Empty if the /// interface couldn't be identified. 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. pub subnet_mask: String, /// Default gateway IPv4 address. diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index e0d7c58..5fe9f89 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -135,6 +135,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) { started_at: time::OffsetDateTime::now_utc(), public_base_url: "http://127.0.0.1".into(), nic_name: "lo".into(), + nic_link: String::new(), subnet_mask: "255.0.0.0".into(), gateway: "127.0.0.1".into(), }; diff --git a/crates/openpxe/src/main.rs b/crates/openpxe/src/main.rs index 4347a9f..368258c 100644 --- a/crates/openpxe/src/main.rs +++ b/crates/openpxe/src/main.rs @@ -215,6 +215,7 @@ async fn main() -> anyhow::Result<()> { started_at: time::OffsetDateTime::now_utc(), public_base_url: public_base_url.clone(), nic_name: net.nic_name, + nic_link: net.nic_link, subnet_mask: net.subnet_mask, gateway: net.gateway, }; @@ -448,6 +449,12 @@ struct NetworkInfo { nic_name: String, subnet_mask: 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 @@ -508,9 +515,44 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo { } } + info.nic_link = detect_link_info(&info.nic_name); + 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 = 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 { let prefix = prefix.min(32); let mask: u32 = if prefix == 0 { diff --git a/crates/webui/src/app.css b/crates/webui/src/app.css index fb85d98..1346cfe 100644 --- a/crates/webui/src/app.css +++ b/crates/webui/src/app.css @@ -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; } .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%; } diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index c758577..d6bfa17 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -199,109 +199,47 @@ })[k] || (k || 'Unknown'); } - // v0.7.0: the Boot rules card — ordered first-match-wins rules - // (MAC prefix / architecture → target) plus the optional - // boot-decision webhook. Saved as one config because rule order - // matters. With no rules and no webhook, behavior is identical to - // before the feature existed. - function bootRulesCard(cfg, targetOptions) { - const archChoices = [ - ['', 'any arch'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'], - ['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'], - ]; - // v0.7.1: optional first-boot binary pin. "Auto" lets the - // 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(); }}, '✕')), - ])); - }); + // v0.7.2: compact read-out of saved group rules — created from the + // unified "Pin MAC" form on the Hosts tab (a prefix or an architecture + // there saves a rule instead of a pin). First match wins, top to + // bottom. The boot-decision webhook remains available via the API + // (/api/boot-rules `webhook_url`) but no longer has a UI knob. + function groupRulesCard(cfg, targetOptions) { + const rules = (cfg && cfg.rules) || []; + if (!rules.length) return null; + const titleFor = id => { + const t = targetOptions.find(x => x.id === id); + return t ? t.title : id; }; - redraw(); - - const addBtn = el('button', {class:'ghost', onclick: () => { - rules.push({mac_prefix:'', arch:'', target:'', enabled:true, note:''}); - redraw(); - }}, '+ Add rule'); - const saveBtn = el('button', {onclick: async () => { - // A rule needs at least one effect: a target or a boot-binary pin. - const bad = rules.find(r => r.enabled !== false && !r.target && !r.driver_mode); - if (bad) { msg.textContent = 'Every enabled rule needs a target or a boot-binary pin.'; msg.className = 'msg err'; return; } - const r = await putJSON('/api/boot-rules', {rules, webhook_url: webhookInput.value.trim()}); - if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; } - else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; } - }}, 'Save rules'); - + 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'} : {}, [ + el('td', {class:'mono'}, r.mac_prefix || el('span', {class:'tag'}, 'any MAC')), + el('td', {}, r.arch || el('span', {class:'tag'}, 'any arch')), + el('td', {}, r.target ? titleFor(r.target) : el('span', {class:'tag'}, '—')), + el('td', {}, r.driver_mode + ? el('span', {class:'tag accent'}, modeLabel[r.driver_mode] || r.driver_mode) + : el('span', {class:'tag'}, 'auto')), + el('td', {}, r.note || ''), + el('td', {style:'text-align:right'}, + el('button', {class:'danger', onclick: async () => { + if (!confirm('Remove this group rule?')) return; + const fresh = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''})); + (fresh.rules = fresh.rules || []).splice(i, 1); + await putJSON('/api/boot-rules', fresh); + render('hosts'); + }}, 'Remove')), + ])); return el('div', {class:'card'}, [ el('header', {}, [ - el('h2', {}, 'Boot rules'), + el('h2', {}, 'Group rules'), el('span', {class:'sub'}, 'first match wins · checked top to bottom'), ]), - el('div', {class:'body'}, [ - el('table', {}, [ - el('thead', {}, el('tr', {}, [ - el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'), - el('th',{},'Boot binary'), el('th',{},'Note'), el('th',{},'On'), el('th',{},''), - ])), - tbody, - ]), - 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": ""} 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.'), + el('table', {}, [ + el('thead', {}, el('tr', {}, [ + el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'), + el('th',{},'Boot binary'), el('th',{},'Note'), el('th',{},''), + ])), + el('tbody', {}, rows), ]), ]); } @@ -501,6 +439,11 @@ el('div', {class:'v'}, net.server_ip || '?'), el('div', {class:'k'}, 'NIC name'), 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:'v'}, net.subnet_mask || '?'), el('div', {class:'k'}, 'Gateway'), @@ -896,6 +839,12 @@ 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'}, [ el('td', {}, [ el('div', {style:'display:flex;align-items:center;gap:8px'}, [ @@ -940,8 +889,24 @@ }}, 'Remove'), ]), ]); + tr.dataset.search = searchText; 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 ? el('table', {}, [ el('thead', {}, el('tr', {}, [ @@ -1279,7 +1244,10 @@ unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); }; 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('div', {}, [ el('div', {class:'id'}, [ @@ -1307,6 +1275,17 @@ ]), el('div', {class:'body'}, [ 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('p', {class:'msg', style:'margin-top:14px'}, 'These answer files drive unattended installs. Attach one to a ' + @@ -1353,6 +1332,7 @@ el('h2', {}, 'Available images'), el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')), ]), + isos.length > 1 ? el('div', {class:'list-search'}, isoSearch) : null, isoTable, ]), ]), unattendedAdvanced]); @@ -1378,8 +1358,22 @@ {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"'}); + // 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', {}, [el('option', {value:''}, '— choose a target —')] .concat(reserved.map(t => el('option', {value: t.id}, t.title))) @@ -1392,21 +1386,40 @@ // hostname/IP templated into the served answer file. 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 () => { - if (!macInput.value || !targetSel.value) { - msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return; + 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; + } + const r = await postJSON('/api/hosts', Object.assign({ + mac, target: targetSel.value, label: labelInput.value, + }, profileFields.read())); + if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; render('hosts'); } + else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; } + return; } - const r = await postJSON('/api/hosts', Object.assign({ - mac: macInput.value, target: targetSel.value, label: labelInput.value, - }, profileFields.read())); - 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'; + // 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; } - }}, 'Bind MAC to target'); + 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 => { // v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole @@ -1463,23 +1476,38 @@ 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('div', {class:'form-row cols-3'}, [ + 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'}, '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('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.'), ]), + 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), upsertBtn, msg, el('p', {class:'msg', style:'margin-top:14px'}, - 'When a client with a bound MAC requests boot.ipxe, OpenPXE ' + - 'short-circuits past the interactive menu and chains directly. ' + - 'If an unattended file is selected, the matching kernel argument ' + - 'is injected and the hostname/IP are templated into the answer file.'), + 'When a matching client requests boot.ipxe, OpenPXE short-circuits ' + + 'past the interactive menu and chains directly. Decision order: ' + + 'exact MAC pin → first matching group rule → menu. ' + + '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'}, [ @@ -1489,7 +1517,7 @@ ]), table, ]), - bootRulesCard(rulesCfg, reserved.concat(targets)), + groupRulesCard(rulesCfg, reserved.concat(targets)), el('div', {class:'card'}, [ el('header', {}, [ el('h2', {}, 'Host log'), @@ -2166,9 +2194,24 @@ el('div', {class:'about-hero'}, [ 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 ' + - 'the image once, run it forever.'), + 'The network-boot platform for modern infrastructure. Drop in an ISO ' + + 'and every machine on your network — BIOS, UEFI, Secure Boot — can ' + + '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('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'), el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'), @@ -2179,15 +2222,16 @@ ]), el('div', {style:'margin-top:18px'}, [updBtn, updMsg]), el('p', {class:'msg', style:'margin-top:18px'}, - 'iPXE is an internal implementation detail. Everything the firmware ' + - 'executes is generated from the settings on these tabs — there is no ' + - 'hand-written .ipxe path anywhere in this product.'), + 'Private by design: no telemetry, no CDN calls, no runtime ' + + 'dependencies on the outside world. Air-gapped labs, customer sites ' + + 'without internet, and locked-down OpenShift clusters run the same ' + + 'image, the same way, indefinitely.'), el('p', {class:'msg'}, - 'Vision: a deployment-grade tool that works on first try in the most ' + - 'awkward environments — air-gapped labs, customer sites without ' + - 'internet, OpenShift clusters with strict SCCs — without ever asking ' + - 'an operator to install drivers signed with test certificates or to ' + - 'flip "testsigning" on a target machine.'), + 'Principled by default: OpenPXE never asks an operator to install ' + + 'test-signed drivers, modify a client’s trust store, or weaken ' + + 'Secure Boot. Everything the firmware executes is generated from the ' + + 'settings on these tabs — there are no hand-written boot scripts to ' + + 'maintain and no internals to learn.'), ]), ]);