Files
OpenPXE/crates/webui/src/app.js
T
Miles WardandClaude Opus 4.8 5df0fd5972 v0.6.0: bootable-ISO polish + close the 0.5.x chapter
Builds on v0.5.9's El Torito detection to make the boot menu honest and
clean, and confirms generic El Torito ISOs (ESXi/VMvisor installers, BSDs,
firmware tools) boot via iPXE sanboot with no special-casing:

- generate_boot_entries: an Unknown-family ISO now produces a sanboot entry
  only when it's actually bootable — it carries an El Torito catalog, OR it's
  a remote-share ISO we couldn't introspect (rev 0, assumed bootable). A
  locally-introspected ISO with no boot catalog (a data/appliance image like
  a VMware vCenter Server Appliance bundle) yields NO entry, so it stays out
  of the iPXE menu instead of offering a pick that always fails. ESXi
  installers (Unknown family + El Torito) surface under the installer menu
  and sanboot the raw ISO — backed by HTTP range reads, so size is moot.
- Dropped the stale "(SAN boot — may fail for >1GiB ISOs)" disclaimer and
  refreshed the SanBootIso doc: sanboot is the primary path for Windows and
  any El Torito image, and HTTP range reads remove the size limit.
- WebUI: renamed the dashboard panel "Images that won't boot with current
  settings" -> "Non-bootable images" (there's no setting that would make a
  data/appliance ISO boot).
- Tests: el_torito catalog detection + boot-entry generation across the
  ESXi / VCSA / remote-share cases.

Full v0.5.0->v0.5.9 compatibility sweep: clippy clean; entire workspace test
suite green (core 96, http-api 31+68, iso-store 61, dhcp 1, tftp 6, bin 2);
app.js syntax-checked.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-05 13:02:30 -04:00

2572 lines
121 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// OpenPXE web UI — vanilla JS, no build step, no framework, no network
// dependencies. Uses fetch() + EventSource only.
//
// Tabs (Phase 4): Dashboard / Network / Queue / Storage / Terminal /
// About. The shell swaps a single view into #view-root.
//
// Keep this readable — nobody wants to debug a clever vanilla-JS
// framework at 3 AM. Plain dumb DOM construction is the design.
(function () {
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const el = (tag, attrs = {}, children = []) => {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') e.className = v;
else if (k === 'html') e.innerHTML = v;
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v);
else if (v !== false && v != null) e.setAttribute(k, v);
}
for (const c of [].concat(children)) {
if (c == null || c === false) continue;
if (typeof c === 'string') e.appendChild(document.createTextNode(c));
else e.appendChild(c);
}
return e;
};
const fmtBytes = (n) => {
const u = ['B','KB','MB','GB','TB']; let i = 0;
while (n >= 1024 && i < u.length-1) { n /= 1024; i++; }
return n.toFixed(n >= 10 || i === 0 ? 0 : 1) + ' ' + u[i];
};
const fmtAgo = (ts) => {
const d = (ts instanceof Date) ? ts : new Date(ts);
if (isNaN(d.getTime())) return '-';
const ds = Math.floor((Date.now() - d.getTime()) / 1000);
if (ds < 0) return 'in ' + Math.abs(ds) + 's';
if (ds < 60) return ds + 's ago';
if (ds < 3600) return Math.floor(ds/60) + 'm ago';
if (ds < 86400) return Math.floor(ds/3600) + 'h ago';
return d.toLocaleString();
};
const fmtUptime = (secs) => {
secs = Math.max(0, Math.floor(secs || 0));
const h = Math.floor(secs/3600), m = Math.floor((secs%3600)/60), s = secs%60;
if (h) return h + 'h ' + m + 'm';
if (m) return m + 'm ' + s + 's';
return s + 's';
};
const familyLabel = (f) => ({
debian_ubuntu: 'Debian / Ubuntu', rhel_fedora: 'RHEL family',
open_suse: 'openSUSE', arch: 'Arch', alpine: 'Alpine',
windows_pe: 'Windows', unknown: 'Unknown',
}[f] || f);
const archLabel = (a) => {
if (!a) return '—';
if (typeof a === 'string') return a;
if (a && typeof a === 'object') {
if ('Unknown' in a) return 'unknown(0x' + a.Unknown.toString(16) + ')';
return JSON.stringify(a);
}
return String(a);
};
// ── network helpers ──────────────────────────────────────────────
// All three helpers funnel through a 401 detector. When the server
// says "auth required" mid-session — most commonly because the
// operator's session expired while the tab was idle — we transparently
// swap the SPA out for the login screen rather than letting the UI
// throw a generic error.
function maybeAuthBounce(r) {
if (r && r.status === 401) {
// Render the login screen without reloading; any in-flight
// promises still return their values to the original caller.
showAuthScreen('login');
}
return r;
}
async function getJSON(url) {
const r = maybeAuthBounce(await fetch(url));
if (!r.ok) throw new Error(url + ': ' + r.status);
return r.json();
}
async function putJSON(url, body) {
return maybeAuthBounce(await fetch(url, {
method:'PUT',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body),
}));
}
async function postJSON(url, body) {
return maybeAuthBounce(await fetch(url, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body),
}));
}
// Animated brand-mark + progress bar widget. Built once here and inlined
// wherever a card wants to convey "an image is being deployed onto a
// queued client right now." Used on the Dashboard and the Queue tab.
function queueProgressWidget(imaging, queueTotal) {
const total = Math.max(queueTotal, imaging, 1);
const pct = imaging > 0 ? Math.round((imaging / total) * 100) : 0;
const root = el('div', {class: 'queue-progress' + (imaging === 0 ? ' idle' : '')}, [
el('div', {class: 'mark', 'aria-hidden': 'true'}),
el('div', {class: 'info'}, [
el('div', {class: 'label'},
imaging === 0
? 'No active imaging'
: (imaging + ' of ' + total + ' device' + (total === 1 ? '' : 's') + ' deploying')),
el('div', {class: 'bar-track'},
el('div', {class: 'bar-fill', style: 'width:' + pct + '%'})),
]),
]);
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) {
const fam = iso.introspection.family;
// v0.5.8: Windows ISOs boot via iPXE HTTP sanboot of the raw image —
// no Settings toggle, no SMB, no size limit. Always bootable.
if (fam === 'windows_pe') {
return { ok: true };
}
// Linux with a detected kernel/initrd — direct kernel+initrd boot.
if (iso.introspection.kernel_path) {
return { ok: true };
}
// v0.5.9: any other ISO that carries an El Torito boot catalog is
// bootable via iPXE sanboot (emulated CD) — BSDs, ESXi, firmware
// tools, custom Linux spins. This replaces the old "> 1.5 GB ⇒
// unbootable" size guess with the authoritative on-disk boot signal,
// so a large bootable ISO is no longer mislabeled and a Windows ISO
// re-introspected on upgrade lights up correctly.
if (iso.introspection.el_torito) {
return { ok: true, warn: 'generic bootable ISO — boots via sanboot (emulated CD)' };
}
// Remote-share ISOs aren't introspected (no random access over the
// network), so el_torito is unknown — assume bootable and let sanboot
// try rather than cry wolf.
const remote = iso.source && iso.source.kind && iso.source.kind !== 'local';
if (remote) {
return { ok: true, warn: 'remote ISO — not introspected; sanboot is attempted at boot' };
}
// Local ISO with no Windows/Linux boot files and no El Torito catalog:
// a data/appliance image (e.g. a VMware vCenter bundle), not a bootable
// installer.
return { ok: false, reason: 'data/appliance ISO — no El Torito boot catalog and no Windows/Linux installer files, so it cant be PXE-booted' };
}
// v0.5.2: pretty label for an unattended file's detected kind.
function unattendedKindLabel(k) {
return ({
kickstart: 'Kickstart', preseed: 'Preseed', autoinstall: 'Autoinstall',
answer_file: 'Answer file', unknown: 'Unknown',
})[k] || (k || 'Unknown');
}
// v0.5.2: build the shared "deployment profile" field group — auto
// hostname, auto IP, and an unattended-file picker — reused by the
// Hosts pin form and the Queue "Profile" modal. `files` is the
// /api/unattended list; `profile` seeds the current values. Returns the
// wrapper element plus a `read()` that yields the API body shape.
function buildProfileFields(profile, files, layoutClass) {
profile = profile || {};
files = files || [];
const hostnameInput = el('input', {type:'text', spellcheck:'false',
placeholder:'e.g. node-7', value: profile.auto_hostname || ''});
const ipInput = el('input', {type:'text', spellcheck:'false',
placeholder:'e.g. 10.0.0.7', value: profile.auto_ip || ''});
const sel = el('select', {},
[el('option', {value:''}, '— none —')].concat(
files.map(f => el('option', {value: f.id},
f.filename + ' · ' + unattendedKindLabel(f.kind)))));
sel.value = profile.unattended_file || '';
const wrap = el('div', {class: layoutClass || 'form-row cols-3'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Auto hostname (optional)'), hostnameInput]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Auto IP address (optional)'), ipInput]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Unattended file'), sel]),
]);
return {
wrap,
read() {
return {
auto_hostname: hostnameInput.value.trim() || null,
auto_ip: ipInput.value.trim() || null,
unattended_file: sel.value || null,
};
},
};
}
// v0.5.2: minimal modal overlay. `onSave(msgEl)` runs on Save and may
// return a falsy value to keep the modal open (e.g. on validation
// error) or anything truthy to close it.
function openModal(titleText, contentEls, onSave) {
const overlay = el('div', {class:'modal-overlay'});
const close = () => { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); };
const msg = el('div', {class:'msg', style:'margin-top:10px'});
const cancelBtn = el('button', {class:'ghost', type:'button', onclick: close}, 'Cancel');
const saveBtn = el('button', {class:'submit', type:'button'}, 'Save');
saveBtn.onclick = async () => {
saveBtn.disabled = true;
try {
const ok = await onSave(msg);
if (ok) close();
} finally {
saveBtn.disabled = false;
}
};
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
});
overlay.appendChild(el('div', {class:'modal-box'}, [
el('h2', {}, titleText),
...(Array.isArray(contentEls) ? contentEls : [contentEls]),
msg,
el('div', {class:'modal-actions'}, [cancelBtn, saveBtn]),
]));
document.body.appendChild(overlay);
return { close };
}
// ── views ────────────────────────────────────────────────────────
const views = {
dashboard: async () => {
const status = await getJSON('/api/status');
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'}, [
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Server status'),
el('div', {class: 'value', style: 'font-size:18px;color:' + (ipxeOk ? 'var(--ok)' : 'var(--err)')},
ipxeOk ? 'Ready' : 'Not ready'),
el('div', {class: 'trend'},
ipxeOk ? 'Bootloaders bundled, accepting clients'
: 'No iPXE binaries bundled'),
])),
el('div', {class: 'card'}, [
el('div', {class: 'stat', style: 'padding-bottom:0'}, [
el('div', {class: 'label'}, 'Imaging now'),
el('div', {class: 'value'}, String(status.imaging_count || 0)),
el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting in queue'),
]),
queueProgressWidget(status.imaging_count || 0, status.queue_count || 0),
]),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Images available'),
el('div', {class: 'value'}, String(isos.length)),
el('div', {class: 'trend'}, (() => {
// v0.5.9: count families honestly. Anything that isn't a known
// Linux family or Windows lands in "other" (data/appliance ISOs
// like VMware VCSA, or as-yet-unclassified images) instead of
// being lumped under "Linux".
const LINUX = ['debian_ubuntu', 'rhel_fedora', 'opensuse', 'arch', 'alpine'];
const win = isos.filter(i => i.introspection.family === 'windows_pe').length;
const lin = isos.filter(i => LINUX.includes(i.introspection.family)).length;
const other = isos.length - win - lin;
// v0.4.67+v0.5.5: count all remote-share protocols. Label
// generically since operators may use any mix of SMB/NFS/SFTP.
const remote = (status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0);
const parts = [win + ' Windows', lin + ' Linux'];
if (other > 0) parts.push(other + ' other');
parts.push(remote + ' remote share' + (remote === 1 ? '' : 's'));
return parts.join(' · ');
})()),
])),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Uptime'),
el('div', {class: 'value', style: 'font-size:22px'}, fmtUptime(status.uptime_secs)),
el('div', {class: 'trend'}, 'OpenPXE ' + status.version),
])),
]);
// Recent connections — the operator's at-a-glance "who tried to
// boot" log. Use last_seen desc (already sorted by API).
const recent = clients.slice(0, 8);
const recentRows = recent.map(c => {
const g = entries.find(g => g.mac === c.mac);
let status = el('span', {class: 'tag ok'}, 'active');
if (g && g.assigned_target) status = el('span', {class:'tag ok'}, 'assigned: ' + g.assigned_target);
else if (g) status = el('span', {class:'tag accent'}, '#' + g.position + ' in queue');
return el('tr', {}, [
el('td', {class: 'mono'}, c.mac),
el('td', {}, c.last_ip ? String(c.last_ip) : '-'),
el('td', {}, el('span', {class:'tag arch'}, archLabel(c.arch))),
el('td', {}, fmtAgo(c.last_seen)),
el('td', {}, status),
]);
});
const recentBlock = el('div', {class: 'card'}, [
el('header', {}, [
el('h2', {}, 'Recent connections'),
el('span', {class: 'sub'}, clients.length + ' total'),
]),
recent.length
? el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th',{},'MAC'), el('th',{},'IP'), el('th',{},'Arch'),
el('th',{},'Last seen'), el('th',{},'Status'),
])),
el('tbody', {}, recentRows),
])
: el('div', {class: 'empty'}, 'No PXE clients have contacted this server yet.'),
]);
// At-a-glance pool of problem images — Bootimus-style early warning.
const settings = status.settings;
const problems = isos.map(i => ({i, b: bootability(i, settings)})).filter(x => !x.b.ok);
const problemsBlock = problems.length ? el('div', {class:'card'}, [
el('header', {}, [el('h2', {}, 'Non-bootable images')]),
el('div', {class:'body'},
problems.map(({i, b}) => el('div', {class:'row-warn'},
'⚠ ' + i.filename + ' — ' + b.reason)))
]) : null;
return el('div', {class:'grid'}, [stats, diskSpaceCard(disk), recentBlock, problemsBlock].filter(Boolean));
},
network: async () => {
const net = await getJSON('/api/network');
const dns = el('input', {type:'text', value: net.dns_server || '',
placeholder: 'Optional, e.g. 8.8.8.8 or 1.1.1.1'});
const msg = el('div', {class:'msg'});
const save = el('button', {onclick: async () => {
const r = await putJSON('/api/network', { dns_server: dns.value });
if (r.status === 204) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; }
else { msg.textContent = 'Save failed: ' + r.status; msg.className = 'msg err'; }
}}, 'Save');
const networkCard = el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Network')),
el('div', {class:'body'}, [
el('div', {class:'kv'}, [
el('div', {class:'k'}, 'Server IP'),
el('div', {class:'v'}, net.server_ip || '?'),
el('div', {class:'k'}, 'NIC name'),
el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'),
el('div', {class:'k'}, 'Subnet mask'),
el('div', {class:'v'}, net.subnet_mask || '?'),
el('div', {class:'k'}, 'Gateway'),
el('div', {class:'v'}, net.gateway || '?'),
el('div', {class:'k'}, 'Public base URL'),
el('div', {class:'v'}, net.public_base_url),
]),
el('p', {class:'msg'},
'Server IP, NIC, mask, and gateway are auto-detected at startup. ' +
'To change them, set OPENPXE_PUBLIC_IP and restart — editing them ' +
'from a hot UI would silently break PXE for every client mid-boot.'),
el('label', {class:'field', style:'margin-top:18px'}, [
el('span', {class:'name'}, 'DNS server (optional, informational)'),
dns,
el('span', {class:'hint'},
'OpenPXE does not run a DNS server itself; this field records ' +
'what your upstream DNS is so you don\'t have to dig it out at ' +
'3 AM during a deployment.'),
]),
save, msg,
]),
]);
return el('div', {class:'grid'}, [networkCard]);
},
queue: async () => {
const [{ entries = [] }, isos, unattRes] = await Promise.all([
getJSON('/api/queue'), getJSON('/api/isos'),
getJSON('/api/unattended').catch(() => ({ files: [] })),
]);
const unattendedFiles = unattRes.files || [];
const targets = isos.flatMap(i => i.boot_entries.map(e => ({
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family)
})));
const pick = el('select', {},
[el('option', {value: ''}, '— choose an image —')]
.concat(targets.map(t => el('option', {value: t.id}, t.title)))
);
const launch = el('button', {}, 'Launch for all waiting');
const msg = el('div', {class:'msg'});
launch.onclick = async () => {
if (!pick.value) { msg.textContent = 'Pick an image first.'; msg.className='msg err'; return; }
const r = await postJSON('/api/queue/assign', { target: pick.value, entry_ids: [] });
if (!r.ok) { msg.textContent = 'Assign failed: ' + r.status; msg.className='msg err'; return; }
const j = await r.json();
if (!j.ok) { msg.textContent = 'Assign failed: ' + (j.error || 'unknown'); msg.className='msg err'; return; }
msg.textContent = 'Launched ' + j.assigned + ' client' + (j.assigned===1?'':'s') + ' → ' + j.target;
msg.className = 'msg ok';
render('queue');
};
const track = entries.length
? el('div', {class:'queue-track'},
entries.map(g => {
const prof = g.profile || {};
const hasProfile = prof.auto_hostname || prof.auto_ip || prof.unattended_file;
const profSummary = hasProfile
? el('div', {class:'meta', style:'margin-top:2px'},
'⚙ ' + [
prof.auto_hostname ? 'host ' + prof.auto_hostname : null,
prof.auto_ip ? 'ip ' + prof.auto_ip : null,
prof.unattended_file ? 'unattended: ' + prof.unattended_file : null,
].filter(Boolean).join(' · '))
: null;
return el('div', {class:'queue-row' + (g.assigned_target ? ' assigned' : '')}, [
el('div', {class:'pos'}, '#' + g.position),
el('div', {}, [
el('div', {class:'mac'}, g.mac),
el('div', {class:'meta'},
(g.ip ? String(g.ip) + ' · ' : '') + archLabel(g.arch) + ' · joined ' + fmtAgo(g.joined_at)),
profSummary,
]),
el('div', {}, g.assigned_target
? el('span', {class:'tag ok'}, '→ ' + g.assigned_target)
: el('span', {class:'tag accent'}, 'waiting')),
// v0.5.2: per-device deployment profile (auto hostname/IP +
// unattended file), same fields as a Hosts pin.
el('button', {class: hasProfile ? 'accent' : 'ghost', onclick: () => {
const fields = buildProfileFields(prof, unattendedFiles, 'form-row');
openModal('Deployment profile · ' + g.mac, [
el('p', {class:'msg', style:'margin-bottom:12px'},
'On assignment this device boots with the chosen unattended ' +
'file; {{HOSTNAME}}/{{IP}}/{{MAC}} are filled into the answer file.'),
fields.wrap,
], async (msg) => {
const r = await putJSON('/api/queue/' + encodeURIComponent(g.id) + '/profile', fields.read());
if (r.ok) { render('queue'); return true; }
msg.textContent = 'Save failed: ' + (await r.text());
msg.className = 'msg err';
return false;
});
}}, 'Profile'),
el('button', {class:'ghost', onclick: async () => {
await fetch('/api/queue/' + encodeURIComponent(g.id), {method:'DELETE'});
render('queue');
}}, 'Release'),
]);
})
)
: el('div', {class:'empty'},
'No clients queued. Boot a client and choose "Queued Deployment" in the PXE menu.');
const imaging = entries.filter(g => g.assigned_target).length;
return el('div', {class:'grid'}, [
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Status')),
queueProgressWidget(imaging, entries.length),
]),
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Launch image for queued clients')),
el('div', {class:'body'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Target image'),
pick,
el('span', {class:'hint'},
'Selecting "Launch" starts every waiting client on the chosen image simultaneously.'),
]),
launch, msg,
]),
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Queue positions'),
el('span', {class:'sub'}, entries.length + ' waiting'),
]),
el('div', {class:'body'}, track),
]),
]);
},
storage: async () => {
// v0.4.65: kernel-mount NFS replaced with userspace SMB via
// smbclient — works in any container regardless of host kernel
// modules or capabilities.
// v0.4.67: NFSv3 added back as an in-process Rust client
// (nfs3_client crate). Both protocols available side-by-side;
// operators pick whichever their NAS prefers.
const [isos, settings, smbRes, nfsRes, sftpRes, disk, unattRes] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'),
getJSON('/api/smb-shares'),
getJSON('/api/nfs-shares'),
getJSON('/api/sftp-shares'),
getJSON('/api/storage/disk').catch(() => ({
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
})),
getJSON('/api/unattended').catch(() => ({ files: [] })),
]);
const shares = smbRes.shares || [];
const nfsShares = nfsRes.shares || [];
const sftpShares = sftpRes.shares || [];
const unattendedFiles = unattRes.files || [];
// ── Upload card ──
const drop = el('div', {class:'drop', id:'drop'}, [
el('div', {}, ['Drop an ', el('strong', {}, '.iso'), ' here, or click to choose.']),
el('div', {style:'font-size:12px;margin-top:6px'},
'Linux + Windows installers auto-detected on upload. Streaming, no 502s on big files.'),
]);
const file = el('input', {type:'file', accept:'.iso,application/octet-stream',
style:'display:none', id:'file'});
const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'}));
const upMsg = el('div', {class:'msg', id:'upmsg'});
// v0.5.8: cancel button — shown only while an upload is in flight.
const cancelUpload = el('button', {class:'danger', type:'button',
style:'display:none;margin-top:12px', id:'cancel-upload'}, 'Cancel upload');
drop.onclick = () => file.click();
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
drop.addEventListener('dragleave', () => drop.classList.remove('hover'));
drop.addEventListener('drop', e => {
e.preventDefault(); drop.classList.remove('hover');
if (e.dataTransfer.files[0]) upload(e.dataTransfer.files[0]);
});
file.onchange = () => { if (file.files[0]) upload(file.files[0]); };
// Chunked upload telemetry. The old browser path posted one huge
// multipart body, which left operators staring at 0% when a reverse
// proxy buffered or rejected the request before OpenPXE saw it. This
// path writes small raw chunks; each acknowledged chunk advances the
// bar and leaves a visible .partial file in the ISO directory.
async function upload(f) {
const started = Date.now();
const bar = $('#bar');
const setStatus = (text, cls) => { upMsg.textContent = text; upMsg.className = 'msg ' + (cls || ''); };
const update = (loaded, total, phase) => {
const pct = total > 0 ? Math.min(100, (loaded / total) * 100) : 100;
bar.style.width = pct.toFixed(1) + '%';
const elapsed = Math.max(0.001, (Date.now() - started) / 1000);
const rate = loaded > 0 ? loaded / elapsed : 0;
const remain = rate > 0 ? (total - loaded) / rate : 0;
setStatus(
phase + ' ' + f.name + ' - ' +
fmtBytes(loaded) + ' of ' + fmtBytes(total) +
' (' + pct.toFixed(1) + '%, ' + fmtBytes(rate) + '/s' +
(remain > 0 ? ', ' + Math.ceil(remain) + 's left' : '') + ')');
};
const failText = async (r) => {
const text = (await r.text()).slice(0, 240);
let hint = '';
if (r.status === 413) hint = ' - body too large. A proxy likely rejected this chunk.';
else if (r.status === 502) hint = ' - bad gateway. Proxy lost the upstream mid-stream.';
else if (r.status === 504) hint = ' - gateway timeout. Try the LAN IP directly.';
else if (r.status === 409) hint = ' - name conflict or offset mismatch. Remove the old ISO and retry.';
return 'HTTP ' + r.status + ' ' + text + hint;
};
let uploadId = null;
// v0.5.8: cancel + leave-page guard. The AbortController stops the
// in-flight chunk; the beforeunload listener warns the operator
// that navigating away aborts the upload (the server-side partial
// is then cleaned up by the DELETE in the catch below).
const ac = new AbortController();
let canceled = false;
const warnLeave = (e) => { e.preventDefault(); e.returnValue = ''; return ''; };
window.addEventListener('beforeunload', warnLeave);
cancelUpload.style.display = '';
cancelUpload.onclick = () => { canceled = true; ac.abort(); };
setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')');
prog.classList.add('active');
bar.style.width = '1%';
try {
const begin = await postJSON('/api/uploads', {
filename: f.name,
size_bytes: f.size,
});
if (!begin.ok) throw new Error(await failText(begin));
const session = await begin.json();
uploadId = session.upload_id;
const chunkSize = Math.max(1024 * 1024, Number(session.chunk_size || 8 * 1024 * 1024));
let offset = Number(session.offset || 0);
let finished = null;
do {
const end = Math.min(offset + chunkSize, f.size);
const complete = end >= f.size;
const r = await fetch('/api/uploads/' + encodeURIComponent(uploadId), {
method: 'PUT',
headers: {
'x-openpxe-upload-offset': String(offset),
'x-openpxe-upload-complete': complete ? 'true' : 'false',
},
body: f.slice(offset, end),
signal: ac.signal,
});
if (!r.ok) throw new Error(await failText(r));
const j = await r.json();
offset = Number(j.offset || end);
update(offset, f.size, complete ? 'Analyzing' : 'Uploading');
if (j.complete) finished = j.iso || true;
} while (!finished);
setStatus('Uploaded and analyzed: ' + f.name + ' (' + fmtBytes(f.size) + ')', 'ok');
render('storage');
} catch (err) {
if (uploadId) {
try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); }
catch {}
}
if (canceled || (err && err.name === 'AbortError')) {
setStatus('Upload canceled — partial file discarded.', '');
} else {
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
}
} finally {
window.removeEventListener('beforeunload', warnLeave);
cancelUpload.style.display = 'none';
cancelUpload.onclick = null;
prog.classList.remove('active');
if (!upMsg.className.includes('ok')) bar.style.width = '0';
}
}
// ── ISO table (mixed local + SMB) ──
// Each row gets a "Password" cell that toggles a small inline
// editor (a checkbox + a password field + Save button) inside the
// *next* row of the table. Keeps the markup flat and avoids the
// overhead of a real modal.
const rowsAndEditors = [];
// v0.5.8: list Available images alphabetically by filename
// (case-insensitive, natural numeric order) instead of newest-first.
const sortedIsos = [...isos].sort((a, b) =>
(a.filename || '').localeCompare(b.filename || '', undefined, { sensitivity: 'base', numeric: true }));
sortedIsos.forEach(i => {
const b = bootability(i, settings);
// v0.4.65: SMB userspace consumer (smbclient).
// v0.4.67: NFS back as in-process Rust client (nfs3_client).
// Both render with the same "remote" badge colour — they
// share the same "on a remote share, can't be deleted from
// here" semantics in the UI.
const isSmb = i.source && i.source.kind === 'smb';
const isNfs = i.source && i.source.kind === 'nfs';
const isRemote = isSmb || isNfs;
const protectedNow = !!i.password_hash;
// The inline editor row is hidden by default; the Password
// button toggles its `display`. Pre-built so toggle is cheap.
const pwCheck = el('input', {type:'checkbox'});
pwCheck.checked = protectedNow;
const pwInput = el('input', {
type: 'password', spellcheck: 'false',
autocomplete: 'new-password', autocapitalize: 'off',
placeholder: protectedNow ? '(unchanged — type to replace)' : 'choose a password',
});
const pwInputWrap = el('label', {class:'field', style:'flex:1;margin:0'}, [
el('span', {class:'name'}, 'Password'),
pwInput,
]);
// Toggle the password field's visibility off when the checkbox
// is unchecked, so the operator's intent is unambiguous on Save.
const refreshFieldVisibility = () => {
pwInputWrap.style.display = pwCheck.checked ? '' : 'none';
};
pwCheck.onchange = refreshFieldVisibility;
const pwMsg = el('div', {class:'msg', style:'margin-top:6px'});
const pwSave = el('button', {style:'flex:none', onclick: async () => {
let resp;
if (pwCheck.checked) {
// Empty input + previously protected = keep the old password
// (operator just toggled the box on but didn't type). We
// detect this by sending the API only when the field has
// content; otherwise no-op + show hint.
if (!pwInput.value && !protectedNow) {
pwMsg.textContent = 'Enter a password to enable.';
pwMsg.className = 'msg err';
return;
}
if (!pwInput.value && protectedNow) {
pwMsg.textContent = 'Password unchanged.';
pwMsg.className = 'msg ok';
return;
}
resp = await putJSON(
'/api/isos/' + encodeURIComponent(i.id) + '/password',
{ password: pwInput.value });
} else {
resp = await fetch(
'/api/isos/' + encodeURIComponent(i.id) + '/password',
{method: 'DELETE'});
}
if (resp.ok || resp.status === 204) {
// Wipe the input field before re-rendering so the
// plaintext doesn't sit in DOM longer than necessary.
pwInput.value = '';
render('storage');
} else {
const t = await resp.text();
pwMsg.textContent = 'Save failed: ' + t;
pwMsg.className = 'msg err';
}
}}, 'Save password');
const editorCells = el('td', {colspan: '7', style:'background:var(--bg-panel-2);padding:14px 18px'}, [
el('div', {style:'display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap'}, [
el('label', {class:'check', style:'flex:none;margin:0'}, [
pwCheck,
el('span', {}, 'Password protect this image'),
]),
pwInputWrap,
pwSave,
]),
el('div', {class:'msg', style:'margin-top:8px;font-size:11.5px'},
'Operators booting this ISO will be prompted on the PXE client. ' +
'Stored bcrypt-hashed; the plaintext never leaves the request.'),
pwMsg,
]);
const editorRow = el('tr', {style:'display:none'}, editorCells);
refreshFieldVisibility();
// Type cell becomes an OS/Tools <select>. Default is OS (everything
// routes through the family-detected installer submenu); switching
// to Tools moves the ISO into the Tools submenu next to memtest.
// The detected family stays visible as a small subtitle below the
// selector so the operator hasn't lost that information.
const catSel = el('select', {
style: 'min-width:96px;background:var(--bg);color:var(--fg);' +
'border:1px solid var(--border);border-radius:var(--radius);' +
'padding:4px 8px;font:inherit;font-size:12px'
}, [
el('option', {value: 'os'}, 'OS'),
el('option', {value: 'tools'}, 'Tools'),
]);
catSel.value = (i.category || 'os');
catSel.onchange = async () => {
const r = await putJSON('/api/isos/' + encodeURIComponent(i.id) + '/category',
{ category: catSel.value });
if (!r.ok) {
const t = await r.text();
alert('Category change failed: ' + t);
}
render('storage');
};
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
el('td', {}, [
el('div', {style:'display:flex;align-items:center;gap:8px'}, [
protectedNow ? el('span', {
title: 'Password protected',
style:'color:var(--accent);font-size:13px'
}, '🔒') : null,
el('span', {}, i.filename),
]),
!b.ok ? el('div', {class:'row-warn'}, '⚠ ' + b.reason)
: (b.warn ? el('div', {class:'row-warn'}, '⚠ ' + b.warn) : null),
]),
el('td', {}, [
catSel,
el('div', {style:'color:var(--fg-dim);font-size:11px;margin-top:4px'},
familyLabel(i.introspection.family)),
]),
el('td', {class:'num'}, fmtBytes(i.size_bytes)),
el('td', {},
el('span', {class:'src-badge' + (isRemote ? ' nfs' : '')},
isSmb ? ('smb:' + i.source.share_id)
: isNfs ? ('nfs:' + i.source.share_id) : 'local')),
el('td', {},
protectedNow
? el('span', {class:'tag accent'}, 'protected')
: el('span', {class:'tag', style:'opacity:.55'}, 'open')),
el('td', {}, fmtAgo(i.uploaded_at)),
el('td', {style:'text-align:right;white-space:nowrap'}, [
el('button', {class:'ghost', style:'margin-right:6px', onclick: () => {
editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none';
}}, protectedNow ? 'Password ✎' : 'Set password'),
isRemote
// v0.4.65/v0.4.67: remote-sourced ISOs live on the
// share — OpenPXE doesn't own those bytes. Surface a
// tag instead of a destructive button.
? el('span', {class:'tag', style:'opacity:.6'},
isSmb ? 'on SMB' : 'on NFS')
: el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove this image?')) return;
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
render('storage');
}}, 'Remove'),
]),
]);
rowsAndEditors.push(tr, editorRow);
});
const isoTable = isos.length
? el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th',{},'Name'), el('th',{},'Type'),
el('th',{class:'num'},'Size'),
el('th',{},'Source'), el('th',{},'Auth'),
el('th',{},'Uploaded'), el('th',{},''),
])),
el('tbody', {}, rowsAndEditors),
])
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.');
// ── Remote shares section (v0.5.1) ──
// SMB + NFS unified into one "Remote shares" card with a protocol
// dropdown. The two protocols keep their own backend endpoints
// (/api/smb-shares, /api/nfs-shares) and the same add/scan/remove
// UX; the form just swaps the relevant fields. This declutters the
// Storage tab and leaves room for a future "Config files" card.
const shareMsg = el('div', {class:'msg'});
// Shared structured-error renderer ({error, stderr, hint}) for both
// protocols' add calls.
const showShareError = async (r) => {
let bodyJson = null;
let raw = null;
try { bodyJson = await r.clone().json(); }
catch (_) { raw = await r.text().catch(() => 'connect failed'); }
const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed');
const hint = bodyJson && bodyJson.hint;
const parts = [el('div', {}, [
el('strong', {}, 'Connect failed: '),
document.createTextNode(msg),
])];
if (hint) {
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
}
shareMsg.replaceChildren(...parts);
shareMsg.className = 'msg err';
};
// Protocol picker — swaps which field block is visible. v0.5.2:
// NFS is the default (listed first) — it has no credential fields,
// so the form lands cleaner than the SMB guest/user/password row.
const protoSelect = el('select', {}, [
el('option', {value:'nfs'}, 'NFS (NFSv3)'),
el('option', {value:'smb'}, 'SMB / CIFS'),
el('option', {value:'sftp'}, 'SFTP (SSH)'),
]);
// SMB inputs.
const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'});
const smbShare = el('input', {type:'text', placeholder:'isos'});
const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true;
const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'});
const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'});
const syncAuthDisabled = () => {
smbUser.disabled = smbGuest.checked;
smbPass.disabled = smbGuest.checked;
smbUser.style.opacity = smbGuest.checked ? '0.55' : '1';
smbPass.style.opacity = smbGuest.checked ? '0.55' : '1';
};
smbGuest.addEventListener('change', syncAuthDisabled);
syncAuthDisabled();
const smbFields = el('div', {}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'SMB server'), smbServer]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Share name'), smbShare]),
]),
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
el('label', {class:'check'}, [smbGuest, el('span', {}, 'Guest (anonymous read)')]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username'), smbUser]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Password'), smbPass]),
]),
]);
const smbRowEls = shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
el('div', {}, [
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'SMB'),
document.createTextNode('//' + m.server + '/' + m.share)]),
el('div', {class:'meta'},
(m.guest ? 'guest' : ('user: ' + (m.username || '?'))) + ' · ' +
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
]),
el('button', {class:'ghost', onclick: async () => {
const r = await postJSON('/api/smb-shares/' + encodeURIComponent(m.id) + '/scan', {});
if (r.ok) render('storage');
}}, 'Re-scan'),
el('button', {class:'danger', onclick: async () => {
if (!confirm('Forget //' + m.server + '/' + m.share + '?')) return;
await fetch('/api/smb-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
render('storage');
}}, 'Remove'),
el('span'),
]));
// NFS inputs. The NFSv3 client is in-process (nfs3_client crate) so
// NFS-sourced ISOs support HTTP Range — SMB-sourced ones can't seek
// mid-stream. No auth fields: NFSv3 access is gated by client IP on
// the server's export list, not client-supplied credentials.
const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
const nfsFields = el('div', {}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'NFS server'), nfsServerIn]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Export path'), nfsExportIn]),
]),
]);
// SFTP inputs (v0.5.5). Pure-Rust russh client, in-process, so
// SFTP-sourced ISOs support HTTP Range like NFS. Auth is password
// OR an SSH private key (PEM, optional passphrase); the server's
// host key is pinned trust-on-first-use on the first connect.
const sftpServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
const sftpExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
const sftpUserIn = el('input', {type:'text', placeholder:'root'});
const sftpPortIn = el('input', {type:'number', placeholder:'22', min:'1', max:'65535'});
const sftpAuthMode = el('select', {}, [
el('option', {value:'password'}, 'Password'),
el('option', {value:'key'}, 'SSH private key'),
]);
const sftpPassIn = el('input', {type:'password', placeholder:'••••••••'});
const sftpKeyIn = el('textarea', {rows:'4',
placeholder:'-----BEGIN OPENSSH PRIVATE KEY-----',
style:'width:100%;font-family:ui-monospace,monospace;font-size:12px;resize:vertical'});
const sftpPassphraseIn = el('input', {type:'password',
placeholder:'(only if the private key is encrypted)'});
const sftpPassBlock = el('label', {class:'field'},
[el('span', {class:'name'}, 'Password'), sftpPassIn]);
const sftpKeyBlock = el('div', {}, [
el('label', {class:'field'},
[el('span', {class:'name'}, 'SSH private key (PEM)'), sftpKeyIn]),
el('label', {class:'field', style:'margin-top:10px'},
[el('span', {class:'name'}, 'Key passphrase (optional)'), sftpPassphraseIn]),
]);
const syncSftpAuth = () => {
const key = sftpAuthMode.value === 'key';
sftpPassBlock.style.display = key ? 'none' : '';
sftpKeyBlock.style.display = key ? '' : 'none';
};
sftpAuthMode.addEventListener('change', syncSftpAuth);
syncSftpAuth();
const sftpFields = el('div', {}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'SSH server'), sftpServerIn]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Export path'), sftpExportIn]),
]),
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username'), sftpUserIn]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Port'), sftpPortIn]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Auth'), sftpAuthMode]),
]),
el('div', {style:'margin-top:14px'}, [sftpPassBlock, sftpKeyBlock]),
]);
// Swap the visible field block + clear any stale message.
const syncProto = () => {
const p = protoSelect.value;
smbFields.style.display = p === 'smb' ? '' : 'none';
nfsFields.style.display = p === 'nfs' ? '' : 'none';
sftpFields.style.display = p === 'sftp' ? '' : 'none';
shareMsg.replaceChildren();
shareMsg.className = 'msg';
};
protoSelect.addEventListener('change', syncProto);
// One add button; dispatches to the selected protocol's endpoint.
const addShare = el('button', {onclick: async () => {
if (protoSelect.value === 'smb') {
if (!smbServer.value || !smbShare.value) {
shareMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
shareMsg.className = 'msg err'; return;
}
if (!smbGuest.checked && !smbUser.value) {
shareMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.'));
shareMsg.className = 'msg err'; return;
}
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
shareMsg.className = 'msg';
const body = { server: smbServer.value, share: smbShare.value, guest: smbGuest.checked };
if (!smbGuest.checked) { body.username = smbUser.value; body.password = smbPass.value; }
const r = await postJSON('/api/smb-shares', body);
if (r.ok) {
shareMsg.replaceChildren(document.createTextNode('Connected.'));
shareMsg.className = 'msg ok';
render('storage');
} else { await showShareError(r); }
} else if (protoSelect.value === 'sftp') {
if (!sftpServerIn.value || !sftpExportIn.value || !sftpUserIn.value) {
shareMsg.replaceChildren(document.createTextNode('Server, export, and username are required.'));
shareMsg.className = 'msg err'; return;
}
const useKey = sftpAuthMode.value === 'key';
if (useKey && !sftpKeyIn.value.trim()) {
shareMsg.replaceChildren(document.createTextNode('Paste the SSH private key, or switch Auth to Password.'));
shareMsg.className = 'msg err'; return;
}
if (!useKey && !sftpPassIn.value) {
shareMsg.replaceChildren(document.createTextNode('Password is required, or switch Auth to SSH private key.'));
shareMsg.className = 'msg err'; return;
}
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
shareMsg.className = 'msg';
const body = {
server: sftpServerIn.value,
export: sftpExportIn.value,
username: sftpUserIn.value,
};
if (sftpPortIn.value) { body.port = parseInt(sftpPortIn.value, 10); }
if (useKey) {
body.private_key = sftpKeyIn.value;
if (sftpPassphraseIn.value) { body.passphrase = sftpPassphraseIn.value; }
} else {
body.password = sftpPassIn.value;
}
const r = await postJSON('/api/sftp-shares', body);
if (r.ok) {
shareMsg.replaceChildren(document.createTextNode('Connected.'));
shareMsg.className = 'msg ok';
render('storage');
} else { await showShareError(r); }
} else {
if (!nfsServerIn.value || !nfsExportIn.value) {
shareMsg.replaceChildren(document.createTextNode('Server and export are required.'));
shareMsg.className = 'msg err'; return;
}
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
shareMsg.className = 'msg';
const r = await postJSON('/api/nfs-shares', { server: nfsServerIn.value, export: nfsExportIn.value });
if (r.ok) {
shareMsg.replaceChildren(document.createTextNode('Connected.'));
shareMsg.className = 'msg ok';
render('storage');
} else { await showShareError(r); }
}
}}, 'Add share');
const nfsRowEls = nfsShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
el('div', {}, [
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'NFS'),
document.createTextNode(m.server + ':' + m.export)]),
el('div', {class:'meta'},
'NFSv3 · ' + (m.reachable ? m.iso_count + ' isos' : 'not reachable')),
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
]),
el('button', {class:'ghost', onclick: async () => {
const r = await postJSON('/api/nfs-shares/' + encodeURIComponent(m.id) + '/scan', {});
if (r.ok) render('storage');
}}, 'Re-scan'),
el('button', {class:'danger', onclick: async () => {
if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return;
await fetch('/api/nfs-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
render('storage');
}}, 'Remove'),
el('span'),
]));
const sftpRowEls = sftpShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
el('div', {}, [
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'SFTP'),
document.createTextNode(m.username + '@' + m.server + ':' + m.export)]),
el('div', {class:'meta'},
'SSH · ' + (m.auth === 'key' ? 'key' : 'password') + ' · ' +
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
m.host_key_fingerprint
? el('div', {style:'margin-top:4px;opacity:.65;font-size:11px;font-family:ui-monospace,monospace;word-break:break-all'},
'host key ' + m.host_key_fingerprint)
: null,
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
]),
el('button', {class:'ghost', onclick: async () => {
const r = await postJSON('/api/sftp-shares/' + encodeURIComponent(m.id) + '/scan', {});
if (r.ok) render('storage');
}}, 'Re-scan'),
el('button', {class:'danger', onclick: async () => {
if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return;
await fetch('/api/sftp-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
render('storage');
}}, 'Remove'),
el('span'),
]));
const totalShares = shares.length + nfsShares.length + sftpShares.length;
const remoteRows = totalShares
? [...smbRowEls, ...nfsRowEls, ...sftpRowEls]
: [el('div', {class:'empty'}, 'No remote shares configured.')];
syncProto();
const diskCard = diskSpaceCard(disk);
// ── Advanced: unattended answer-file upload (v0.5.2) ──
// Mirrors the Settings "Advanced" disclosure. Kickstart / Preseed /
// Autoinstall / Windows answer files land in their own directory
// (never the ISO listing or PXE menu) and are referenced by host
// pins + queue profiles.
const unattMsg = el('div', {class:'msg', style:'margin-top:10px'});
const unattFile = el('input', {
type:'file',
accept:'.ks,.cfg,.seed,.yaml,.yml,.xml',
style:'display:none', id:'unatt-file',
});
async function uploadUnattended(f) {
const fd = new FormData(); fd.append('file', f, f.name);
unattMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…';
unattMsg.className = 'msg';
const r = await fetch('/api/unattended', {method:'POST', body: fd});
if (r.ok) {
unattMsg.textContent = 'Stored ' + f.name + '.';
unattMsg.className = 'msg ok';
render('storage');
} else {
unattMsg.textContent = 'Upload failed: ' + (await r.text());
unattMsg.className = 'msg err';
}
}
const unattDrop = el('div', {class:'drop', id:'unatt-drop'}, [
el('div', {}, 'Drop a Kickstart, Preseed, Autoinstall, or Answer File here.'),
el('div', {style:'font-size:12px;margin-top:6px'},
'Accepted: .ks · .cfg · .seed · .yaml · .yml · .xml (or user-data). ' +
'Use {{HOSTNAME}}, {{IP}}, {{MAC}} as placeholders — they are filled in per host at boot.'),
]);
unattDrop.onclick = () => unattFile.click();
unattDrop.addEventListener('dragover', e => { e.preventDefault(); unattDrop.classList.add('hover'); });
unattDrop.addEventListener('dragleave', () => unattDrop.classList.remove('hover'));
unattDrop.addEventListener('drop', e => {
e.preventDefault(); unattDrop.classList.remove('hover');
if (e.dataTransfer.files[0]) uploadUnattended(e.dataTransfer.files[0]);
});
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
const unattRows = unattendedFiles.length
? unattendedFiles.map(f => el('div', {class:'nfs-row'}, [
el('span', {class:'dot ok'}),
el('div', {}, [
el('div', {class:'id'}, [
el('span', {class:'proto-badge'}, unattendedKindLabel(f.kind)),
document.createTextNode(f.filename),
]),
el('div', {class:'meta'}, fmtBytes(f.size_bytes) + ' · id ' + f.id),
]),
el('span'),
el('button', {class:'danger', onclick: async () => {
if (!confirm('Delete unattended file ' + f.filename + '?')) return;
await fetch('/api/unattended/' + encodeURIComponent(f.id), {method:'DELETE'});
render('storage');
}}, 'Delete'),
el('span'),
]))
: [el('div', {class:'empty'}, 'No unattended files yet.')];
const unattendedAdvanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
el('summary', {class:'advanced-summary'}, 'Advanced'),
el('div', {class:'card', style:'margin-top:14px'}, [
el('header', {}, [
el('h2', {}, 'Unattended file upload'),
el('span', {class:'sub'}, unattendedFiles.length + ' file' + (unattendedFiles.length === 1 ? '' : 's')),
]),
el('div', {class:'body'}, [
unattDrop, unattFile, unattMsg,
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
el('p', {class:'msg', style:'margin-top:14px'},
'These answer files drive unattended installs. Attach one to a ' +
'host pin (Hosts tab) or a queued device (Queue → Profile); on ' +
'boot OpenPXE injects the matching kernel argument and serves the ' +
'file with the hosts name/IP filled in. Stored separately from ISOs.'),
]),
]),
]);
return el('div', {}, [el('div', {class:'grid'}, [
diskCard,
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Upload ISO')),
el('div', {class:'body'}, [drop, file, prog, upMsg, cancelUpload]),
]),
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a
// protocol dropdown. Backend endpoints are unchanged; this is a
// pure UI consolidation that declutters the Storage tab.
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Remote shares'),
el('span', {class:'sub'}, totalShares + ' configured'),
]),
el('div', {class:'body'}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Protocol'),
protoSelect,
]),
el('span'),
]),
el('div', {style:'margin-top:14px'}, [smbFields, nfsFields, sftpFields]),
addShare, shareMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows),
el('p', {class:'msg', style:'margin-top:14px'},
'Remote .iso libraries are read on demand — no local cache to ' +
'preserve disk usage. Support for NFS 3.0, SMB, and SFTP (SSH). ' +
'Ensure that the hosts IP address is provisioned.'),
]),
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Available images'),
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
]),
isoTable,
]),
]), unattendedAdvanced]);
},
hosts: async () => {
const [{ hosts = [] }, isos, bootLogRes, unattRes] = await Promise.all([
getJSON('/api/hosts'), getJSON('/api/isos'),
getJSON('/api/boot-log').catch(() => ({ events: [] })),
getJSON('/api/unattended').catch(() => ({ files: [] })),
]);
const bootEvents = bootLogRes.events || [];
const unattendedFiles = unattRes.files || [];
const targets = isos.flatMap(i => i.boot_entries.map(e => ({
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family),
})));
// Reserved menu shortcuts that the operator might want to bind.
const reserved = [
{id: '_local', title: '↳ Boot from Local HDD (built-in)'},
{id: '_queue', title: '↳ Queued Deployment (built-in)'},
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
];
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff', spellcheck:'false'});
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
const targetSel = el('select', {},
[el('option', {value:''}, '— choose a target —')]
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
.concat(targets.map(t => el('option', {value: t.id}, t.title))));
const msg = el('div', {class:'msg'});
// v0.5.2: optional unattended-install profile — auto hostname, auto
// IP, and an answer-file picker. On boot, a bound MAC with an
// unattended file selected has the right kernel arg injected
// (inst.ks / preseed url / autoinstall ds=nocloud) and the
// hostname/IP templated into the served answer file.
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
const upsertBtn = el('button', {onclick: async () => {
if (!macInput.value || !targetSel.value) {
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
}
const r = await postJSON('/api/hosts', Object.assign({
mac: macInput.value, target: targetSel.value, label: labelInput.value,
}, profileFields.read()));
if (r.ok) {
msg.textContent = 'Saved.'; msg.className = 'msg ok';
render('hosts');
} else {
const t = await r.text();
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
}
}}, 'Bind MAC to target');
const rows = hosts.map(h => {
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
// table is bound hosts). The button reports its own state inline
// so there's no shared toast to thread through.
const wakeBtn = el('button', {class:'ghost', onclick: async () => {
wakeBtn.disabled = true;
const original = wakeBtn.textContent;
wakeBtn.textContent = 'Waking…';
try {
const r = await postJSON('/api/hosts/' + encodeURIComponent(h.mac) + '/wol', {});
wakeBtn.textContent = r.ok ? 'Sent ✓' : 'Failed';
} catch (e) {
wakeBtn.textContent = 'Failed';
}
setTimeout(() => { wakeBtn.textContent = original; wakeBtn.disabled = false; }, 2500);
}}, 'Wake');
const autoDeploy = (h.auto_hostname || h.auto_ip || h.unattended_file)
? el('div', {style:'font-size:12px;line-height:1.5'}, [
h.unattended_file ? el('div', {}, [el('span', {class:'tag accent'}, 'unattended'), document.createTextNode(' ' + h.unattended_file)]) : null,
h.auto_hostname ? el('div', {class:'mono'}, 'host: ' + h.auto_hostname) : null,
h.auto_ip ? el('div', {class:'mono'}, 'ip: ' + h.auto_ip) : null,
])
: el('span', {class:'tag'}, '—');
return el('tr', {}, [
el('td', {class:'mono'}, h.mac),
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
el('td', {class:'mono'}, h.target),
el('td', {}, autoDeploy),
el('td', {}, fmtAgo(h.updated_at)),
el('td', {style:'text-align:right;white-space:nowrap'}, [
wakeBtn,
el('button', {class:'danger', style:'margin-left:8px', onclick: async () => {
if (!confirm('Remove binding for ' + h.mac + '?')) return;
await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
render('hosts');
}}, 'Remove'),
]),
]);
});
const table = hosts.length
? el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th',{},'MAC'), el('th',{},'Label'),
el('th',{},'Target'), el('th',{},'Auto-deploy'),
el('th',{},'Updated'), el('th',{},''),
])),
el('tbody', {}, rows),
])
: el('div', {class:'empty'}, 'No host bindings yet. Pin a MAC to a boot target to skip the menu for that machine.');
return el('div', {class:'grid'}, [
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
el('div', {class:'body'}, [
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
el('label', {class:'field', style:'grid-column:1 / -1'}, [
el('span', {class:'name'}, 'Target'),
targetSel,
el('span', {class:'hint'},
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
]),
]),
el('div', {style:'margin-top:16px'}, profileFields.wrap),
upsertBtn, msg,
el('p', {class:'msg', style:'margin-top:14px'},
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' +
'short-circuits past the interactive menu and chains directly. ' +
'If an unattended file is selected, the matching kernel argument ' +
'is injected and the hostname/IP are templated into the answer file.'),
]),
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Bound hosts'),
el('span', {class:'sub'}, hosts.length + ' binding' + (hosts.length === 1 ? '' : 's')),
]),
table,
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Host log'),
el('span', {class:'sub'},
bootEvents.length + ' event' + (bootEvents.length === 1 ? '' : 's')),
]),
bootEvents.length
? el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th', {}, 'Time'),
el('th', {}, 'MAC'),
el('th', {}, 'IP'),
el('th', {}, 'Image'),
])),
el('tbody', {},
bootEvents.map(e => el('tr', {}, [
el('td', {}, fmtAgo(e.timestamp)),
el('td', {class:'mono'}, e.mac || el('span', {class:'tag'}, '(unknown)')),
el('td', {class:'mono'}, e.ip ? String(e.ip) : '—'),
el('td', {}, [
el('span', {style:'font-weight:600'}, e.target_title || e.target_id),
el('div', {class:'meta',
style:'color:var(--fg-dim);font-size:11.5px;margin-top:2px'},
e.target_id),
]),
]))),
])
: el('div', {class:'empty'},
'No boot events yet. When a PXE client chains a boot entry, ' +
'it lands here with the MAC, IP, and image it received.'),
]),
]);
},
terminal: async () => {
// Two-pane layout: live log on top (auto-scrolling), command line
// on bottom. Mirrors the Minecraft-server console feel from the
// brief — output and input share one continuous timeline.
const pane = el('div', {class:'pane'});
const input = el('input', {type:'text', placeholder:'type a command, or "help"', spellcheck:'false', autocapitalize:'off', autocomplete:'off'});
const auto = el('input', {type:'checkbox'}); auto.checked = true;
const clearBtn = el('button', {onclick: () => { pane.innerHTML = ''; }}, 'Clear pane');
const tailBtn = el('button', {onclick: () => { auto.checked = !auto.checked; }}, 'Auto-scroll');
const term = el('div', {class:'terminal'}, [
el('div', {class:'toolbar'}, [
el('span', {}, 'Live log + operator console'),
el('div', {class:'right'}, [
el('label', {class:'check', style:'border:0;padding:0;margin:0;background:transparent'},
[auto, el('span', {style:'color:var(--fg-dim);font-size:11px'}, 'Auto-scroll')]),
clearBtn,
]),
]),
pane,
el('div', {class:'input-row'}, [
el('span', {class:'prompt'}, '>'),
input,
]),
]);
function append(line, kind) {
const lvl = (line.level || 'info').toLowerCase();
const ts = (line.timestamp || new Date().toISOString()).replace(/\.\d+/, '').replace('T', ' ').replace('Z', '');
const span = el('span', {class: 'lvl-' + lvl}, [
el('span', {class:'ts'}, ts + ' '),
el('span', {class:'tg'}, '[' + (line.target || 'openpxe') + '] '),
line.message,
'\n',
]);
if (kind === 'echo') {
span.firstChild.nextSibling.textContent = '';
span.firstChild.textContent = '';
span.classList.add('echo');
}
pane.appendChild(span);
if (auto.checked) pane.scrollTop = pane.scrollHeight;
}
// Initial fetch — show recent buffer in case SSE is slow to open.
try {
const r = await getJSON('/api/log/recent');
for (const l of (r.lines || [])) append(l);
} catch (e) {
append({timestamp: new Date().toISOString(), level:'warn', target:'openpxe::ui',
message: 'failed to load recent logs: ' + e.message});
}
// Live SSE stream. EventSource auto-reconnects on disconnect.
const es = new EventSource('/api/log/stream');
es.onmessage = (ev) => {
try { append(JSON.parse(ev.data)); }
catch { append({timestamp: new Date().toISOString(), level:'debug', target:'openpxe::ui', message: ev.data}); }
};
es.addEventListener('lagged', (ev) => {
const j = JSON.parse(ev.data || '{}');
append({timestamp: new Date().toISOString(), level:'warn',
target:'openpxe::ui',
message: 'log stream lagged: ' + (j.skipped || '?') + ' lines skipped'});
});
es.onerror = () => {
// EventSource quietly retries; surface a hint without spamming.
// We only append once, on transition from connected → erroring.
if (!term._notedErr) {
term._notedErr = true;
append({timestamp: new Date().toISOString(), level:'warn', target:'openpxe::ui',
message: 'log stream connection lost — auto-reconnecting'});
setTimeout(() => { term._notedErr = false; }, 5000);
}
};
// Close the SSE when the view changes — avoids piling up streams.
term._cleanup = () => es.close();
// Command history (in-memory only, ↑/↓ to recall).
const history = [];
let hi = -1;
input.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') {
const cmd = input.value;
if (!cmd.trim()) return;
input.value = '';
history.unshift(cmd); if (history.length > 100) history.pop();
hi = -1;
// The echo also comes back from the server in the live tail,
// so we don't append it locally — keeps the order consistent.
try {
const r = await postJSON('/api/terminal', {command: cmd});
const j = await r.json();
const out = j.output || '';
if (out === '\f') { pane.innerHTML = ''; return; }
// Output also gets pushed onto the LogBus by the server, but
// include it locally so even if the SSE stream dropped we
// see it. Tagged "echo" so it stands out from regular logs.
append({timestamp: new Date().toISOString(), level: j.ok ? 'info' : 'warn',
target: 'terminal-output', message: out});
} catch (err) {
append({timestamp: new Date().toISOString(), level:'error',
target:'openpxe::ui', message: 'command failed: ' + err.message});
}
} else if (e.key === 'ArrowUp') {
if (history.length === 0) return;
hi = Math.min(hi + 1, history.length - 1);
input.value = history[hi];
e.preventDefault();
} else if (e.key === 'ArrowDown') {
hi = Math.max(hi - 1, -1);
input.value = hi < 0 ? '' : history[hi];
e.preventDefault();
}
});
// Welcome banner.
append({timestamp: new Date().toISOString(), level:'info', target:'openpxe::terminal',
message: 'Connected. Type "help" for available commands.'});
// Focus the input on next tick (after view swap completes).
setTimeout(() => input.focus(), 50);
return term;
},
settings: async () => {
const [status, me, sso, notify, docs] = await Promise.all([
getJSON('/api/status'),
getJSON('/api/me').catch(() => ({})),
getJSON('/api/sso').catch(() => ({
enabled:false, idp_name:'', metadata:'', metadata_url:'',
})),
// v0.5.1: the former Advanced tab folds in here, so Settings
// fetches the notify config + API docs it needs too.
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
getJSON('/api/docs').catch(() => ({ groups: [] })),
]);
// ── Account card (Forms admin credentials, v0.4.5).
// Sonarr/Radarr-style: the admin enters their current password
// before changing username or password. On success the server
// revokes every other session, so a forgotten browser tab can't
// keep operating with stale credentials.
const currentPw = el('input', {type:'password', autocomplete:'current-password'});
const newUser = el('input', {type:'text', autocomplete:'username',
placeholder: (me.user && me.user.username) || 'admin'});
const newPw = el('input', {type:'password', autocomplete:'new-password',
placeholder: 'leave blank to keep current'});
const newPwConfirm = el('input', {type:'password', autocomplete:'new-password',
placeholder: 'confirm new password'});
const accountMsg = el('div', {class:'msg', style:'margin-top:8px'});
// v0.4.63: explicit top margin so the action button sits clearly
// beneath the input row instead of butting against the password
// fields. Mirrors the `Save SSO settings` button below for visual
// parity between the two settings cards.
const accountSave = el('button', {onclick: async () => {
accountMsg.textContent = ''; accountMsg.className = 'msg';
if (!currentPw.value) {
accountMsg.textContent = 'Current password is required.';
accountMsg.className = 'msg err';
return;
}
if (newPw.value && newPw.value !== newPwConfirm.value) {
accountMsg.textContent = 'New password and confirmation do not match.';
accountMsg.className = 'msg err';
return;
}
if (!newUser.value && !newPw.value) {
accountMsg.textContent = 'Nothing to change. Fill in a new username or password.';
accountMsg.className = 'msg err';
return;
}
const body = { current_password: currentPw.value };
if (newUser.value) body.new_username = newUser.value;
if (newPw.value) body.new_password = newPw.value;
const r = await putJSON('/api/me/credentials', body);
// Clear the typed plaintext immediately — minimises DOM dwell time.
currentPw.value = ''; newPw.value = ''; newPwConfirm.value = '';
if (r.ok) {
accountMsg.textContent = 'Credentials updated. Other sessions were signed out.';
accountMsg.className = 'msg ok';
// Refresh the settings view to pick up the new "logged in as" display.
setTimeout(() => render('settings'), 600);
} else {
const j = await r.json().catch(() => ({}));
accountMsg.textContent = j.error || ('Update failed: HTTP ' + r.status);
accountMsg.className = 'msg err';
}
}}, 'Update credentials');
const accountCard = el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Administrator account'),
el('span', {class:'sub'},
(me && me.user && me.user.username)
? ('signed in as ' + me.user.username)
: 'signed in'),
]),
el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:14px'},
'Rotate the administrator login. Your current password is required ' +
'to make any change; on success every other browser session is ' +
'signed out so a stale cookie can\'t keep operating.'),
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Current password'),
currentPw,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'New username (optional)'),
newUser,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'New password (optional)'),
newPw,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Confirm new password'),
newPwConfirm,
]),
]),
accountSave, accountMsg,
]),
]);
// ── SSO card (FleetDM-shaped, storage-only for v0.4.5).
// Operators paste either a metadata URL or the raw XML; tabs
// switch the visible field. Saving validates server-side. The
// actual SAML login flow ships in a later release — we surface
// a yellow "config saved, runtime pending" line when usable.
const ssoEnabled = el('input', {type:'checkbox'});
ssoEnabled.checked = !!sso.enabled;
const ssoName = el('input', {type:'text', placeholder:'e.g. Okta, Azure AD',
value: sso.idp_name || ''});
// v0.4.6: optional FleetDM-style IdP logo URL. The login screen
// will render this as the brand mark on the "Sign in with X"
// button once the runtime SSO flow ships; for v0.4.6 we just
// persist it.
const ssoLogo = el('input', {type:'text',
placeholder:'https://idp.example.com/logo.svg',
value: sso.idp_logo_url || ''});
const ssoUrl = el('input', {type:'text', placeholder:'https://idp.example.com/metadata',
value: sso.metadata_url || ''});
// The textarea inherits the same chrome via the global
// `label.field textarea` rule, plus the monospace family for
// pasting raw XML. Children come after the attrs object — the
// initial value is the only "child".
const ssoXml = el('textarea',
{rows:'6',
spellcheck:'false', autocapitalize:'off',
placeholder:'<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"…',
style:'font-family:var(--mono);font-size:12px;resize:vertical'},
sso.metadata || '');
// The mode picker is a styled <select> so it aligns with text
// inputs in the same `.form-row` — the global `label.field
// select` rule takes care of the chrome.
const ssoMode = el('select', {}, [
el('option', {value:'url'}, 'Metadata URL'),
el('option', {value:'xml'}, 'Metadata XML'),
]);
ssoMode.value = sso.metadata && !sso.metadata_url ? 'xml' : 'url';
// v0.4.63: the IdP metadata URL now sits inside the 4-col header
// grid as column 4, so the SSO row is column-for-column aligned with
// the Administrator account row above. When the operator switches
// to XML mode, column 4 collapses (display:none) and the multi-line
// XML textarea takes its own full-width row below — there's no way
// to fit a 6-row textarea into a single grid cell without making
// the rest of the row look stretched.
const urlWrap = el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP metadata URL'),
ssoUrl,
]);
const xmlWrap = el('label', {class:'field', style:'margin-top:14px'}, [
el('span', {class:'name'}, 'IdP metadata XML'),
ssoXml,
el('span', {class:'hint'},
'Paste the raw <EntityDescriptor>…</EntityDescriptor> document from your IdP.'),
]);
// Hint that used to live under the URL field; surfaced once below
// the whole row so it doesn't compete with the in-grid layout.
const urlHint = el('p', {class:'msg', style:'margin-top:10px;margin-bottom:0'},
'OpenPXE fetches this metadata URL at sign-in to verify the IdPs signature. ' +
'Any IdP-authenticated user gets an operator session.');
const refreshSsoFields = () => {
if (ssoMode.value === 'url') {
urlWrap.style.display = ''; xmlWrap.style.display = 'none';
urlHint.style.display = '';
} else {
urlWrap.style.display = 'none'; xmlWrap.style.display = '';
urlHint.style.display = 'none';
}
};
ssoMode.onchange = refreshSsoFields;
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
const ssoSave = el('button', {onclick: async () => {
ssoMsg.textContent = ''; ssoMsg.className = 'msg';
const payload = {
enabled: ssoEnabled.checked,
idp_name: ssoName.value,
idp_logo_url: ssoLogo.value,
metadata: ssoMode.value === 'xml' ? ssoXml.value : '',
metadata_url: ssoMode.value === 'url' ? ssoUrl.value : '',
};
const r = await putJSON('/api/sso', payload);
if (r.ok) {
ssoMsg.textContent = ssoEnabled.checked
? 'SSO saved and live. The login page now shows a “Sign in with …” button.'
: 'SSO configuration saved (disabled).';
ssoMsg.className = 'msg ok';
} else {
const t = await r.text();
ssoMsg.textContent = 'Save failed: ' + t;
ssoMsg.className = 'msg err';
}
}}, 'Save SSO settings');
const ssoCard = el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Single sign-on (SAML)'),
el('span', {class:'sub'},
sso.enabled
? (sso.metadata_url || sso.metadata
? 'live · active'
: 'enabled but missing source')
: 'disabled'),
]),
el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:14px'},
'SAML single sign-on is live. With it enabled, the login page shows ' +
'a “Sign in with …” button that hands off to your IdP; OpenPXE ' +
'verifies the signed assertion against the IdP metadata and mints an ' +
'operator session for any authenticated user. The local administrator ' +
'account above always remains available as a fallback.'),
el('label', {class:'check', style:'margin-bottom:14px;max-width:280px'}, [
ssoEnabled,
el('span', {}, 'Enable single sign-on'),
]),
// v0.4.63: 4-column form-row that matches the Administrator
// account card above column-for-column — display name / logo
// URL / metadata source / metadata URL. All four controls share
// the same `label.field` chrome so they line up cleanly. When
// the operator picks "Metadata XML" the URL column collapses
// and the multi-line textarea drops below the row.
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP display name'),
ssoName,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP logo URL'),
ssoLogo,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Metadata source'),
ssoMode,
]),
urlWrap,
]),
xmlWrap,
urlHint,
ssoSave, ssoMsg,
]),
]);
// Wire up + paint the initial visibility now that all elements
// referenced by `refreshSsoFields` are attached.
refreshSsoFields();
// ── Custom logos (v0.5.2). Three independent slots on one row,
// FleetDM-style: Light + Dark feed the WebUI top-left and the form
// login page (whichever theme is active picks its variant); Client
// is the raster painted above the PXE boot menu. Each slot has a
// preview, an upload (PNG/SVG/JPEG/WebP/GIF up to 2 MB; the Client
// slot is raster-only), and a clear.
const logoMsg = el('div', {class:'msg', style:'margin-top:10px'});
const bust = '?v=' + Date.now(); // bust the preview cache after a change
const brandingPresence = status.branding || { light:false, dark:false, client:false };
// Each swatch previews on a background matching where the mark
// lands (light page / dark page / dark PXE screen), independent of
// the operator's current page theme — so the Dark slot always reads
// as dark even while viewing Settings in light mode.
const slotDefs = [
{ slot:'light', title:'Light mode', preview:'/assets/logo.svg?theme=light' + '&' + bust.slice(1),
swatchBg:'#f4f5f7', hint:'Shown on light-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
{ slot:'dark', title:'Dark mode', preview:'/assets/logo.svg?theme=dark' + '&' + bust.slice(1),
swatchBg:'#0e1014', hint:'Shown on dark-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
{ slot:'client', title:'Client', preview:'/branding/pxe-logo' + bust,
swatchBg:'#0e1014', hint:'Above the PXE boot menu.', accept:'image/png,image/jpeg,image/webp,image/gif' },
];
const slotCol = (def) => {
const set = !!brandingPresence[def.slot];
const input = el('input', {type:'file', accept:def.accept, style:'display:none'});
input.onchange = async () => {
if (!input.files[0]) return;
const f = input.files[0];
const fd = new FormData(); fd.append('file', f, f.name);
logoMsg.textContent = 'Uploading ' + def.title + ' logo (' + fmtBytes(f.size) + ')…';
logoMsg.className = 'msg';
const r = await fetch('/api/branding/logo/' + def.slot, {method:'POST', body: fd});
if (r.ok) {
logoMsg.textContent = def.title + ' logo installed. Reloading…';
logoMsg.className = 'msg ok';
setTimeout(() => location.reload(), 600);
} else {
logoMsg.textContent = 'Upload failed: ' + (await r.text());
logoMsg.className = 'msg err';
}
};
return el('div', {class:'logo-slot'}, [
el('div', {class:'logo-slot-head'}, [
el('span', {class:'name'}, def.title),
set ? el('span', {class:'tag ok'}, 'set') : el('span', {class:'tag'}, 'default'),
]),
el('div', {class:'swatch', style:'background:' + def.swatchBg},
el('img', {src: def.preview, alt: def.title + ' logo'})),
el('div', {class:'logo-slot-hint'}, def.hint),
el('div', {style:'display:flex;gap:6px;flex-wrap:wrap'}, [
el('button', {class:'ghost', onclick: () => input.click()}, set ? 'Replace' : 'Upload'),
set ? el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove the ' + def.title + ' logo?')) return;
const r = await fetch('/api/branding/logo/' + def.slot, {method:'DELETE'});
if (r.ok || r.status === 204) {
logoMsg.textContent = def.title + ' logo cleared. Reloading…';
logoMsg.className = 'msg ok';
setTimeout(() => location.reload(), 500);
} else {
logoMsg.textContent = 'Clear failed: ' + (await r.text());
logoMsg.className = 'msg err';
}
}}, 'Remove') : null,
]),
input,
]);
};
const logoCard = el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Branding')),
el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:14px'},
'Upload your own brand marks. Up to 2 MB each; PNG, SVG, JPEG, ' +
'WebP, or GIF (the Client logo must be a raster). The Light and Dark ' +
'marks appear in the top-left and on the sign-in page depending on ' +
'theme; the Client mark sits above the PXE boot menu. The favicon ' +
'and the version string in the bottom-left always stay OpenPXE.'),
el('div', {class:'logo-slots'}, slotDefs.map(slotCol)),
logoMsg,
]),
]);
// v0.5.1: the former "Advanced" sidebar tab now lives here, folded
// into a collapsible disclosure beneath the core settings cards —
// webhook/email notifications + the API reference. Keeps Settings
// clean by default while leaving the knobs one click away.
const [notifyCard, apiCard] = views._advancedCards(notify, docs);
const advanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
el('summary', {class:'advanced-summary'}, 'Advanced'),
el('div', {class:'grid', style:'margin-top:14px'}, [notifyCard, apiCard]),
]);
return el('div', {}, [
el('div', {class:'grid'}, [accountCard, ssoCard, logoCard]),
advanced,
]);
},
// v0.5.1: builds the two "Advanced" cards — webhook/email notifications
// and the API reference. There is no longer an Advanced sidebar tab;
// the Settings view folds these into a collapsible disclosure and
// passes in the pre-fetched `notify` + `docs` payloads.
_advancedCards: (notify, docs) => {
// ── Notification config ──
const nMsg = el('div', {class:'msg', style:'margin-top:12px'});
const nEnabled = el('input', {type:'checkbox'}); nEnabled.checked = !!notify.enabled;
const nKind = el('select', {}, [
el('option', {value:'slack'}, 'Slack'),
el('option', {value:'discord'}, 'Discord'),
el('option', {value:'teams'}, 'Microsoft Teams'),
el('option', {value:'smtp'}, 'Email (SMTP)'),
]);
nKind.value = notify.kind || 'slack';
const nWebhook = el('input', {type:'text', placeholder:'https://hooks.slack.com/services/…',
value: notify.webhook_url || ''});
// SMTP fields.
const sHost = el('input', {type:'text', placeholder:'smtp.example.com', value: notify.smtp_host || ''});
const sPort = el('input', {type:'number', value: String(notify.smtp_port || 587)});
const sUser = el('input', {type:'text', placeholder:'(optional)', value: notify.smtp_username || ''});
// GET returns the password as a redaction sentinel when one is
// set; show an empty field with an "(unchanged)" placeholder and
// let collectNotify() re-send the sentinel so the stored secret
// is preserved unless the operator types a new one.
const hasStoredPass = !!notify.smtp_password;
const sPass = el('input', {type:'password',
placeholder: hasStoredPass ? '•••••• (unchanged)' : '', value: ''});
const sFrom = el('input', {type:'text', placeholder:'[email protected]', value: notify.smtp_from || ''});
const sTo = el('input', {type:'text', placeholder:'[email protected]', value: notify.smtp_to || ''});
const sTls = el('input', {type:'checkbox'}); sTls.checked = !!notify.smtp_implicit_tls;
const webhookBlock = el('div', {class:'form-row'}, [
el('label', {class:'field', style:'grid-column:1 / -1'}, [
el('span', {class:'name'}, 'Incoming webhook URL'),
nWebhook,
el('span', {class:'hint'},
'Slack/Discord/Teams all use an "incoming webhook" URL you create in that app.'),
]),
]);
const smtpBlock = el('div', {}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'SMTP host'), sHost]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Port'), sPort]),
]),
el('div', {class:'form-row cols-2', style:'margin-top:12px'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username (optional)'), sUser]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Password'), sPass]),
]),
el('div', {class:'form-row cols-2', style:'margin-top:12px'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'From'), sFrom]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'To'), sTo]),
]),
el('label', {class:'check', style:'margin-top:12px'}, [
sTls, el('span', {}, 'Implicit TLS (port 465). Leave off for STARTTLS (587).'),
]),
]);
// Toggle which provider block shows.
const syncKind = () => {
const smtp = nKind.value === 'smtp';
webhookBlock.style.display = smtp ? 'none' : '';
smtpBlock.style.display = smtp ? '' : 'none';
};
nKind.addEventListener('change', syncKind);
syncKind();
const collectNotify = () => ({
enabled: nEnabled.checked,
kind: nKind.value,
webhook_url: nWebhook.value,
smtp_host: sHost.value,
smtp_port: Number(sPort.value) || 587,
smtp_username: sUser.value,
// Empty field + a stored password → send the sentinel so the
// server keeps it. Otherwise send whatever was typed (a new
// password, or empty to clear when none was stored).
smtp_password: (sPass.value === '' && hasStoredPass) ? '__keep__' : sPass.value,
smtp_from: sFrom.value,
smtp_to: sTo.value,
smtp_implicit_tls: sTls.checked,
});
const saveBtn = el('button', {onclick: async () => {
nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
const r = await putJSON('/api/notify', collectNotify());
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); }
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
}}, 'Save notification settings');
const testBtn = el('button', {class:'ghost', style:'margin-left:8px',
onclick: async () => {
nMsg.textContent = 'Sending test…'; nMsg.className = 'msg';
// Save first so the test uses exactly what's on screen.
const rs = await putJSON('/api/notify', collectNotify());
if (!rs.ok) { nMsg.textContent = 'Save failed: ' + (await rs.text()); nMsg.className = 'msg err'; return; }
const r = await postJSON('/api/notify/test', {});
if (r.ok) { nMsg.textContent = 'Test notification sent — check your channel/inbox.'; nMsg.className = 'msg ok'; }
else { nMsg.textContent = 'Test failed: ' + (await r.text()); nMsg.className = 'msg err'; }
}}, 'Send test');
const notifyCard = el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Webhook notifications'),
el('span', {class:'sub'}, notify.enabled ? 'enabled' : 'disabled'),
]),
el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:14px'},
'Get pinged when a machine PXE-boots an image, a deployment is assigned, ' +
'or a host is woken. One provider at a time — pick yours, paste the URL ' +
'(or SMTP details), and Send test.'),
el('div', {class:'form-row cols-2'}, [
el('label', {class:'check'}, [nEnabled, el('span', {}, 'Enable notifications')]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Provider'), nKind]),
]),
el('div', {style:'margin-top:14px'}, [webhookBlock, smtpBlock]),
saveBtn, testBtn, nMsg,
]),
]);
// ── API reference (relocated from Settings) ──
const groups = docs.groups || [];
const apiCard = el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'API reference'),
el('span', {class:'sub'},
groups.reduce((n, g) => n + (g.endpoints || []).length, 0) + ' endpoints'),
]),
el('div', {class:'api-ref'},
groups.length
? groups.map(g => el('div', {class:'group'}, [
el('h3', {}, g.name),
...(g.endpoints || []).map(ep => el('div', {class:'ep'}, [
el('span', {class:'method ' + (ep.method || 'get').toLowerCase()},
ep.method || 'GET'),
el('span', {class:'path'}, ep.path || '?'),
el('span', {class:'desc'}, ep.summary || ''),
])),
]))
: el('div', {class:'empty'},
'No API documentation returned by /api/docs.')),
]);
return [notifyCard, apiCard];
},
about: async () => {
const status = await getJSON('/api/status');
// ── Check for updates ──
const updMsg = el('div', {class:'msg', style:'margin-top:10px'});
const updBtn = el('button', {onclick: async () => {
updMsg.textContent = 'Checking the OpenPXE release feed…'; updMsg.className = 'msg';
try {
const r = await getJSON('/api/updates/check');
if (r.error) {
updMsg.replaceChildren(document.createTextNode('Couldnt check: ' + r.error));
updMsg.className = 'msg err';
} else if (r.update_available) {
const parts = [document.createTextNode('Update available: '),
el('strong', {}, r.latest), document.createTextNode(' (youre on ' + r.current + '). ')];
if (r.html_url) {
parts.push(el('a', {href:r.html_url, target:'_blank', rel:'noopener noreferrer'},
'View release →'));
}
updMsg.replaceChildren(...parts);
updMsg.className = 'msg ok';
} else {
updMsg.replaceChildren(document.createTextNode(
'Youre up to date — running the latest release (' + r.current + ').'));
updMsg.className = 'msg ok';
}
} catch (e) {
updMsg.textContent = 'Couldnt reach the release feed (offline?).'; updMsg.className = 'msg err';
}
}}, 'Check for updates');
const heroCard = el('div', {class:'card'}, [
el('div', {class:'about-hero'}, [
el('h2', {}, 'OpenPXE'),
el('p', {class:'lead'},
'Air-gapped network PXE boot, container-native, that anyone can run. ' +
'No CDN calls, no telemetry, no surprise external dependencies — ship ' +
'the image once, run it forever.'),
el('div', {class:'who'}, [
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
el('span', {}, 'Base URL: '), el('strong', {}, status.public_base_url), el('br'),
el('span', {}, 'Docs: '),
el('a', {href:'https://openpxe.com/', target:'_blank', rel:'noopener noreferrer'},
'https://openpxe.com/'),
]),
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
el('p', {class:'msg', style:'margin-top:18px'},
'iPXE is an internal implementation detail. Everything the firmware ' +
'executes is generated from the settings on these tabs — there is no ' +
'hand-written .ipxe path anywhere in this product.'),
el('p', {class:'msg'},
'Vision: a deployment-grade tool that works on first try in the most ' +
'awkward environments — air-gapped labs, customer sites without ' +
'internet, OpenShift clusters with strict SCCs — without ever asking ' +
'an operator to install drivers signed with test certificates or to ' +
'flip "testsigning" on a target machine.'),
]),
]);
// ── Licenses ──
const licenseCard = el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'License')),
el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:12px'}, [
'OpenPXE is dual-licensed under ',
el('strong', {}, 'MIT'), document.createTextNode(' OR '),
el('strong', {}, 'Apache License 2.0'),
document.createTextNode(
' — use it under whichever fits your organization (SPDX: MIT OR Apache-2.0).'),
]),
el('div', {class:'license-grid', style:'display:grid;grid-template-columns:1fr 1fr;gap:14px'}, [
el('div', {}, [
el('h3', {style:'margin:0 0 6px'}, 'MIT License'),
el('p', {class:'msg', style:'font-size:12px'},
'Permission is hereby granted, free of charge, to any person obtaining a copy ' +
'of this software and associated documentation files, to deal in the Software ' +
'without restriction… THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.'),
el('a', {href:'https://opensource.org/license/mit', target:'_blank', rel:'noopener noreferrer'},
'Full MIT text →'),
]),
el('div', {}, [
el('h3', {style:'margin:0 0 6px'}, 'Apache License 2.0'),
el('p', {class:'msg', style:'font-size:12px'},
'Licensed under the Apache License, Version 2.0. Includes an express grant of ' +
'patent rights from contributors. Distributed on an "AS IS" BASIS, WITHOUT ' +
'WARRANTIES OR CONDITIONS OF ANY KIND.'),
el('a', {href:'https://www.apache.org/licenses/LICENSE-2.0', target:'_blank', rel:'noopener noreferrer'},
'Full Apache 2.0 text →'),
]),
]),
el('p', {class:'msg', style:'margin-top:14px;font-size:12px;opacity:.8'},
'Bundled components keep their own licenses: iPXE (GPLv2 / UBDL), Samba smbclient, ' +
'wimtools, and the Rust crates in this build. See the source repository for the ' +
'complete NOTICE.'),
]),
]);
return el('div', {class:'grid'}, [heroCard, licenseCard]);
},
};
// ── shell ────────────────────────────────────────────────────────
const viewTitles = {
dashboard: 'Dashboard',
network: 'Network',
queue: 'Queue',
storage: 'Storage',
hosts: 'Hosts',
terminal: 'Terminal',
settings: 'Settings',
about: 'About',
};
// Theme toggle. The data-attribute is set on <html> by the inline
// script in index.html before paint; we just flip it here and persist.
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('openpxe-theme', theme); } catch {}
applyBrandLogos(theme);
}
function currentTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
// v0.5.2: point the sidebar + login brand marks at the theme's logo
// slot so a light/dark toggle swaps the logo too (FleetDM-style).
function applyBrandLogos(theme) {
theme = theme || currentTheme();
const rev = (brandInfo && brandInfo.logo_rev) || 0;
const url = '/assets/logo.svg?theme=' + theme + '&r=' + rev;
document.querySelectorAll('.sidebar .brand img, .brand-row img').forEach(img => {
img.src = url;
});
}
document.addEventListener('DOMContentLoaded', () => {
applyBrandLogos();
const btn = $('#theme-toggle');
if (btn) {
btn.addEventListener('click', () => {
applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
});
}
});
// Keyboard shortcut: T toggles theme (skip when typing in an input).
document.addEventListener('keydown', (e) => {
if (e.key !== 't' && e.key !== 'T') return;
if (/^(INPUT|TEXTAREA|SELECT)$/.test((e.target && e.target.tagName) || '')) return;
applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
});
let currentBody = null;
async function render(view) {
view = view || 'dashboard';
$$('.sidebar nav a').forEach(a => a.classList.toggle('active', a.dataset.view === view));
$('[data-bind=view_title]').textContent = viewTitles[view] || view;
const root = $('#view-root');
// Clean up any per-view resources (e.g. terminal SSE) before swap.
if (currentBody && typeof currentBody._cleanup === 'function') {
try { currentBody._cleanup(); } catch (e) { /* ignore */ }
}
root.innerHTML = '';
// Animated rainbow-mark loader replacing the old plain-text
// \"Loading…\". The SVG drives all animation via SMIL — no JS, no
// CSS keyframes.
root.appendChild(el('div', {class:'loader'}, [
el('div', {class:'mark'}),
el('div', {}, 'Loading…'),
]));
try {
const body = await views[view]();
root.innerHTML = '';
root.appendChild(body);
currentBody = body;
} catch (e) {
root.innerHTML = '';
root.appendChild(el('div', {class:'msg err'}, 'Error: ' + e.message));
}
}
// Set the sidebar footer "Service status:" line. The chip itself moved
// off the topbar in v0.4.x: operators wanted readiness, advertised
// URL, and the boot IP grouped together as the bottom-left summary.
function setReady(state) {
const dot = $('[data-bind=ready_dot]');
const lbl = $('[data-bind=ready_label]');
if (!dot || !lbl) return;
const map = {
ready: { cls: 'ok', text: 'Ready' },
notready: { cls: 'err', text: 'Not ready' },
unreachable: { cls: 'err', text: 'Unreachable' },
};
const m = map[state] || { cls: 'warn', text: 'Checking…' };
dot.className = 'dot ' + m.cls;
lbl.className = 'status-value ' + m.cls;
lbl.textContent = m.text;
}
async function refreshChips() {
try {
const s = await getJSON('/api/status');
const r = await fetch('/readyz');
$$('[data-bind=version]').forEach(n => n.textContent = s.version);
$$('[data-bind=iso_count],[data-bind=iso_count2]').forEach(n => n.textContent = String(s.iso_count));
$$('[data-bind=client_count],[data-bind=client_count2]').forEach(n => n.textContent = String(s.client_count));
$$('[data-bind=queue_count],[data-bind=queue_count2]').forEach(n => n.textContent = String(s.queue_count));
$$('[data-bind=host_count]').forEach(n => n.textContent = String(s.host_bindings || 0));
setReady(r.ok ? 'ready' : 'notready');
} catch {
setReady('unreachable');
}
}
document.addEventListener('click', (e) => {
const a = e.target.closest('.sidebar nav a[data-view]');
if (a) { e.preventDefault(); render(a.dataset.view); }
});
// ── Auth bootstrap (v0.4.5) ──────────────────────────────────────
// Before painting the dashboard, ask /api/me whether the operator
// needs to bootstrap an admin (`setup_required`), sign in
// (`!authenticated`), or just load the dashboard. The auth screen
// takes over the viewport completely — no half-rendered chrome
// bleeding through. Sonarr/Radarr-style.
let chipsInterval = null;
let authScreenEl = null;
let ssoConfig = null;
// v0.5.0: branding bootstrap for the pre-auth screens, populated from
// /api/me (public). Lets the login/setup cards render the same
// FleetDM-style full-width custom logo the dashboard sidebar uses.
let brandInfo = { has_custom_logo: false, logo_rev: 0 };
// Brand row for the auth screens. With a custom logo uploaded: the
// logo spans the card, no "OpenPXE" wordmark (the logo is the brand).
// Default: bundled mark + "OpenPXE".
function authBrandRow() {
const src = '/assets/logo.svg?theme=' + currentTheme() + '&r=' + (brandInfo.logo_rev || 0);
if (brandInfo.has_custom_logo) {
return el('div', {class:'brand-row has-custom-logo'}, [
el('img', {src, alt:'logo'}),
]);
}
return el('div', {class:'brand-row'}, [
el('img', {src, alt:''}),
el('div', {class:'name'}, 'OpenPXE'),
]);
}
function teardownAuthScreen() {
if (authScreenEl && authScreenEl.parentNode) {
authScreenEl.parentNode.removeChild(authScreenEl);
}
authScreenEl = null;
document.querySelector('.shell').style.display = '';
}
function buildLoginCard() {
const usernameInput = el('input', {type:'text', name:'username', autocomplete:'username', autofocus:'autofocus', spellcheck:'false'});
const passwordInput = el('input', {type:'password', name:'password', autocomplete:'current-password'});
const err = el('div', {class:'auth-err', style:'display:none'});
const submit = el('button', {class:'submit', type:'submit'}, 'Sign in');
// v0.5.1: surface a failed/blocked SSO round-trip. The ACS handler
// redirects back to "/?sso_error=..." on any failure; we show a
// generic, non-leaky message and scrub the query so a refresh is clean.
const ssoErr = new URLSearchParams(window.location.search).get('sso_error');
if (ssoErr) {
err.textContent = ssoErr === 'idp_initiated'
? 'IdP-initiated SSO is disabled. Use the “Sign in with …” button, or enable it under Settings → SSO.'
: (ssoErr === 'unavailable' || ssoErr === 'metadata')
? 'Single sign-on is unavailable right now. Sign in with the local admin, or check the SSO settings.'
: 'SSO sign-in failed. Please try again, or sign in with the local admin.';
err.style.display = '';
window.history.replaceState({}, '', window.location.pathname);
}
// v0.5.2: FleetDM-style separation. The local credential form is its
// own self-contained <form>; when SSO is enabled, a distinct
// "Sign in with …" button sits below a divider — the credential
// fields no longer double as the SSO trigger.
// `enabled` from /api/me already means "usable" (enabled AND a metadata
// source is configured), so the button only shows when SSO will work.
const ssoLive = !!(ssoConfig && ssoConfig.enabled);
const ssoBlock = ssoLive
? el('div', {class:'sso-block'}, [
el('div', {class:'auth-divider'}, el('span', {}, 'or')),
el('button', {type:'button', class:'sso-btn', onclick: () => {
// SP-initiated SAML login: hand off to the IdP. /api/sso/acs
// verifies the response, mints the operator session, and
// redirects back to the dashboard.
window.location.assign('/api/sso/login');
}}, [
ssoConfig.idp_logo_url
? el('img', {class:'sso-logo', src: ssoConfig.idp_logo_url, alt:'', onerror: function(){ this.style.display='none'; }})
: null,
el('span', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')),
]),
])
: null;
const form = el('form', {class:'auth-form local-login', onsubmit: async (e) => {
e.preventDefault();
err.style.display = 'none';
submit.disabled = true;
submit.textContent = 'Signing in…';
try {
const r = await fetch('/api/login', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({username: usernameInput.value, password: passwordInput.value}),
});
if (r.ok) {
passwordInput.value = '';
teardownAuthScreen();
await startDashboard();
return;
}
const j = await r.json().catch(() => ({}));
err.textContent = j.error || ('Sign-in failed: HTTP ' + r.status);
err.style.display = '';
} catch (ex) {
err.textContent = 'Network error: ' + (ex && ex.message ? ex.message : ex);
err.style.display = '';
} finally {
submit.disabled = false;
submit.textContent = 'Sign in';
}
}}, [
el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Username'),
usernameInput,
]),
el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Password'),
passwordInput,
]),
submit,
]);
return el('div', {class:'login-stack'}, [
authBrandRow(),
el('h2', {}, 'Sign in'),
el('p', {class:'lede'}, 'Enter your administrator credentials. Forgot them? SSH to the host and remove work_dir/auth.json — the next launch will re-prompt for setup.'),
form,
ssoBlock,
err,
el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')),
]);
}
function buildSetupCard() {
const usernameInput = el('input', {type:'text', name:'username', autocomplete:'username', autofocus:'autofocus', spellcheck:'false'});
const passwordInput = el('input', {type:'password', name:'password', autocomplete:'new-password'});
const confirmInput = el('input', {type:'password', name:'confirm', autocomplete:'new-password'});
const err = el('div', {class:'auth-err', style:'display:none'});
const submit = el('button', {class:'submit', type:'submit'}, 'Create administrator');
const form = el('form', {class:'auth-form', onsubmit: async (e) => {
e.preventDefault();
err.style.display = 'none';
if (passwordInput.value !== confirmInput.value) {
err.textContent = 'Passwords do not match.';
err.style.display = '';
return;
}
if (passwordInput.value.length < 8) {
err.textContent = 'Password must be at least 8 characters.';
err.style.display = '';
return;
}
submit.disabled = true;
submit.textContent = 'Creating…';
try {
const r = await fetch('/api/setup', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({username: usernameInput.value, password: passwordInput.value}),
});
if (r.ok) {
passwordInput.value = '';
confirmInput.value = '';
teardownAuthScreen();
await startDashboard();
return;
}
const j = await r.json().catch(() => ({}));
err.textContent = j.error || ('Setup failed: HTTP ' + r.status);
err.style.display = '';
} catch (ex) {
err.textContent = 'Network error: ' + (ex && ex.message ? ex.message : ex);
err.style.display = '';
} finally {
submit.disabled = false;
submit.textContent = 'Create administrator';
}
}}, [
authBrandRow(),
el('h2', {}, 'First-run setup'),
el('p', {class:'lede'}, 'Welcome. Create the administrator account that will own this OpenPXE deployment. Additional users come in through SSO later.'),
el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Username'),
usernameInput,
]),
el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Password (≥8 chars)'),
passwordInput,
]),
el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Confirm password'),
confirmInput,
]),
submit,
err,
el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')),
]);
return form;
}
function showAuthScreen(mode) {
// Tear down any previous screen + the dashboard chrome.
if (authScreenEl && authScreenEl.parentNode) {
authScreenEl.parentNode.removeChild(authScreenEl);
}
const shell = document.querySelector('.shell');
if (shell) shell.style.display = 'none';
if (chipsInterval) { clearInterval(chipsInterval); chipsInterval = null; }
const card = (mode === 'setup' ? buildSetupCard() : buildLoginCard());
authScreenEl = el('div', {class:'auth-screen'},
el('div', {class:'auth-card'}, card));
document.body.appendChild(authScreenEl);
// Focus the first visible input (autofocus on dynamically created
// inputs doesn't fire in all browsers).
setTimeout(() => {
const inp = authScreenEl.querySelector('input[type="text"], input[type="password"]');
if (inp) inp.focus();
}, 30);
}
async function startDashboard() {
// v0.4.6: light up the top-right user-menu chip. The button is
// hidden in index.html until /api/me confirms a signed-in session,
// so we don't show the icon (then hide it) when the user lands
// on /login. Clicking the icon opens a small popover with
// Name / Edit account / Sign out.
try {
const me = await fetch('/api/me').then(r => r.ok ? r.json() : null);
const wrap = $('[data-bind=user_menu_wrap]');
const pop = $('[data-bind=user_menu_pop]');
const name = $('[data-bind=user_pop_name]');
const edit = $('[data-bind=user_pop_edit]');
const out = $('[data-bind=user_pop_logout]');
const btn = $('#user-menu-btn');
if (wrap && me && me.authenticated && me.user) {
wrap.style.display = '';
if (name) name.textContent = me.user.username;
if (btn) btn.title = 'Signed in as ' + me.user.username;
if (btn && !btn._wired) {
btn._wired = true;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const open = !pop.hidden;
pop.hidden = open;
btn.setAttribute('aria-expanded', String(!open));
});
}
if (edit && !edit._wired) {
edit._wired = true;
edit.addEventListener('click', () => {
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
render('settings');
});
}
if (out && !out._wired) {
out._wired = true;
out.addEventListener('click', async () => {
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
await fetch('/api/logout', {method:'POST'}).catch(() => {});
wrap.style.display = 'none';
showAuthScreen('login');
});
}
// Click-outside-to-close, wired once. Stored on document so we
// don't re-attach every render.
if (!document._userPopWired) {
document._userPopWired = true;
document.addEventListener('click', (e) => {
if (pop.hidden) return;
if (e.target.closest('.user-menu')) return;
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !pop.hidden) {
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
});
}
}
} catch (e) { /* surfaces elsewhere */ }
render('dashboard');
await refreshChips();
if (!chipsInterval) chipsInterval = setInterval(refreshChips, 3000);
}
async function bootstrap() {
let me;
try {
me = await fetch('/api/me').then(r => r.json());
// Capture branding so the auth cards render the custom logo.
brandInfo = {
has_custom_logo: !!me.has_custom_logo,
logo_rev: me.logo_rev || 0,
};
} catch (e) {
// /api/me is unauthenticated in every state — if we can't reach
// it the server is genuinely down, not an auth problem.
document.body.appendChild(el('div', {class:'auth-screen'},
el('div', {class:'auth-card'}, [
authBrandRow(),
el('h2', {}, 'Connection error'),
el('p', {class:'lede'}, 'Could not reach the OpenPXE server. Refresh once it is back up.'),
])));
return;
}
// v0.5.9: the login card's "Sign in with …" button keys off the SSO
// descriptor that /api/me now carries (public, non-sensitive: enabled
// + idp_name + idp_logo_url). It's available signed in or out, so the
// button is static — it no longer relied on the auth-gated /api/sso,
// which 401s pre-auth and made the button vanish on fresh login loads.
ssoConfig = me.sso || null;
if (me.setup_required) {
showAuthScreen('setup');
return;
}
if (!me.authenticated) {
showAuthScreen('login');
return;
}
await startDashboard();
}
bootstrap();
})();