v0.4.68: fix NFS secure-export mount, logo cache-bust, dashboard disk card, NFS form spacing

Four operator-reported issues from v0.4.67 validation.

## 1. NFS MNT3ERR_ACCES even with the host IP allow-listed

Root cause: Linux kernel nfsd (what UniFi UNAS / Synology / TrueNAS all
run underneath) exports with the `secure` option by default, which only
accepts mount/NFS requests from a privileged source port (<1024). v0.4.67
explicitly connected from a non-privileged port on the mistaken assumption
that uid 10001 can't bind low ports — but the binary carries
CAP_NET_BIND_SERVICE (granted via setcap for the DHCP/TFTP/HTTP low-port
binds), which also covers privileged *source* ports for outbound connects.

Fix: build_connection now tries a privileged source port first (the common
case for every appliance NAS), then falls back to a non-privileged port
for `insecure` exports or capability-less environments. Each attempt has
its own connect timeout; a timeout on the first attempt skips the fallback
(the server isn't answering — a retry would just double the wait).

Also: hint_for now recognizes MNT3ERR_ACCES distinctly from NFS3ERR_ACCES
and explains both the allow-list and the secure/insecure angle, with the
UniFi /var/nfs/shared/<share> path convention called out.

## 2. Custom logo didn't update the top-left brand mark

The brand <img> and favicon were pinned to ?v=<app-version>, which only
changes on upgrade — so uploading a new logo left the cached bundled SVG
in place. Added a monotonic `rev` counter to BrandingStore that bumps on
every set/clear, persisted across restarts, surfaced through index_html as
an extra &r=<rev> cache-bust token on the brand mark + favicon URLs. Since
index.html is served no-cache, the fresh token lands on the next reload
after upload and the new logo appears immediately.

(Note: this updates the WebUI brand mark. The PXE *boot menu* still shows
the ASCII wordmark — painting the operator's PNG there needs the
IMAGE_PNG-enabled iPXE rebuild that remains queued for native x86_64
hardware. The /branding/pxe-logo compositor is ready for when it lands.)

## 3. Disk-space card on the Dashboard

Extracted the Storage tab's disk card into a shared diskSpaceCard(disk)
helper and added it to the Dashboard grid under the stat strip. Dashboard
fetches /api/storage/disk with the same graceful-degradation fallback the
Storage tab uses.

## 4. NFS "Add share" button touching the form field

The NFS card has a single form row (vs SMB's two), so the button butted
right against it. Added margin-top:14px to match SMB's effective spacing.

Tests: 162 passing (+2 — logo_rev bump, MNT3ERR_ACCES hint). clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Miles Ward
2026-05-28 21:34:41 -04:00
co-authored by Claude Opus 4.8
parent 3f9d8568f0
commit 2f12a2ae84
8 changed files with 211 additions and 76 deletions
Generated
+8 -8
View File
@@ -1171,7 +1171,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "openpxe" name = "openpxe"
version = "0.4.67" version = "0.4.68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -1193,7 +1193,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-core" name = "openpxe-core"
version = "0.4.67" version = "0.4.68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bcrypt", "bcrypt",
@@ -1212,7 +1212,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-dhcp-proxy" name = "openpxe-dhcp-proxy"
version = "0.4.67" version = "0.4.68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -1226,7 +1226,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-http-api" name = "openpxe-http-api"
version = "0.4.67" version = "0.4.68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -1257,7 +1257,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-ipxe-assets" name = "openpxe-ipxe-assets"
version = "0.4.67" version = "0.4.68"
dependencies = [ dependencies = [
"openpxe-core", "openpxe-core",
"rust-embed", "rust-embed",
@@ -1267,7 +1267,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-iso-store" name = "openpxe-iso-store"
version = "0.4.67" version = "0.4.68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bcrypt", "bcrypt",
@@ -1294,7 +1294,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-tftp" name = "openpxe-tftp"
version = "0.4.67" version = "0.4.68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -1308,7 +1308,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-webui" name = "openpxe-webui"
version = "0.4.67" version = "0.4.68"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.4.67" version = "0.4.68"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+36
View File
@@ -44,6 +44,14 @@ struct Inner {
/// MIME of the active logo, mirroring `logo_filename`. Cached here /// MIME of the active logo, mirroring `logo_filename`. Cached here
/// so the HTTP layer can set Content-Type without re-sniffing. /// so the HTTP layer can set Content-Type without re-sniffing.
logo_mime: Option<String>, logo_mime: Option<String>,
/// Monotonic counter bumped on every set/clear. Surfaces as a
/// cache-bust token (`/assets/logo.svg?r=<rev>`) so the browser
/// fetches the new bytes the moment the operator swaps the logo —
/// the app version alone can't do this since it doesn't change on
/// upload. Persisted so the token stays stable across restarts and
/// keeps climbing across multiple swaps.
#[serde(default)]
rev: u64,
} }
/// In-memory + on-disk override registry. Cheap to clone; locks are /// In-memory + on-disk override registry. Cheap to clone; locks are
@@ -150,6 +158,7 @@ impl BrandingStore {
let mut g = self.inner.write(); let mut g = self.inner.write();
g.logo_filename = Some(filename.clone()); g.logo_filename = Some(filename.clone());
g.logo_mime = Some(mime.to_string()); g.logo_mime = Some(mime.to_string());
g.rev = g.rev.wrapping_add(1);
} }
self.persist(); self.persist();
tracing::info!( tracing::info!(
@@ -166,6 +175,7 @@ impl BrandingStore {
let mut g = self.inner.write(); let mut g = self.inner.write();
let removed = g.logo_filename.take(); let removed = g.logo_filename.take();
g.logo_mime = None; g.logo_mime = None;
g.rev = g.rev.wrapping_add(1);
removed removed
}; };
if let Some(name) = removed { if let Some(name) = removed {
@@ -185,6 +195,14 @@ impl BrandingStore {
self.inner.read().logo_filename.is_some() self.inner.read().logo_filename.is_some()
} }
/// Cache-bust token for the logo asset URL. Changes on every
/// set/clear so `/assets/logo.svg?r=<rev>` resolves to a fresh URL
/// whenever the operator swaps the brand mark. Stable otherwise.
#[must_use]
pub fn logo_rev(&self) -> u64 {
self.inner.read().rev
}
fn persist(&self) { fn persist(&self) {
let snap = self.inner.read().clone(); let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) { let body = match serde_json::to_vec_pretty(&snap) {
@@ -292,6 +310,23 @@ mod tests {
assert!(!entries.iter().any(|n| n == "logo.png"), "stale PNG left over: {entries:?}"); assert!(!entries.iter().any(|n| n == "logo.png"), "stale PNG left over: {entries:?}");
} }
#[test]
fn logo_rev_bumps_on_each_set_and_clear() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert_eq!(b.logo_rev(), 0);
b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap();
assert_eq!(b.logo_rev(), 1);
b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake2").unwrap();
assert_eq!(b.logo_rev(), 2);
b.clear_logo().unwrap();
assert_eq!(b.logo_rev(), 3);
// Survives a restart.
drop(b);
let b2 = BrandingStore::load_or_default(dir.path());
assert_eq!(b2.logo_rev(), 3);
}
#[test] #[test]
fn sanitize_ext_strips_separators_and_path_chars() { fn sanitize_ext_strips_separators_and_path_chars() {
assert_eq!(sanitize_ext("svg"), "svg"); assert_eq!(sanitize_ext("svg"), "svg");
@@ -319,6 +354,7 @@ mod tests {
let inner = Inner { let inner = Inner {
logo_filename: Some("logo.png".into()), logo_filename: Some("logo.png".into()),
logo_mime: Some("image/png".into()), logo_mime: Some("image/png".into()),
rev: 0,
}; };
std::fs::write( std::fs::write(
brand_dir.join("branding.json"), brand_dir.join("branding.json"),
+8 -1
View File
@@ -208,7 +208,14 @@ async fn index(State(state): State<AppState>) -> Response {
// browsers re-fetch JS/CSS after an upgrade. We use the OpenPXE // browsers re-fetch JS/CSS after an upgrade. We use the OpenPXE
// binary version — every release ships a new value, every release // binary version — every release ships a new value, every release
// forces a fresh URL on each asset. // forces a fresh URL on each asset.
let html = openpxe_webui::index_html(&state.public_base_url, env!("CARGO_PKG_VERSION")); // logo_rev cache-busts the brand mark / favicon independently of
// the release version, so an operator who swaps the custom logo
// sees it update on the next reload without waiting for an upgrade.
let html = openpxe_webui::index_html(
&state.public_base_url,
env!("CARGO_PKG_VERSION"),
state.branding.logo_rev(),
);
( (
[ [
( (
+97 -22
View File
@@ -510,10 +510,9 @@ async fn list_isos(
export: &str, export: &str,
port: u16, port: u16,
) -> std::result::Result<Vec<NfsListEntry>, NfsClientError> { ) -> std::result::Result<Vec<NfsListEntry>, NfsClientError> {
let mut conn = // build_connection applies its own per-attempt timeout (privileged
tokio::time::timeout(CONNECT_TIMEOUT, build_connection(server, export, port)) // source port first, then a non-privileged fallback).
.await let mut conn = build_connection(server, export, port).await?;
.map_err(|_| NfsClientError::Timeout(server.to_string(), port))??;
let root = conn.root_nfs_fh3(); let root = conn.root_nfs_fh3();
let mut entries = Vec::new(); let mut entries = Vec::new();
@@ -610,10 +609,7 @@ async fn stream_loop(
max_len: Option<u64>, max_len: Option<u64>,
tx: tokio::sync::mpsc::Sender<std::io::Result<Bytes>>, tx: tokio::sync::mpsc::Sender<std::io::Result<Bytes>>,
) -> std::result::Result<(), NfsClientError> { ) -> std::result::Result<(), NfsClientError> {
let mut conn = let mut conn = build_connection(server, export, port).await?;
tokio::time::timeout(CONNECT_TIMEOUT, build_connection(server, export, port))
.await
.map_err(|_| NfsClientError::Timeout(server.to_string(), port))??;
let root = conn.root_nfs_fh3(); let root = conn.root_nfs_fh3();
// Look up the file to get its handle. // Look up the file to get its handle.
@@ -686,13 +682,30 @@ async fn stream_loop(
Ok(()) Ok(())
} }
/// Hand the connection builder the user's settings. `mount_path` is /// Mount the export and return a live connection.
/// the server-side export (e.g. "/srv/isos"). We disable ///
/// `connect_from_privileged_port` because the openpxe process runs /// ## Privileged source port (the v0.4.68 fix)
/// as uid 10001 and can't bind sub-1024 source ports — and most ///
/// modern NFS servers no longer require them anyway. If a server /// Linux kernel `nfsd` — which is what UniFi UNAS, Synology, TrueNAS,
/// does demand it the operator's hint will guide them to the /// and essentially every appliance NAS runs underneath — exports with
/// `insecure` export option. /// the `secure` option **by default**. `secure` means the server only
/// accepts MOUNT3 / NFS3 requests whose TCP **source** port is in the
/// privileged range (< 1024). A client connecting from an ephemeral
/// high port gets `MNT3ERR_ACCES` at mount time — which is exactly the
/// error operators hit in v0.4.67 even with their host IP correctly in
/// the export's allow-list.
///
/// v0.4.67 disabled privileged source ports because the openpxe
/// process runs as uid 10001 and "can't bind sub-1024 ports". That
/// reasoning was wrong: the binary carries `CAP_NET_BIND_SERVICE`
/// (granted via `setcap` in the Dockerfile so it can bind the DHCP /
/// TFTP / HTTP low ports as non-root), and that capability also lets
/// it bind a privileged *source* port for an outbound connection.
///
/// So we now try a privileged source port first — the common case for
/// real NAS appliances — and fall back to a non-privileged port for
/// servers exported `insecure` (or environments where we genuinely
/// can't grab a low port). Each attempt gets its own connect timeout.
async fn build_connection( async fn build_connection(
server: &str, server: &str,
export: &str, export: &str,
@@ -701,12 +714,42 @@ async fn build_connection(
nfs3_client::Nfs3Connection<nfs3_client::tokio::TokioIo<tokio::net::TcpStream>>, nfs3_client::Nfs3Connection<nfs3_client::tokio::TokioIo<tokio::net::TcpStream>>,
NfsClientError, NfsClientError,
> { > {
Nfs3ConnectionBuilder::new(TokioConnector, server, export) match connect_once(server, export, port, true).await {
.connect_from_privileged_port(false) Ok(conn) => Ok(conn),
// A timeout means the server didn't answer at all — a
// non-privileged retry would just time out again and double
// the operator's wait. Surface the timeout immediately.
Err(primary @ NfsClientError::Timeout(..)) => Err(primary),
Err(primary) => match connect_once(server, export, port, false).await {
Ok(conn) => Ok(conn),
// Surface the privileged-attempt error: for the dominant
// `secure`-export case it's the one whose hint points at
// the real fix.
Err(_) => Err(primary),
},
}
}
/// One mount attempt with a specific source-port policy, bounded by
/// [`CONNECT_TIMEOUT`].
async fn connect_once(
server: &str,
export: &str,
port: u16,
privileged: bool,
) -> std::result::Result<
nfs3_client::Nfs3Connection<nfs3_client::tokio::TokioIo<tokio::net::TcpStream>>,
NfsClientError,
> {
let fut = Nfs3ConnectionBuilder::new(TokioConnector, server, export)
.connect_from_privileged_port(privileged)
.nfs3_port(port) .nfs3_port(port)
.mount() .mount();
.await match tokio::time::timeout(CONNECT_TIMEOUT, fut).await {
.map_err(|e| NfsClientError::Connect(e.to_string())) Ok(Ok(conn)) => Ok(conn),
Ok(Err(e)) => Err(NfsClientError::Connect(e.to_string())),
Err(_) => Err(NfsClientError::Timeout(server.to_string(), port)),
}
} }
#[derive(Debug)] #[derive(Debug)]
@@ -778,14 +821,34 @@ fn status_label(status: nfs3::nfsstat3) -> String {
/// transport-level messages from the Rust crate. /// transport-level messages from the Rust crate.
fn hint_for(text: &str) -> Option<String> { fn hint_for(text: &str) -> Option<String> {
let s = text.to_ascii_lowercase(); let s = text.to_ascii_lowercase();
if s.contains("nfs3err_acces") || s.contains("permission denied") { if s.contains("mnt3err_acces") || s.contains("mount") && s.contains("acces") {
// Mount-protocol access denial. Two common causes, in order of
// likelihood for an appliance NAS: (1) the export requires a
// privileged source port (`secure`, the Linux default) — we
// already retry with one, so reaching here means even that was
// refused; (2) the client IP isn't in the allow-list.
Some(
"the NFS server denied the mount (MNT3ERR_ACCES). Two things to \
check on the server: (1) this OpenPXE host's IP is in the \
export's allowed-clients list, and (2) if your export uses the \
default `secure` option, OpenPXE already connects from a \
privileged port — but if the server still refuses, add \
`insecure` to the export. On UniFi UNAS, confirm the host IP is \
listed under the share's NFS permissions and the export path is \
/var/nfs/shared/<share> (not just /<share>)."
.into(),
)
} else if s.contains("nfs3err_acces") || s.contains("permission denied") {
Some( Some(
"the NFS server rejected this client. Most likely your export \ "the NFS server rejected this client. Most likely your export \
is restricted by client IP — add this OpenPXE host (or its \ is restricted by client IP — add this OpenPXE host (or its \
subnet) to the export's allowed-clients list on the server." subnet) to the export's allowed-clients list on the server."
.into(), .into(),
) )
} else if s.contains("nfs3err_noent") || s.contains("nfs3err_notdir") { } else if s.contains("nfs3err_noent")
|| s.contains("nfs3err_notdir")
|| s.contains("mnt3err_noent")
{
Some( Some(
"the export path doesn't exist on the server, or it isn't a \ "the export path doesn't exist on the server, or it isn't a \
directory. Double-check the path (e.g. /srv/isos vs /isos — \ directory. Double-check the path (e.g. /srv/isos vs /isos — \
@@ -888,6 +951,18 @@ mod tests {
); );
} }
#[test]
fn hint_for_mount_acces_calls_out_privileged_port_and_allowlist() {
// The dominant v0.4.67 field failure: mount denied even with the
// host IP allow-listed, because the export is `secure` and the
// client used a high source port. The hint should mention both
// the allow-list and the secure/insecure angle.
let h = hint_for("connect failed: MNT3ERR_ACCES").unwrap();
let lc = h.to_lowercase();
assert!(lc.contains("insecure") || lc.contains("privileged"), "got: {h}");
assert!(lc.contains("allow") || lc.contains("permission"), "got: {h}");
}
#[test] #[test]
fn hint_for_noent_points_to_export_path() { fn hint_for_noent_points_to_export_path() {
let h = hint_for("NFS server returned NFS3ERR_NOENT").unwrap(); let h = hint_for("NFS server returned NFS3ERR_NOENT").unwrap();
+50 -41
View File
@@ -116,6 +116,46 @@
return root; return root;
} }
// Disk-space card. Free + used + total for the volume hosting the ISO
// directory, with a coloured bar. Warns at 80% and goes red at 95% so
// the operator sees the runway shrinking before uploads start failing
// with ENOSPC. Shared by the Storage tab and the Dashboard (v0.4.68).
function diskSpaceCard(disk) {
const total = Number(disk.total_bytes || 0);
const avail = Number(disk.available_bytes || 0);
const used = Number(disk.used_bytes || 0);
const pctUsed = total > 0 ? (used / total) * 100 : 0;
let barClass = 'diskbar';
if (pctUsed >= 95) barClass += ' full';
else if (pctUsed >= 80) barClass += ' warn';
return el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Disk space'),
el('span', {class:'sub'},
total > 0 ? (pctUsed.toFixed(1) + '% used') : 'unavailable'),
]),
el('div', {class:'body'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;word-break:break-all'},
disk.path ? ('Volume: ' + disk.path) : 'Volume path unknown'),
el('div', {class: barClass},
el('div', {class:'fill',
style:'width:' + Math.min(100, pctUsed).toFixed(1) + '%'})),
el('div', {class:'disk-meta'}, [
el('span', {}, ['Used ', el('strong', {}, fmtBytes(used))]),
el('span', {}, ['Free ', el('strong', {}, fmtBytes(avail))]),
el('span', {}, ['Total ', el('strong', {}, fmtBytes(total))]),
]),
pctUsed >= 95
? el('p', {class:'msg err', style:'margin-top:10px'},
'⚠ Less than 5% free. Remove old ISOs or grow the volume before uploading more.')
: (pctUsed >= 80
? el('p', {class:'msg', style:'color:var(--warn);margin-top:10px'},
'Volume is getting full. Consider pruning old ISOs.')
: null),
]),
]);
}
// Categorize an ISO row's "bootable now" status — drives the amber // Categorize an ISO row's "bootable now" status — drives the amber
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}. // tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
function bootability(iso, settings) { function bootability(iso, settings) {
@@ -142,6 +182,13 @@
const isos = await getJSON('/api/isos'); const isos = await getJSON('/api/isos');
const clients = (await getJSON('/api/clients')).clients || []; const clients = (await getJSON('/api/clients')).clients || [];
const entries = (await getJSON('/api/queue')).entries || []; const entries = (await getJSON('/api/queue')).entries || [];
// v0.4.68: surface the same disk-space card the Storage tab shows,
// so operators see capacity at a glance from the landing page.
// Tolerate the endpoint being unavailable (e.g. statvfs failure)
// the same way the Storage tab does.
const disk = await getJSON('/api/storage/disk').catch(() => ({
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
}));
const ipxeOk = (status.ipxe_assets || []).length > 0; const ipxeOk = (status.ipxe_assets || []).length > 0;
const stats = el('div', {class: 'statstrip'}, [ const stats = el('div', {class: 'statstrip'}, [
@@ -222,7 +269,7 @@
'⚠ ' + i.filename + ' — ' + b.reason))) '⚠ ' + i.filename + ' — ' + b.reason)))
]) : null; ]) : null;
return el('div', {class:'grid'}, [stats, recentBlock, problemsBlock].filter(Boolean)); return el('div', {class:'grid'}, [stats, diskSpaceCard(disk), recentBlock, problemsBlock].filter(Boolean));
}, },
network: async () => { network: async () => {
@@ -737,7 +784,7 @@
const nfsMsg = el('div', {class:'msg'}); const nfsMsg = el('div', {class:'msg'});
const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'}); const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'}); const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
const addNfs = el('button', {onclick: async () => { const addNfs = el('button', {style:'margin-top:14px', onclick: async () => {
if (!nfsServerIn.value || !nfsExportIn.value) { if (!nfsServerIn.value || !nfsExportIn.value) {
nfsMsg.replaceChildren(document.createTextNode('Server and export are required.')); nfsMsg.replaceChildren(document.createTextNode('Server and export are required.'));
nfsMsg.className = 'msg err'; return; nfsMsg.className = 'msg err'; return;
@@ -794,45 +841,7 @@
el('span'), el('span'),
])) : [el('div', {class:'empty'}, 'No NFS shares configured.')]; ])) : [el('div', {class:'empty'}, 'No NFS shares configured.')];
// Disk-space card. Free + used + total for the volume hosting the const diskCard = diskSpaceCard(disk);
// ISO directory, with a coloured bar. Warns at 80% and goes red at
// 95% so the operator sees the runway shrinking before uploads
// start failing with ENOSPC.
const total = Number(disk.total_bytes || 0);
const avail = Number(disk.available_bytes || 0);
const used = Number(disk.used_bytes || 0);
const pctUsed = total > 0 ? (used / total) * 100 : 0;
let barClass = 'diskbar';
if (pctUsed >= 95) barClass += ' full';
else if (pctUsed >= 80) barClass += ' warn';
const diskCard = el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Disk space'),
el('span', {class:'sub'},
total > 0
? (pctUsed.toFixed(1) + '% used')
: 'unavailable'),
]),
el('div', {class:'body'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;word-break:break-all'},
disk.path ? ('Volume: ' + disk.path) : 'Volume path unknown'),
el('div', {class: barClass},
el('div', {class:'fill',
style:'width:' + Math.min(100, pctUsed).toFixed(1) + '%'})),
el('div', {class:'disk-meta'}, [
el('span', {}, ['Used ', el('strong', {}, fmtBytes(used))]),
el('span', {}, ['Free ', el('strong', {}, fmtBytes(avail))]),
el('span', {}, ['Total ', el('strong', {}, fmtBytes(total))]),
]),
pctUsed >= 95
? el('p', {class:'msg err', style:'margin-top:10px'},
'⚠ Less than 5% free. Remove old ISOs or grow the volume before uploading more.')
: (pctUsed >= 80
? el('p', {class:'msg', style:'color:var(--warn);margin-top:10px'},
'Volume is getting full. Consider pruning old ISOs.')
: null),
]),
]);
return el('div', {class:'grid'}, [ return el('div', {class:'grid'}, [
diskCard, diskCard,
+2 -2
View File
@@ -13,7 +13,7 @@
on the asset handlers, the practical caching window is one on the asset handlers, the practical caching window is one
version. --> version. -->
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" /> <link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}" /> <link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" />
<!-- Theme is read from localStorage *before* paint to avoid the <!-- Theme is read from localStorage *before* paint to avoid the
dark→light flash on every navigation. Falls back to the OS dark→light flash on every navigation. Falls back to the OS
preference and finally to dark. --> preference and finally to dark. -->
@@ -33,7 +33,7 @@
<div class="shell"> <div class="shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"> <div class="brand">
<img src="/assets/logo.svg?v={{ASSET_VERSION}}" alt="OpenPXE" /> <img src="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" alt="OpenPXE" />
<strong>OpenPXE</strong> <strong>OpenPXE</strong>
</div> </div>
<nav> <nav>
+9 -1
View File
@@ -17,11 +17,19 @@
/// when we know the new one is incompatible. Combined with /// when we know the new one is incompatible. Combined with
/// `Cache-Control: no-cache, must-revalidate` on the asset handlers, /// `Cache-Control: no-cache, must-revalidate` on the asset handlers,
/// the worst-case caching window is one version. /// the worst-case caching window is one version.
/// * `logo_rev` is appended to the brand-mark and favicon URLs as an
/// extra `&r=…` token. Unlike `asset_version` it changes every time
/// the operator swaps the custom logo, so the top-left mark updates
/// immediately on the next page load instead of being pinned to the
/// release version (which only changes on upgrade). `index.html`
/// itself is served `no-cache`, so the fresh token lands as soon as
/// the operator reloads after an upload.
#[must_use] #[must_use]
pub fn index_html(base_url: &str, asset_version: &str) -> String { pub fn index_html(base_url: &str, asset_version: &str, logo_rev: u64) -> String {
INDEX_HTML INDEX_HTML
.replace("{{BASE_URL}}", base_url) .replace("{{BASE_URL}}", base_url)
.replace("{{ASSET_VERSION}}", asset_version) .replace("{{ASSET_VERSION}}", asset_version)
.replace("{{LOGO_REV}}", &logo_rev.to_string())
} }
#[must_use] #[must_use]