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:
Miles Ward
2026-05-25 17:46:52 -04:00
co-authored by Claude Opus 4.7
parent a171331a7a
commit 7b972dc049
14 changed files with 1385 additions and 45 deletions
+333 -4
View File
@@ -29,9 +29,11 @@ use axum::{
routing::{delete, get, post, put},
Json, Router,
};
use openpxe_core::{BootEvent, ClientEvent, Error, Settings};
use openpxe_core::{
ext_for_mime, BootEvent, ClientEvent, Error, Settings, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
};
use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{IsoMeta, NfsAddRequest};
use openpxe_iso_store::{IsoCategory, IsoMeta, NfsAddRequest};
use serde::Deserialize;
use serde_json::json;
use std::net::SocketAddr;
@@ -73,6 +75,28 @@ pub fn build_router(state: AppState) -> Router {
"/api/isos/:id/password",
axum::routing::put(api_set_iso_password).delete(api_clear_iso_password),
)
// v0.4.4: per-ISO menu category (Os / Tools). Drives whether the
// image appears under Linux/Windows Installers (default) or in
// the Tools submenu next to memtest / shell / NIC info.
.route(
"/api/isos/:id/category",
axum::routing::put(api_set_iso_category),
)
// v0.4.4: filesystem free-space telemetry for the ISO directory's
// volume — surfaced as a small card on the Storage tab so the
// operator knows when they're about to run out of room.
.route("/api/storage/disk", get(api_storage_disk))
// v0.4.4: operator-controlled WebUI branding overrides
// (custom logo). Multipart upload to POST; DELETE clears.
.route(
"/api/branding/logo",
post(api_branding_upload).delete(api_branding_clear),
)
// v0.4.4: self-rendered API reference, served as JSON so the UI
// can format it consistently with the rest of the chrome. Lives
// under the Settings tab — operators chasing an integration get
// it in-product instead of having to fetch the OpenAPI YAML.
.route("/api/docs", get(api_docs))
.route("/api/clients", get(api_list_clients))
.route("/api/status", get(api_status))
.route("/api/settings", get(api_get_settings).put(api_put_settings))
@@ -143,7 +167,43 @@ async fn ui_css() -> Response {
.into_response()
}
async fn ui_logo() -> Response {
async fn ui_logo(State(state): State<AppState>) -> Response {
// Custom override first; fall back to the bundled rainbow-horizon
// SVG. We resolve the override on each request rather than caching
// because operators may upload/clear from the Settings tab while the
// server is live, and we want them to see their change immediately
// without bouncing the binary.
if let Some(path) = state.branding.logo_path() {
let mime = state
.branding
.logo_mime()
.unwrap_or_else(|| "image/svg+xml".to_string());
match tokio::fs::read(&path).await {
Ok(bytes) => {
let ct = match HeaderValue::from_str(&mime) {
Ok(v) => v,
Err(_) => HeaderValue::from_static("application/octet-stream"),
};
return (
[
(header::CONTENT_TYPE, ct),
(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache, max-age=0"),
),
],
bytes,
)
.into_response();
}
Err(e) => {
tracing::warn!(
target: "openpxe::http::branding",
error = %e, "failed to read custom logo; falling back to bundled"
);
}
}
}
(
[(
header::CONTENT_TYPE,
@@ -298,7 +358,7 @@ async fn boot_sub(
"_local" => render_local_hdd(base),
"_linux_menu" => render_family_menu(&isos, base, false),
"_windows_menu" => render_family_menu(&isos, base, true),
"_tools_menu" => render_tools_menu(base),
"_tools_menu" => render_tools_menu(&isos, base),
"_util" => render_util(base),
"_shell" => render_shell(base),
"_nic" => render_nic_info(base),
@@ -620,6 +680,274 @@ async fn api_clear_iso_password(
}
}
// ─── ISO category (OS / Tools) ────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct SetCategoryBody {
/// `"os"` or `"tools"` — matches `IsoCategory`'s snake_case serde
/// repr. Anything else returns 400 with the allowed set spelled out.
category: String,
}
async fn api_set_iso_category(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
Json(body): Json<SetCategoryBody>,
) -> Response {
let cat = match body.category.as_str() {
"os" => IsoCategory::Os,
"tools" => IsoCategory::Tools,
other => {
return (
StatusCode::BAD_REQUEST,
format!("unknown category '{other}'; expected one of: os, tools"),
)
.into_response();
}
};
match state.iso_store.set_category(&id, cat).await {
Ok(meta) => {
tracing::info!(
target: "openpxe::http::iso",
iso = %id, category = ?cat,
"iso category updated"
);
(StatusCode::OK, Json(meta)).into_response()
}
Err(Error::Invalid(msg)) => (StatusCode::NOT_FOUND, msg).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
// ─── Disk space (Storage tab) ─────────────────────────────────────────────
async fn api_storage_disk(State(state): State<AppState>) -> Json<serde_json::Value> {
// statvfs on the directory that holds the ISO store. We deliberately
// don't walk the directory ourselves — the kernel already tracks
// free/total at the volume level and that's the only number the
// operator actually cares about for "do I have room for one more
// 5 GB ISO?". `statvfs` itself lives in iso-store to keep the
// http-api crate free of `unsafe`.
let dir = state.iso_store.iso_dir();
let (total, available) = state.iso_store.disk_usage().unwrap_or((0, 0));
let used = total.saturating_sub(available);
Json(json!({
"path": dir.to_string_lossy(),
"total_bytes": total,
"available_bytes": available,
"used_bytes": used,
}))
}
// ─── Branding (custom logo) ───────────────────────────────────────────────
async fn api_branding_upload(
State(state): State<AppState>,
mut multipart: Multipart,
) -> Response {
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
if name != "file" && name != "logo" {
continue;
}
let mime = field.content_type().unwrap_or("").to_string();
if !ALLOWED_LOGO_MIMES.iter().any(|m| *m == mime) {
return (
StatusCode::BAD_REQUEST,
format!(
"unsupported MIME '{mime}'. Allowed: {}",
ALLOWED_LOGO_MIMES.join(", ")
),
)
.into_response();
}
// Pre-read into memory so we can enforce the size cap before
// hitting disk. Logos are tiny by definition.
let bytes = match field.bytes().await {
Ok(b) => b,
Err(e) => {
return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response()
}
};
if bytes.len() > MAX_LOGO_BYTES {
return (
StatusCode::PAYLOAD_TOO_LARGE,
format!(
"logo too large ({} bytes, max {})",
bytes.len(),
MAX_LOGO_BYTES
),
)
.into_response();
}
let Some(ext) = ext_for_mime(&mime) else {
return (StatusCode::BAD_REQUEST, "unsupported MIME").into_response();
};
match state.branding.set_logo(&mime, ext, &bytes) {
Ok(filename) => {
return (
StatusCode::OK,
Json(json!({
"filename": filename,
"mime": mime,
"size_bytes": bytes.len(),
})),
)
.into_response()
}
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
}
}
}
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
}
async fn api_branding_clear(State(state): State<AppState>) -> Response {
match state.branding.clear_logo() {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
// ─── API reference (Settings → bottom) ────────────────────────────────────
async fn api_docs() -> Json<serde_json::Value> {
// Hand-curated rather than introspected from axum because:
// 1. axum's runtime route table doesn't carry parameter docs;
// 2. the WebUI surfaces this as a readable list, not as an OpenAPI
// spec — readers are operators chasing an integration, not
// machines.
// Keep this in lockstep with `build_router` when adding endpoints.
Json(json!({
"version": env!("CARGO_PKG_VERSION"),
"groups": [
{
"name": "Status & health",
"endpoints": [
{"method": "GET", "path": "/healthz",
"summary": "Liveness — always 200 OK while the HTTP task is alive."},
{"method": "GET", "path": "/readyz",
"summary": "Readiness — 200 only when iPXE binaries are bundled and the ISO directory is readable."},
{"method": "GET", "path": "/api/status",
"summary": "Dashboard JSON — versions, counts, settings snapshot, uptime."},
{"method": "GET", "path": "/metrics",
"summary": "Prometheus text exposition (counters + gauges)."},
],
},
{
"name": "ISO images",
"endpoints": [
{"method": "GET", "path": "/api/isos",
"summary": "List ISOs (local + NFS) with size, family, boot entries, category."},
{"method": "POST", "path": "/api/isos",
"summary": "Legacy single-shot multipart upload. Prefer /api/uploads for big files."},
{"method": "DELETE", "path": "/api/isos/:id",
"summary": "Delete a local ISO and its sidecar metadata."},
{"method": "PUT", "path": "/api/isos/:id/password",
"summary": "Set or update an ISO's boot password (bcrypt-hashed; plaintext never stored)."},
{"method": "DELETE", "path": "/api/isos/:id/password",
"summary": "Clear an ISO's boot password."},
{"method": "PUT", "path": "/api/isos/:id/category",
"summary": "Set the menu category. Body: { \"category\": \"os\" | \"tools\" }."},
],
},
{
"name": "Chunked uploads",
"endpoints": [
{"method": "POST", "path": "/api/uploads",
"summary": "Begin a chunked upload session. Body: { \"filename\", \"size_bytes\" }."},
{"method": "PUT", "path": "/api/uploads/:upload_id",
"summary": "Append a chunk. Headers: x-openpxe-upload-offset, x-openpxe-upload-complete."},
{"method": "DELETE", "path": "/api/uploads/:upload_id",
"summary": "Abort a chunked upload session and remove the .partial file."},
],
},
{
"name": "NFS shares",
"endpoints": [
{"method": "GET", "path": "/api/nfs",
"summary": "List configured NFS shares with mount state and iso counts."},
{"method": "POST", "path": "/api/nfs",
"summary": "Mount an NFS share. Body: { server, export, version, read_only }."},
{"method": "DELETE", "path": "/api/nfs/:id",
"summary": "Unmount a share and drop its entries from the ISO store."},
{"method": "POST", "path": "/api/nfs/:id/scan",
"summary": "Re-walk a mounted share for ISOs."},
],
},
{
"name": "Network",
"endpoints": [
{"method": "GET", "path": "/api/network",
"summary": "Detected NIC, server IP, subnet, gateway, advertised base URL."},
{"method": "PUT", "path": "/api/network",
"summary": "Update the informational DNS server hint (does not run DNS)."},
],
},
{
"name": "Settings",
"endpoints": [
{"method": "GET", "path": "/api/settings",
"summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."},
{"method": "PUT", "path": "/api/settings",
"summary": "Replace runtime settings. Guards against enabling Windows when wimboot isn't bundled."},
{"method": "POST", "path": "/api/branding/logo",
"summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."},
{"method": "DELETE", "path": "/api/branding/logo",
"summary": "Remove the custom logo and revert to the bundled mark."},
{"method": "GET", "path": "/api/docs",
"summary": "This API reference."},
],
},
{
"name": "Storage telemetry",
"endpoints": [
{"method": "GET", "path": "/api/storage/disk",
"summary": "Free / used / total bytes for the volume hosting the ISO directory."},
],
},
{
"name": "Queued Deployment",
"endpoints": [
{"method": "GET", "path": "/api/queue",
"summary": "List queue entries (waiting + assigned)."},
{"method": "POST", "path": "/api/queue/assign",
"summary": "Assign a target image to queued clients. Body: { target, entry_ids }."},
{"method": "DELETE", "path": "/api/queue/:entry_id",
"summary": "Release a queue entry without assigning."},
],
},
{
"name": "Hosts & boot log",
"endpoints": [
{"method": "GET", "path": "/api/hosts",
"summary": "List per-MAC boot bindings."},
{"method": "POST", "path": "/api/hosts",
"summary": "Pin a MAC to a boot target. Body: { mac, target, label }."},
{"method": "DELETE", "path": "/api/hosts/:mac",
"summary": "Remove a binding."},
{"method": "GET", "path": "/api/boot-log",
"summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
],
},
{
"name": "Operator console",
"endpoints": [
{"method": "GET", "path": "/api/clients",
"summary": "Live PXE-client registry — MAC, last IP, arch, events."},
{"method": "GET", "path": "/api/log/recent",
"summary": "Ring of recent server log lines for the Terminal tab."},
{"method": "GET", "path": "/api/log/stream",
"summary": "Server-Sent Events stream of log lines."},
{"method": "POST", "path": "/api/terminal",
"summary": "Run a whitelisted operator command. Body: { command }."},
],
},
],
}))
}
async fn api_upload_iso(State(state): State<AppState>, mut multipart: Multipart) -> Response {
// Walk multipart parts until we find the file. Each branch logs so an
// operator chasing a "stuck" upload in the Terminal tab can see
@@ -979,6 +1307,7 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
"nfs_count": nfs.len(),
"nfs_active": nfs_active,
"host_bindings": state.hosts.len(),
"custom_logo": state.branding.has_logo(),
"uptime_secs": uptime_secs,
"started_at": state.started_at,
"nic_name": state.nic_name,
+53 -4
View File
@@ -149,6 +149,13 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
if !filter(iso.introspection.family) {
continue;
}
// v0.4.4: ISOs the operator flipped to the Tools category move
// out of the OS installer submenus entirely — they only appear
// under Tools. Without this filter the operator would see the
// same ISO in both menus.
if matches!(iso.category, openpxe_iso_store::IsoCategory::Tools) {
continue;
}
for entry in &iso.boot_entries {
let size_label = fmt_size_mib(iso.size_bytes);
let key = hotkey_for_index(count);
@@ -210,15 +217,52 @@ fn hotkey_for_index(i: usize) -> String {
}
}
/// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware.
/// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware,
/// plus any ISOs the operator flipped to [`IsoCategory::Tools`] in the
/// Storage tab. The category-Tools ISOs render first so frequently used
/// recovery / hardware tools are reachable with a single number key
/// before the built-in shortcuts.
#[must_use]
pub fn render_tools_menu(base_url: &str) -> String {
pub fn render_tools_menu(isos: &[IsoMeta], base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - Tools");
// Operator-categorized tool ISOs (hotkeys 1..9), each chained the
// same way as a per-family menu pick — through the boot-entry id
// route, carrying `?mac=${mac}` for Host log attribution.
let mut count = 0;
for iso in isos {
if !matches!(iso.category, openpxe_iso_store::IsoCategory::Tools) {
continue;
}
for entry in &iso.boot_entries {
let size_label = fmt_size_mib(iso.size_bytes);
let key = hotkey_for_index(count);
let lock = if iso.is_password_protected() {
"*"
} else {
" "
};
let _ = writeln!(
s,
"item {}{} {}[{:>6}] {}",
key,
entry.id,
lock,
size_label,
escape_label(&entry.title),
);
count += 1;
}
}
if count > 0 {
let _ = writeln!(s, "item --gap");
}
let _ = writeln!(s, "item --key u util Utilities (memtest, ...)");
let _ = writeln!(s, "item --key s shell OpenPXE Shell");
let _ = writeln!(s, "item --key n nic Network Card Info");
@@ -252,7 +296,12 @@ pub fn render_tools_menu(base_url: &str) -> String {
s,
"iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"
);
let _ = writeln!(s, "goto menu");
// Fall-through for category-Tools ISO ids — same as the family
// submenu, carrying `?mac=${mac}` for the boot log.
let _ = writeln!(
s,
"chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
);
s
}
@@ -531,7 +580,7 @@ mod password_tests {
let settings = Settings::default();
let scripts = [
render_menu(&[], &settings, "http://10.0.0.5"),
render_tools_menu("http://10.0.0.5"),
render_tools_menu(&[], "http://10.0.0.5"),
render_local_hdd("http://10.0.0.5"),
render_util("http://10.0.0.5"),
render_shell("http://10.0.0.5"),
+6 -1
View File
@@ -1,6 +1,7 @@
use crate::uploads::UploadSessions;
use openpxe_core::{
BootLog, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore,
BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics,
SettingsStore,
};
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use std::sync::Arc;
@@ -20,6 +21,10 @@ pub struct AppState {
/// every `/boot/<entry>.ipxe` chain that goes on to serve a script
/// (i.e. an image actually starting to install on a machine).
pub boot_log: BootLog,
/// Operator-controlled UI overrides (custom logo). When the
/// operator hasn't uploaded anything, the WebUI serves the bundled
/// rainbow-horizon mark.
pub branding: BrandingStore,
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus
/// text format. Cheap to clone (handles to atomics).
pub metrics: Metrics,