v0.7.3: UI cleanup — aligned Hosts pin form, native-chrome list filters, Link row reorder

Design-language cleanup of the v0.7.2 additions (no behaviour change).

Hosts:
- The Pin MAC form collapses to a single aligned 4-up row: MAC ·
  Label · Architecture · Boot binary. Target drops full-width onto its
  own line beneath them. The per-field hints that broke the row's
  alignment moved into the explanatory note below, so every control
  shares one baseline.

Storage:
- The image and unattended filter inputs are now wrapped in a
  label.field, so they inherit the standard text-field chrome (border,
  radius, height, focus ring) instead of the raw browser
  <input type=search> look. They span the full card width for
  continuity with the rest of the page.

Network:
- The 'Link' row moved below 'Public base URL'. Its joined
  operstate · speed · duplex · MAC string runs long, so placing it last
  lets it wrap at the bottom without shoving the other rows around.

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]>
This commit is contained in:
Miles Ward
2026-06-09 22:44:19 -04:00
co-authored by Claude Opus 4.8
parent a71057fce6
commit 934cfbab46
4 changed files with 49 additions and 50 deletions
Generated
+8 -8
View File
@@ -2836,7 +2836,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "openpxe" name = "openpxe"
version = "0.7.2" version = "0.7.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -2858,7 +2858,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-core" name = "openpxe-core"
version = "0.7.2" version = "0.7.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -2885,7 +2885,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-dhcp-proxy" name = "openpxe-dhcp-proxy"
version = "0.7.2" version = "0.7.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2902,7 +2902,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-http-api" name = "openpxe-http-api"
version = "0.7.2" version = "0.7.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -2938,7 +2938,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-ipxe-assets" name = "openpxe-ipxe-assets"
version = "0.7.2" version = "0.7.3"
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.2" version = "0.7.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bcrypt", "bcrypt",
@@ -2977,7 +2977,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-tftp" name = "openpxe-tftp"
version = "0.7.2" version = "0.7.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2991,7 +2991,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-webui" name = "openpxe-webui"
version = "0.7.2" version = "0.7.3"
[[package]] [[package]]
name = "p256" name = "p256"
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.7.2" version = "0.7.3"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+5 -3
View File
@@ -920,6 +920,8 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
the same 16px below its form. */ the same 16px below its form. */
.card .body > label.field:has(+ button) { margin-bottom: 0; } .card .body > label.field:has(+ button) { margin-bottom: 0; }
/* v0.7.2: inline list filter (Available images / Unattended files). */ /* v0.7.2: inline list filter above a table (Available images). The input
.list-search { padding: 14px 16px 0; } is wrapped in a label.field so it borrows the standard text-field chrome
.list-search input { width: 100%; } and matches every other input in the app; this wrapper just insets it
from the card edges so it lines up with the header text above. */
.list-search { padding: 14px 16px; }
+35 -38
View File
@@ -439,17 +439,18 @@
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'),
el('div', {class:'v'}, net.gateway || '?'), el('div', {class:'v'}, net.gateway || '?'),
el('div', {class:'k'}, 'Public base URL'), el('div', {class:'k'}, 'Public base URL'),
el('div', {class:'v'}, net.public_base_url), el('div', {class:'v'}, net.public_base_url),
// 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. Kept last — the joined
// value runs long, so it wraps cleanly at the bottom of the list.
el('div', {class:'k'}, 'Link'),
el('div', {class:'v'}, net.nic_link || '—'),
]), ]),
el('p', {class:'msg'}, el('p', {class:'msg'},
'Server IP, NIC, mask, and gateway are auto-detected at startup. ' + 'Server IP, NIC, mask, and gateway are auto-detected at startup. ' +
@@ -896,7 +897,7 @@
// v0.7.2: client-side filter over the image table. Rows travel in // v0.7.2: client-side filter over the image table. Rows travel in
// (row, password-editor) pairs; filtering hides both, and an open // (row, password-editor) pairs; filtering hides both, and an open
// editor stays closed for filtered-out rows. // editor stays closed for filtered-out rows.
const isoSearch = el('input', {type:'search', placeholder:'Filter images… (name, family, category, source)', const isoSearch = el('input', {type:'search', placeholder:'Filter images by name, type, or source',
spellcheck:'false', oninput: () => { spellcheck:'false', oninput: () => {
const q = isoSearch.value.trim().toLowerCase(); const q = isoSearch.value.trim().toLowerCase();
for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) { for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) {
@@ -1277,14 +1278,14 @@
unattDrop, unattFile, unattMsg, unattDrop, unattFile, unattMsg,
// v0.7.2: filter for big answer-file libraries. // v0.7.2: filter for big answer-file libraries.
unattendedFiles.length > 1 ? (() => { unattendedFiles.length > 1 ? (() => {
const search = el('input', {type:'search', placeholder:'Filter files… (name, kind)', const search = el('input', {type:'search', placeholder:'Filter files by name or kind',
spellcheck:'false', style:'margin-top:14px', oninput: () => { spellcheck:'false', oninput: () => {
const q = search.value.trim().toLowerCase(); const q = search.value.trim().toLowerCase();
unattRows.forEach(r => { unattRows.forEach(r => {
r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none'; r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none';
}); });
}}); }});
return search; return el('label', {class:'field', style:'margin-top:14px;margin-bottom:0'}, search);
})() : null, })() : 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'},
@@ -1332,7 +1333,9 @@
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, isos.length > 1
? el('div', {class:'list-search'}, el('label', {class:'field', style:'margin-bottom:0'}, isoSearch))
: null,
isoTable, isoTable,
]), ]),
]), unattendedAdvanced]); ]), unattendedAdvanced]);
@@ -1358,7 +1361,7 @@
{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 or prefix aa:bb:cc', spellcheck:'false'}); const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff or 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 // v0.7.2: the former separate "Boot rules" card folded into this
// form. A full MAC with no architecture saves a per-host pin // form. A full MAC with no architecture saves a per-host pin
@@ -1476,38 +1479,32 @@
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 cols-3'}, [ // v0.7.3: MAC · Label · Architecture · Boot binary share one
el('label', {class:'field'}, [ // 4-up row so the controls line up across the page; the
el('span', {class:'name'}, 'MAC address or prefix'), // per-field guidance that used to sit under them moved into the
macInput, // note below to keep the inputs flush. Target spans full width
el('span', {class:'hint'}, 'Full MAC pins one machine; a prefix (OUI) makes a group rule.'), // on its own line beneath them.
]), el('div', {class:'form-row'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address or prefix'), macInput]),
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('label', {class:'field'}, [el('span', {class:'name'}, 'Architecture (optional)'), archSel]),
el('span', {class:'name'}, 'Architecture (optional)'), el('label', {class:'field'}, [el('span', {class:'name'}, 'Boot binary (optional)'), binSel]),
archSel, ]),
el('span', {class:'hint'}, 'Selecting one makes a group rule for that firmware.'), el('label', {class:'field', style:'margin-top:14px'}, [
]), el('span', {class:'name'}, 'Target'),
el('label', {class:'field', style:'grid-column:1 / -1'}, [ targetSel,
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), 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 matching client requests boot.ipxe, OpenPXE short-circuits ' + 'A full MAC pins one machine; a MAC prefix (OUI) or an architecture ' +
'past the interactive menu and chains directly. Decision order: ' + 'saves a first-match group rule. Pin the boot binary to “shim” for ' +
'exact MAC pin → first matching group rule → menu. ' + 'Secure Boot racks — zero failed boot cycles. When a matching client ' +
'If an unattended file is selected on a pin, the matching kernel ' + 'requests boot.ipxe, OpenPXE short-circuits past the interactive menu ' +
'argument is injected and the hostname/IP are templated into the answer file.'), 'and chains directly; decision order is 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'}, [ el('div', {class:'card'}, [