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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user