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
aa178cee93
commit
55d74662c2
+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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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