Files
OpenPXE/crates/webui/src/app.js
T
Miles WardandClaude Opus 4.8 cbcd63bb14 feat(saml): wire SAML 2.0 SSO end-to-end (pure-Rust) + Settings/Storage UI consolidation (v0.5.1)
SAML SSO (the config was storage-only since v0.4.5; now it logs you in):
- New openpxe-core::saml — pure-Rust SP built on bergshamra (XML-DSig +
  exclusive c14n via RustCrypto, no OpenSSL/xmlsec/libxml2). The static
  musl binary stays C-free; samael was rejected for hard-requiring OpenSSL.
  * metadata.rs   — parse IdP EntityDescriptor (SSO URLs + signing certs),
                    build our SP metadata.
  * authn_request.rs — build + HTTP-Redirect-encode AuthnRequests.
  * response.rs   — verify the signature against the pinned IdP cert
                    (trusted_keys_only + strict_verification for XSW),
                    then enforce Status/Destination/Audience/time-bounds/
                    signature-scope. Stateless; returns the IDs the HTTP
                    layer needs.
- http-api saml_routes: GET /api/sso/login (302 to IdP), POST /api/sso/acs
  (verify -> InResponseTo correlation / IdP-initiated gating / assertion
  replay guard -> mint operator session -> 302), GET /api/sso/metadata.
  Added to the pre-auth allowlist; /api/sso config stays gated.
- SsoConfig gains entity_id (SP Entity ID, defaults to public base URL)
  and allow_idp_initiated (default off), mirroring FleetDM.
- Access model: any IdP-authenticated, cryptographically-verified user gets
  an operator session (single-tier; local admin remains the fallback owner).
- Login page: the "Sign in with <IdP>" button now drives the real flow and
  surfaces sso_error redirects.

UI consolidation:
- Removed the Advanced sidebar tab; folded its webhook-notifications +
  API-reference cards into a collapsible "Advanced" disclosure at the
  bottom of Settings.
- Merged the Storage tab's separate SMB and NFS cards into one "Remote
  shares" card with a protocol dropdown and a unified, protocol-badged
  table. No backend changes — same /api/smb-shares + /api/nfs-shares.

Tests: 17 SAML core tests (accept + reject tampered/unsigned/wrong-key/
wrong-audience/expired/future/wrong-issuer/non-success) and 6 ACS
integration tests (happy path, IdP-initiated gating, SP correlation,
replay, garbage). Full workspace: 206 tests green, clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-31 00:50:28 -04:00

2165 lines
99 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;
const isWin = fam === 'windows_pe';
if (isWin && !settings.windows_enabled) {
return { ok: false, reason: 'Windows boot disabled in Settings' };
}
if (!isWin && !iso.introspection.kernel_path && fam !== 'windows_pe') {
// Linux without a detected kernel falls through to sanboot which
// rarely works for >1 GiB ISOs.
if (iso.size_bytes > 1.5 * 1024 * 1024 * 1024) {
return { ok: false, reason: 'no kernel/initrd detected; ISO too large for sanboot fallback' };
}
return { ok: true, warn: 'no kernel detected — sanboot fallback may not work' };
}
return { ok: true };
}
// ── 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'},
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
// v0.4.67: count both protocols. Label generically since
// operators may be using one, the other, or both.
((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) +
' remote share' +
(((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) === 1 ? '' : 's')),
])),
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', {}, 'Images that won\'t boot with current settings')]),
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] = await Promise.all([
getJSON('/api/queue'), getJSON('/api/isos'),
]);
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 => 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)),
]),
el('div', {}, g.assigned_target
? el('span', {class:'tag ok'}, '→ ' + g.assigned_target)
: el('span', {class:'tag accent'}, 'waiting')),
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, disk] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'),
getJSON('/api/smb-shares'),
getJSON('/api/nfs-shares'),
getJSON('/api/storage/disk').catch(() => ({
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
})),
]);
const shares = smbRes.shares || [];
const nfsShares = nfsRes.shares || [];
// ── 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'});
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;
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),
});
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 {}
}
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
} finally {
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 = [];
isos.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.
const protoSelect = el('select', {}, [
el('option', {value:'smb'}, 'SMB / CIFS'),
el('option', {value:'nfs'}, 'NFS (NFSv3)'),
]);
// 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]),
]),
]);
// Swap the visible field block + clear any stale message.
const syncProto = () => {
const nfs = protoSelect.value === 'nfs';
smbFields.style.display = nfs ? 'none' : '';
nfsFields.style.display = nfs ? '' : 'none';
shareMsg.replaceChildren();
shareMsg.className = 'msg';
};
protoSelect.addEventListener('change', syncProto);
// One add button; dispatches to the selected protocol's endpoint.
const addShare = el('button', {style:'margin-top:14px', 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 (!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 totalShares = shares.length + nfsShares.length;
const remoteRows = totalShares
? [...smbRowEls, ...nfsRowEls]
: [el('div', {class:'empty'}, 'No remote shares configured.')];
syncProto();
const diskCard = diskSpaceCard(disk);
return el('div', {class:'grid'}, [
diskCard,
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Upload ISO')),
el('div', {class:'body'}, [drop, file, prog, upMsg]),
]),
// 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]),
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, no ' +
'double disk usage. SMB/CIFS is read in userspace via Sambas ' +
'smbclient; NFSv3 via a pure-Rust in-process client. Both work in ' +
'any container (Unraid, OpenShift restricted SCC, plain Docker) with ' +
'no kernel modules and no CAP_SYS_ADMIN. SMB supports guest or ' +
'user/password; most NAS appliances expose ISO libraries as ' +
'guest-readable. NFSv3 auth is AUTH_SYS only — gate access by ' +
'allowing this OpenPXE hosts IP in the servers export list. ' +
'NFS-sourced ISOs also support HTTP Range (seek into a 5 GB ISO ' +
'without reading what precedes the offset); SMB streams sequentially.'),
]),
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Available images'),
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
]),
isoTable,
]),
]);
},
hosts: async () => {
const [{ hosts = [] }, isos, bootLogRes] = await Promise.all([
getJSON('/api/hosts'), getJSON('/api/isos'),
getJSON('/api/boot-log').catch(() => ({ events: [] })),
]);
const bootEvents = bootLogRes.events || [];
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'});
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', {
mac: macInput.value, target: targetSel.value, label: labelInput.value,
});
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');
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', {}, 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',{},'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.'),
]),
]),
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.'),
]),
]),
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: [] })),
]);
const hasLogo = !!status.custom_logo;
// ── 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', {style:'margin-top:6px', 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 will fetch the metadata URL once SSO sign-in lands; v0.4.63 stores it.');
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', {style:'margin-top:16px', 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 configuration saved. Runtime sign-in flow ships in a future release.'
: '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
? 'configured · runtime pending'
: 'enabled but missing source')
: 'disabled'),
]),
el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:14px'},
'Configure your SAML IdP today; OpenPXE persists the metadata so ' +
'when SSO sign-in lights up in a future release, no operator ' +
're-entry is needed. The local administrator account above is ' +
'always available as a fallback owner regardless of SSO state.'),
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 logo upload.
// Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB.
// Persisted as <work_dir>/branding/logo.<ext> and served from
// /assets/logo.svg in preference to the bundled mark.
const logoFile = el('input', {
type:'file',
accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif',
style:'display:none', id:'logo-file',
});
const logoMsg = el('div', {class:'msg', style:'margin-top:10px'});
const logoBust = '?v=' + Date.now(); // bust the browser cache after upload
logoFile.onchange = async () => {
if (!logoFile.files[0]) return;
const f = logoFile.files[0];
const fd = new FormData(); fd.append('file', f, f.name);
logoMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…';
logoMsg.className = 'msg';
const r = await fetch('/api/branding/logo', {method:'POST', body: fd});
if (r.ok) {
logoMsg.textContent = 'Custom logo installed. Reloading…';
logoMsg.className = 'msg ok';
setTimeout(() => location.reload(), 600);
} else {
const t = await r.text();
logoMsg.textContent = 'Upload failed: ' + t;
logoMsg.className = 'msg err';
}
};
const logoCard = el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Branding')),
el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:14px'},
'Override the top-left brand mark with your own logo. Up to 2 MB; ' +
'PNG, SVG, JPEG, WebP, or GIF. The original OpenPXE version is ' +
'always shown in the bottom-left for support purposes.'),
el('div', {class:'logo-preview'}, [
el('div', {class:'swatch'},
el('img', {src: '/assets/logo.svg' + logoBust, alt:'current logo'})),
el('div', {class:'info'}, [
el('div', {class:'name'}, hasLogo ? 'Custom logo (uploaded)' : 'Default OpenPXE mark'),
el('div', {class:'meta'},
hasLogo
? 'Operator-uploaded; served from <work_dir>/branding/.'
: 'Bundled rainbow-horizon mark. Upload an image to override.'),
]),
el('div', {style:'display:flex;gap:8px;flex-wrap:wrap'}, [
el('button', {onclick: () => logoFile.click()},
hasLogo ? 'Replace logo' : 'Upload logo'),
hasLogo
? el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove custom logo and revert to the OpenPXE mark?')) return;
const r = await fetch('/api/branding/logo', {method:'DELETE'});
if (r.ok || r.status === 204) {
logoMsg.textContent = 'Reverted to default mark. Reloading…';
logoMsg.className = 'msg ok';
setTimeout(() => location.reload(), 500);
} else {
const t = await r.text();
logoMsg.textContent = 'Clear failed: ' + t;
logoMsg.className = 'msg err';
}
}}, 'Remove')
: null,
]),
]),
logoFile, 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', {style:'margin-top:16px', 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-top:16px;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 {}
}
function currentTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
document.addEventListener('DOMContentLoaded', () => {
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?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);
}
const ssoButton = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata)
? el('button', {type:'button', class:'sso-btn', onclick: () => {
// SP-initiated SAML login (v0.5.1): hand off to the IdP. The
// /api/sso/acs endpoint verifies the response, mints the
// operator session, and redirects back to the dashboard.
window.location.assign('/api/sso/login');
}}, [
el('div', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')),
])
: null;
const form = el('form', {class:'auth-form', 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';
}
}}, [
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.'),
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,
ssoButton,
err,
el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')),
]);
return form;
}
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;
}
// Preload the SSO config so the login card can offer the operator
// an "Sign in with X" button when configured. Failure is harmless.
try { ssoConfig = await fetch('/api/sso').then(r => r.ok ? r.json() : null); }
catch { ssoConfig = null; }
if (me.setup_required) {
showAuthScreen('setup');
return;
}
if (!me.authenticated) {
showAuthScreen('login');
return;
}
await startDashboard();
}
bootstrap();
})();