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
+333
-4
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user