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:
co-authored by
Claude Opus 4.8
parent
3f9d8568f0
commit
2f12a2ae84
+50
-41
@@ -116,6 +116,46 @@
|
||||
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
|
||||
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
|
||||
function bootability(iso, settings) {
|
||||
@@ -142,6 +182,13 @@
|
||||
const isos = await getJSON('/api/isos');
|
||||
const clients = (await getJSON('/api/clients')).clients || [];
|
||||
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 stats = el('div', {class: 'statstrip'}, [
|
||||
@@ -222,7 +269,7 @@
|
||||
'⚠ ' + i.filename + ' — ' + b.reason)))
|
||||
]) : 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 () => {
|
||||
@@ -737,7 +784,7 @@
|
||||
const nfsMsg = el('div', {class:'msg'});
|
||||
const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
|
||||
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) {
|
||||
nfsMsg.replaceChildren(document.createTextNode('Server and export are required.'));
|
||||
nfsMsg.className = 'msg err'; return;
|
||||
@@ -794,45 +841,7 @@
|
||||
el('span'),
|
||||
])) : [el('div', {class:'empty'}, 'No NFS shares configured.')];
|
||||
|
||||
// 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.
|
||||
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),
|
||||
]),
|
||||
]);
|
||||
const diskCard = diskSpaceCard(disk);
|
||||
|
||||
return el('div', {class:'grid'}, [
|
||||
diskCard,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
on the asset handlers, the practical caching window is one
|
||||
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
|
||||
dark→light flash on every navigation. Falls back to the OS
|
||||
preference and finally to dark. -->
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<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>
|
||||
</div>
|
||||
<nav>
|
||||
|
||||
@@ -17,11 +17,19 @@
|
||||
/// when we know the new one is incompatible. Combined with
|
||||
/// `Cache-Control: no-cache, must-revalidate` on the asset handlers,
|
||||
/// 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]
|
||||
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
|
||||
.replace("{{BASE_URL}}", base_url)
|
||||
.replace("{{ASSET_VERSION}}", asset_version)
|
||||
.replace("{{LOGO_REV}}", &logo_rev.to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
||||
Reference in New Issue
Block a user