v0.4.4: Settings tab, API reference, ISO category, branding, disk space
Settings:
- New top-level Settings tab. Carries a placeholder for the planned
LDAP / OIDC / user-management work, the new branding controls, and
the API reference at the bottom.
- Custom logo upload (PNG/SVG/JPEG/WebP/GIF up to 2 MB) replaces the
bundled brand mark via /assets/logo.svg; bytes live at
<work_dir>/branding/ and survive restart. The original "OpenPXE
v<x.y.z>" pins to the sidebar footer for support.
- API reference rendered from a new GET /api/docs into a per-method
coloured pill list grouped by area.
ISO category (Storage):
- New IsoCategory { Os, Tools } on IsoMeta with PUT
/api/isos/:id/category. Storage table's Type cell becomes a
dropdown; selecting Tools moves the ISO into the Tools submenu next
to memtest / shell / NIC info and removes it from the OS Installers
family submenu. Family detection still drives BIOS/UEFI / kernel
args; only the menu placement changes.
Storage telemetry:
- New IsoStore::disk_usage (libc::statvfs, lives in iso-store so the
http-api crate stays #![forbid(unsafe_code)]) and GET
/api/storage/disk. The Storage tab now shows free/used/total for
the volume hosting the ISO directory with an 80%/95% colour ramp.
UI polish:
- Brand block in the sidebar now matches the topbar height exactly,
so the divider runs straight across the top of the app rather than
stepping; version label moved out of the brand and pinned to the
sidebar footer ("OpenPXE v0.4.4").
- Light-mode terminal: --terminal-bg + per-level text colours track
the active theme rather than being hard-coded dark.
- About: lead paragraph spans the full content width; new Docs row
links to https://openpxe.com/.
106 tests passing (was 89 in v0.4.1, +17 across branding unit tests
and new integration coverage for category / disk / docs / branding).
cargo clippy --workspace --all-targets clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a171331a7a
commit
7b972dc049
+215
-3
@@ -321,8 +321,11 @@
|
||||
},
|
||||
|
||||
storage: async () => {
|
||||
const [isos, settings, nfsRes] = await Promise.all([
|
||||
const [isos, settings, nfsRes, disk] = await Promise.all([
|
||||
getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'),
|
||||
getJSON('/api/storage/disk').catch(() => ({
|
||||
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
|
||||
})),
|
||||
]);
|
||||
const mounts = nfsRes.mounts || [];
|
||||
|
||||
@@ -511,6 +514,30 @@
|
||||
const editorRow = el('tr', {style:'display:none'}, editorCells);
|
||||
refreshFieldVisibility();
|
||||
|
||||
// Type cell becomes an OS/Tools <select>. Default is OS (everything
|
||||
// routes through the family-detected installer submenu); switching
|
||||
// to Tools moves the ISO into the Tools submenu next to memtest.
|
||||
// The detected family stays visible as a small subtitle below the
|
||||
// selector so the operator hasn't lost that information.
|
||||
const catSel = el('select', {
|
||||
style: 'min-width:96px;background:var(--bg);color:var(--fg);' +
|
||||
'border:1px solid var(--border);border-radius:var(--radius);' +
|
||||
'padding:4px 8px;font:inherit;font-size:12px'
|
||||
}, [
|
||||
el('option', {value: 'os'}, 'OS'),
|
||||
el('option', {value: 'tools'}, 'Tools'),
|
||||
]);
|
||||
catSel.value = (i.category || 'os');
|
||||
catSel.onchange = async () => {
|
||||
const r = await putJSON('/api/isos/' + encodeURIComponent(i.id) + '/category',
|
||||
{ category: catSel.value });
|
||||
if (!r.ok) {
|
||||
const t = await r.text();
|
||||
alert('Category change failed: ' + t);
|
||||
}
|
||||
render('storage');
|
||||
};
|
||||
|
||||
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
|
||||
el('td', {}, [
|
||||
el('div', {style:'display:flex;align-items:center;gap:8px'}, [
|
||||
@@ -523,7 +550,11 @@
|
||||
!b.ok ? el('div', {class:'row-warn'}, '⚠ ' + b.reason)
|
||||
: (b.warn ? el('div', {class:'row-warn'}, '⚠ ' + b.warn) : null),
|
||||
]),
|
||||
el('td', {}, el('span', {class:'tag'}, familyLabel(i.introspection.family))),
|
||||
el('td', {}, [
|
||||
catSel,
|
||||
el('div', {style:'color:var(--fg-dim);font-size:11px;margin-top:4px'},
|
||||
familyLabel(i.introspection.family)),
|
||||
]),
|
||||
el('td', {class:'num'}, fmtBytes(i.size_bytes)),
|
||||
el('td', {},
|
||||
el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')},
|
||||
@@ -609,7 +640,48 @@
|
||||
el('span'),
|
||||
])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')];
|
||||
|
||||
// Disk-space card. Free + used + total for the volume hosting the
|
||||
// ISO directory, with a coloured bar. Warns at 80% and goes red at
|
||||
// 95% so the operator sees the runway shrinking before uploads
|
||||
// start failing with ENOSPC.
|
||||
const total = Number(disk.total_bytes || 0);
|
||||
const avail = Number(disk.available_bytes || 0);
|
||||
const used = Number(disk.used_bytes || 0);
|
||||
const pctUsed = total > 0 ? (used / total) * 100 : 0;
|
||||
let barClass = 'diskbar';
|
||||
if (pctUsed >= 95) barClass += ' full';
|
||||
else if (pctUsed >= 80) barClass += ' warn';
|
||||
const diskCard = el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Disk space'),
|
||||
el('span', {class:'sub'},
|
||||
total > 0
|
||||
? (pctUsed.toFixed(1) + '% used')
|
||||
: 'unavailable'),
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {style:'color:var(--fg-dim);font-size:12px;word-break:break-all'},
|
||||
disk.path ? ('Volume: ' + disk.path) : 'Volume path unknown'),
|
||||
el('div', {class: barClass},
|
||||
el('div', {class:'fill',
|
||||
style:'width:' + Math.min(100, pctUsed).toFixed(1) + '%'})),
|
||||
el('div', {class:'disk-meta'}, [
|
||||
el('span', {}, ['Used ', el('strong', {}, fmtBytes(used))]),
|
||||
el('span', {}, ['Free ', el('strong', {}, fmtBytes(avail))]),
|
||||
el('span', {}, ['Total ', el('strong', {}, fmtBytes(total))]),
|
||||
]),
|
||||
pctUsed >= 95
|
||||
? el('p', {class:'msg err', style:'margin-top:10px'},
|
||||
'⚠ Less than 5% free. Remove old ISOs or grow the volume before uploading more.')
|
||||
: (pctUsed >= 80
|
||||
? el('p', {class:'msg', style:'color:var(--warn);margin-top:10px'},
|
||||
'Volume is getting full. Consider pruning old ISOs.')
|
||||
: null),
|
||||
]),
|
||||
]);
|
||||
|
||||
return el('div', {class:'grid'}, [
|
||||
diskCard,
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Upload ISO')),
|
||||
el('div', {class:'body'}, [drop, file, prog, upMsg]),
|
||||
@@ -908,6 +980,142 @@
|
||||
return term;
|
||||
},
|
||||
|
||||
settings: async () => {
|
||||
const [status, docs] = await Promise.all([
|
||||
getJSON('/api/status'),
|
||||
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
||||
]);
|
||||
const hasLogo = !!status.custom_logo;
|
||||
|
||||
// ── Identity & access placeholder.
|
||||
// We haven't shipped auth yet — but operators looking at this tab
|
||||
// need to see *where* it'll live so they're not surprised when an
|
||||
// upgrade lights up real config controls under the same heading.
|
||||
const accessCard = el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Identity & access')),
|
||||
el('div', {class:'body'}, [
|
||||
el('p', {class:'msg'},
|
||||
'LDAP, SSO (OIDC), and operator user management are planned ' +
|
||||
'for a future release. Today, OpenPXE assumes a single trusted ' +
|
||||
'operator on a flat L2 network and provides no built-in ' +
|
||||
'authentication on the WebUI or HTTP API. Run behind your ' +
|
||||
'identity-aware reverse proxy (Authentik, Authelia, oauth2-proxy) ' +
|
||||
'for a hardened deployment until first-class support lands here.'),
|
||||
el('div', {class:'form-row', style:'opacity:.55;pointer-events:none'}, [
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'LDAP server URL'),
|
||||
el('input', {type:'text', placeholder:'ldaps://dc.example.com:636', disabled:'disabled'}),
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'OIDC issuer'),
|
||||
el('input', {type:'text', placeholder:'https://idp.example.com/realms/openpxe', disabled:'disabled'}),
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'User management'),
|
||||
el('input', {type:'text', placeholder:'configurable in a future release', disabled:'disabled'}),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
// ── Custom logo upload.
|
||||
// Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB.
|
||||
// Persisted as <work_dir>/branding/logo.<ext> and served from
|
||||
// /assets/logo.svg in preference to the bundled mark.
|
||||
const logoFile = el('input', {
|
||||
type:'file',
|
||||
accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif',
|
||||
style:'display:none', id:'logo-file',
|
||||
});
|
||||
const logoMsg = el('div', {class:'msg', style:'margin-top:10px'});
|
||||
const logoBust = '?v=' + Date.now(); // bust the browser cache after upload
|
||||
logoFile.onchange = async () => {
|
||||
if (!logoFile.files[0]) return;
|
||||
const f = logoFile.files[0];
|
||||
const fd = new FormData(); fd.append('file', f, f.name);
|
||||
logoMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…';
|
||||
logoMsg.className = 'msg';
|
||||
const r = await fetch('/api/branding/logo', {method:'POST', body: fd});
|
||||
if (r.ok) {
|
||||
logoMsg.textContent = 'Custom logo installed. Reloading…';
|
||||
logoMsg.className = 'msg ok';
|
||||
setTimeout(() => location.reload(), 600);
|
||||
} else {
|
||||
const t = await r.text();
|
||||
logoMsg.textContent = 'Upload failed: ' + t;
|
||||
logoMsg.className = 'msg err';
|
||||
}
|
||||
};
|
||||
const logoCard = el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Branding')),
|
||||
el('div', {class:'body'}, [
|
||||
el('p', {class:'msg', style:'margin-bottom:14px'},
|
||||
'Override the top-left brand mark with your own logo. Up to 2 MB; ' +
|
||||
'PNG, SVG, JPEG, WebP, or GIF. The original OpenPXE version is ' +
|
||||
'always shown in the bottom-left for support purposes.'),
|
||||
el('div', {class:'logo-preview'}, [
|
||||
el('div', {class:'swatch'},
|
||||
el('img', {src: '/assets/logo.svg' + logoBust, alt:'current logo'})),
|
||||
el('div', {class:'info'}, [
|
||||
el('div', {class:'name'}, hasLogo ? 'Custom logo (uploaded)' : 'Default OpenPXE mark'),
|
||||
el('div', {class:'meta'},
|
||||
hasLogo
|
||||
? 'Operator-uploaded; served from <work_dir>/branding/.'
|
||||
: 'Bundled rainbow-horizon mark. Upload an image to override.'),
|
||||
]),
|
||||
el('div', {style:'display:flex;gap:8px;flex-wrap:wrap'}, [
|
||||
el('button', {onclick: () => logoFile.click()},
|
||||
hasLogo ? 'Replace logo' : 'Upload logo'),
|
||||
hasLogo
|
||||
? el('button', {class:'danger', onclick: async () => {
|
||||
if (!confirm('Remove custom logo and revert to the OpenPXE mark?')) return;
|
||||
const r = await fetch('/api/branding/logo', {method:'DELETE'});
|
||||
if (r.ok || r.status === 204) {
|
||||
logoMsg.textContent = 'Reverted to default mark. Reloading…';
|
||||
logoMsg.className = 'msg ok';
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} else {
|
||||
const t = await r.text();
|
||||
logoMsg.textContent = 'Clear failed: ' + t;
|
||||
logoMsg.className = 'msg err';
|
||||
}
|
||||
}}, 'Remove')
|
||||
: null,
|
||||
]),
|
||||
]),
|
||||
logoFile, logoMsg,
|
||||
]),
|
||||
]);
|
||||
|
||||
// ── API reference (always at the bottom of Settings).
|
||||
// Sourced from /api/docs so the hand-curated list stays the
|
||||
// single source of truth and the UI doesn't need its own copy
|
||||
// baked into the JS bundle.
|
||||
const groups = docs.groups || [];
|
||||
const apiCard = el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'API reference'),
|
||||
el('span', {class:'sub'},
|
||||
groups.reduce((n, g) => n + (g.endpoints || []).length, 0) + ' endpoints'),
|
||||
]),
|
||||
el('div', {class:'api-ref'},
|
||||
groups.length
|
||||
? groups.map(g => el('div', {class:'group'}, [
|
||||
el('h3', {}, g.name),
|
||||
...(g.endpoints || []).map(ep => el('div', {class:'ep'}, [
|
||||
el('span', {class:'method ' + (ep.method || 'get').toLowerCase()},
|
||||
ep.method || 'GET'),
|
||||
el('span', {class:'path'}, ep.path || '?'),
|
||||
el('span', {class:'desc'}, ep.summary || ''),
|
||||
])),
|
||||
]))
|
||||
: el('div', {class:'empty'},
|
||||
'No API documentation returned by /api/docs.')),
|
||||
]);
|
||||
|
||||
return el('div', {class:'grid'}, [accessCard, logoCard, apiCard]);
|
||||
},
|
||||
|
||||
about: async () => {
|
||||
const status = await getJSON('/api/status');
|
||||
return el('div', {class:'card'}, [
|
||||
@@ -920,7 +1128,10 @@
|
||||
el('div', {class:'who'}, [
|
||||
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
|
||||
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
|
||||
el('span', {}, 'Base URL: '), el('strong', {}, status.public_base_url),
|
||||
el('span', {}, 'Base URL: '), el('strong', {}, status.public_base_url), el('br'),
|
||||
el('span', {}, 'Docs: '),
|
||||
el('a', {href:'https://openpxe.com/', target:'_blank', rel:'noopener noreferrer'},
|
||||
'https://openpxe.com/'),
|
||||
]),
|
||||
el('p', {class:'msg', style:'margin-top:18px'},
|
||||
'iPXE is an internal implementation detail. Everything the firmware ' +
|
||||
@@ -945,6 +1156,7 @@
|
||||
storage: 'Storage',
|
||||
hosts: 'Hosts',
|
||||
terminal: 'Terminal',
|
||||
settings: 'Settings',
|
||||
about: 'About',
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user