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 aa178cee93
commit 55d74662c2
14 changed files with 1385 additions and 45 deletions
+205
View File
@@ -99,6 +99,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let log_bus = LogBus::new(64);
let hosts = HostBindings::load_or_default(dir.path());
let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
let branding = openpxe_core::BrandingStore::load_or_default(dir.path());
let metrics = Metrics::new();
let state = AppState {
iso_store,
@@ -107,6 +108,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
settings,
hosts,
boot_log,
branding,
metrics,
smb: None,
nfs,
@@ -1201,3 +1203,206 @@ async fn chunked_upload_rejects_offset_mismatch_without_advancing() {
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(text.contains("expected offset 0"), "got: {text}");
}
#[tokio::test]
async fn iso_category_switch_moves_entry_between_menus() {
// OS (default): appears in Linux Installers submenu and not in Tools.
// After flipping to Tools: gone from Linux, present under Tools.
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let upload = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(upload.status(), StatusCode::CREATED);
let (_, linux_before) = get(&app, "/boot/_linux_menu.ipxe").await;
let linux_before = String::from_utf8(linux_before).unwrap();
assert!(
linux_before.contains("fake-alpine-linux"),
"expected entry in Linux submenu (default OS):\n{linux_before}"
);
let (_, tools_before) = get(&app, "/boot/_tools_menu.ipxe").await;
let tools_before = String::from_utf8(tools_before).unwrap();
assert!(
!tools_before.contains("fake-alpine-linux"),
"OS-category ISO shouldn't appear in Tools yet:\n{tools_before}"
);
// Flip to Tools.
let (s, _) = put_json(
&app,
"/api/isos/fake-alpine/category",
r#"{"category":"tools"}"#,
)
.await;
assert_eq!(s, StatusCode::OK);
let (_, linux_after) = get(&app, "/boot/_linux_menu.ipxe").await;
let linux_after = String::from_utf8(linux_after).unwrap();
assert!(
!linux_after.contains("fake-alpine-linux"),
"Tools-category ISO should NOT appear in Linux submenu:\n{linux_after}"
);
let (_, tools_after) = get(&app, "/boot/_tools_menu.ipxe").await;
let tools_after = String::from_utf8(tools_after).unwrap();
assert!(
tools_after.contains("fake-alpine-linux"),
"Tools-category ISO should appear in Tools submenu:\n{tools_after}"
);
}
#[tokio::test]
async fn iso_category_unknown_value_returns_400() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
let (s, body) = put_json(
&app,
"/api/isos/fake-alpine/category",
r#"{"category":"gibberish"}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("unknown category"), "got: {text}");
}
#[tokio::test]
async fn iso_category_unknown_id_returns_404() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, _) = put_json(
&app,
"/api/isos/does-not-exist/category",
r#"{"category":"tools"}"#,
)
.await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn storage_disk_endpoint_reports_volume_stats() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/api/storage/disk").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
// statvfs always returns something on the tempdir filesystem; check
// that the shape is sane (total >= used >= 0 and total >= available).
assert!(v["total_bytes"].as_u64().unwrap() > 0, "got {v}");
let total = v["total_bytes"].as_u64().unwrap();
let avail = v["available_bytes"].as_u64().unwrap();
let used = v["used_bytes"].as_u64().unwrap();
assert!(total >= avail, "{v}");
assert!(total >= used, "{v}");
assert!(v["path"].as_str().unwrap().contains("isos"), "got {v}");
}
#[tokio::test]
async fn api_docs_lists_known_endpoints() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/api/docs").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
let groups = v["groups"].as_array().expect("groups array");
assert!(!groups.is_empty());
// Flatten the paths and confirm a handful of the routes that real
// operators will look up are documented.
let mut paths: Vec<String> = Vec::new();
for g in groups {
for ep in g["endpoints"].as_array().unwrap() {
paths.push(ep["path"].as_str().unwrap().into());
}
}
for needle in [
"/api/isos",
"/api/isos/:id/category",
"/api/storage/disk",
"/api/branding/logo",
"/api/boot-log",
"/metrics",
] {
assert!(
paths.iter().any(|p| p == needle),
"expected {needle} in docs; got {paths:?}"
);
}
}
#[tokio::test]
async fn branding_clear_when_no_logo_is_no_content() {
// No-op clear should still 204 — it's not an error to revert to
// the default when there's nothing to revert from.
let (state, _dir) = build_state().await;
let app = build_router(state);
let res = app
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/branding/logo")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn status_exposes_custom_logo_flag() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/api/status").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
v["custom_logo"].as_bool(),
Some(false),
"fresh state has no custom logo: {v}"
);
}
async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>) {
let res = router
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri(path)
.header("content-type", "application/json")
.body(Body::from(body.to_owned()))
.unwrap(),
)
.await
.unwrap();
let status = res.status();
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap()
.to_vec();
(status, bytes)
}