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
@@ -31,6 +31,30 @@ pub enum IsoSource {
|
||||
},
|
||||
}
|
||||
|
||||
/// Where the ISO lands in the PXE menu hierarchy.
|
||||
///
|
||||
/// Auto-detected family (Debian, Windows, …) still drives BIOS/UEFI
|
||||
/// behaviour and per-entry boot args, but the *menu placement* is
|
||||
/// operator-controlled — an operator who's uploaded a TinyCore live ISO
|
||||
/// to use as a recovery shim, or a SystemRescue image, can flip its
|
||||
/// category to `Tools` so it lands next to memtest/shell instead of
|
||||
/// under Linux Installers.
|
||||
///
|
||||
/// Old `meta.json` files without this field deserialize as `Os`, which
|
||||
/// matches v0.4.1 behaviour (everything goes under OS Installers).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IsoCategory {
|
||||
/// "OS Installer" — routed via the auto-detected family into the
|
||||
/// Linux / Windows installer submenus.
|
||||
#[default]
|
||||
Os,
|
||||
/// "Tool" — surfaced under the Tools menu next to memtest, shell,
|
||||
/// NIC info, etc. Family detection still decides BIOS/UEFI vs
|
||||
/// wimboot vs sanboot at boot time.
|
||||
Tools,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IsoMeta {
|
||||
/// Stable slug used in URLs (derived from the uploaded filename).
|
||||
@@ -56,6 +80,10 @@ pub struct IsoMeta {
|
||||
/// the common no-password case.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub password_hash: Option<String>,
|
||||
/// Where the ISO sits in the PXE menu hierarchy — operator-controlled,
|
||||
/// not driven by family detection. Defaults to [`IsoCategory::Os`].
|
||||
#[serde(default)]
|
||||
pub category: IsoCategory,
|
||||
}
|
||||
|
||||
impl IsoMeta {
|
||||
@@ -114,6 +142,7 @@ impl UploadHandle {
|
||||
boot_entries,
|
||||
source: IsoSource::Local,
|
||||
password_hash: None,
|
||||
category: IsoCategory::default(),
|
||||
};
|
||||
store.persist_meta(&meta).await?;
|
||||
store.insert(meta.clone());
|
||||
@@ -318,6 +347,7 @@ impl IsoStore {
|
||||
boot_entries,
|
||||
source,
|
||||
password_hash: None,
|
||||
category: IsoCategory::default(),
|
||||
};
|
||||
self.inner.write().isos.insert(id, meta);
|
||||
}
|
||||
@@ -377,6 +407,47 @@ impl IsoStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flip an ISO's menu category. Persists to `meta.json` for local
|
||||
/// ISOs; NFS-sourced ISOs keep the change in memory only (the next
|
||||
/// re-scan would overwrite it anyway).
|
||||
pub async fn set_category(&self, id: &str, category: IsoCategory) -> Result<IsoMeta> {
|
||||
let updated = {
|
||||
let mut g = self.inner.write();
|
||||
let m = g
|
||||
.isos
|
||||
.get_mut(id)
|
||||
.ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
|
||||
m.category = category;
|
||||
m.clone()
|
||||
};
|
||||
if matches!(updated.source, IsoSource::Local) {
|
||||
self.persist_meta(&updated).await?;
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Absolute path to the directory holding local ISO uploads. Used
|
||||
/// by the HTTP layer for the disk-space endpoint — the volume that
|
||||
/// hosts this directory is what runs out of room first.
|
||||
#[must_use]
|
||||
pub fn iso_dir(&self) -> PathBuf {
|
||||
self.iso_dir.as_path().to_path_buf()
|
||||
}
|
||||
|
||||
/// `(total_bytes, available_bytes)` for the filesystem hosting the
|
||||
/// ISO directory. Returns `None` if `statvfs` fails (read-only
|
||||
/// filesystem with no quota, mount disappeared, …) — callers
|
||||
/// should treat that as "unknown" rather than zero.
|
||||
///
|
||||
/// Lives here rather than the HTTP crate because `http-api`'s
|
||||
/// `#![forbid(unsafe_code)]` rules out the libc FFI directly, and
|
||||
/// because this is naturally an `IsoStore` question — the volume
|
||||
/// of interest is whatever's hosting the iso dir.
|
||||
#[must_use]
|
||||
pub fn disk_usage(&self) -> Option<(u64, u64)> {
|
||||
disk_usage_for(self.iso_dir.as_path())
|
||||
}
|
||||
|
||||
/// Verify a candidate password against the stored bcrypt hash.
|
||||
/// Returns:
|
||||
/// - `Ok(true)` — match (or the ISO has no password set; boot is open)
|
||||
@@ -393,6 +464,33 @@ impl IsoStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `(total, available)` bytes for the filesystem hosting `path`.
|
||||
/// Returns `None` if `statvfs` fails.
|
||||
#[allow(unsafe_code)]
|
||||
fn disk_usage_for(path: &std::path::Path) -> Option<(u64, u64)> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let c = CString::new(path.as_os_str().as_bytes()).ok()?;
|
||||
// SAFETY: `statvfs` is repr(C); a zeroed value is a valid initial
|
||||
// state per POSIX. The FFI call writes every field we then read.
|
||||
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: `c` is a NUL-terminated C string pointing into a stack
|
||||
// CString that outlives this call; `&mut stat` is a unique aligned
|
||||
// pointer to a stack-local `statvfs`. The kernel writes through
|
||||
// it but does not retain the pointer past return.
|
||||
let rc = unsafe { libc::statvfs(c.as_ptr(), &raw mut stat) };
|
||||
if rc != 0 {
|
||||
return None;
|
||||
}
|
||||
// Use f_frsize (fundamental block size). f_bsize is "preferred I/O
|
||||
// block" and doesn't always match the unit f_blocks is denominated
|
||||
// in — on some BSDs it would over-report by a factor of 8.
|
||||
let frsize = stat.f_frsize as u64;
|
||||
let total = stat.f_blocks as u64 * frsize;
|
||||
let avail = stat.f_bavail as u64 * frsize;
|
||||
Some((total, avail))
|
||||
}
|
||||
|
||||
fn slugify(filename: &str) -> String {
|
||||
let stem = Path::new(filename)
|
||||
.file_stem()
|
||||
@@ -551,6 +649,7 @@ mod tests {
|
||||
boot_entries: vec![],
|
||||
source: IsoSource::Local,
|
||||
password_hash: None,
|
||||
category: IsoCategory::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user