// 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 ────────────────────────────────────────────── async function getJSON(url) { const r = await fetch(url); if (!r.ok) throw new Error(url + ': ' + r.status); return r.json(); } async function putJSON(url, body) { return fetch(url, {method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}); } async function postJSON(url, body) { return 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; } // 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 || []; 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 · ' + (status.nfs_active || 0) + ' NFS active'), ])), 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, 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]); }, gate: 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('gate'); }; 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('gate'); }}, 'Release'), ])) ) : el('div', {class:'empty'}, 'No clients at the gate. 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', {}, 'Forge')), queueProgressWidget(imaging, entries.length), ]), el('div', {class:'card'}, [ el('header', {}, el('h2', {}, 'Launch an image across the gate')), 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', {}, 'Gate positions'), el('span', {class:'sub'}, entries.length + ' waiting'), ]), el('div', {class:'body'}, track), ]), ]); }, storage: async () => { const [isos, settings, nfsRes] = await Promise.all([ getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'), ]); const mounts = nfsRes.mounts || []; // ── 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]); }; function upload(f) { upMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…'; upMsg.className = 'msg'; prog.classList.add('active'); const fd = new FormData(); fd.append('file', f); const xhr = new XMLHttpRequest(); xhr.upload.onprogress = e => { if (e.lengthComputable) $('#bar').style.width = (e.loaded/e.total*100).toFixed(1) + '%'; }; xhr.onload = () => { prog.classList.remove('active'); $('#bar').style.width = '0'; if (xhr.status >= 200 && xhr.status < 300) { upMsg.textContent = 'Uploaded & analyzed.'; upMsg.className = 'msg ok'; render('storage'); } else { upMsg.textContent = 'Upload failed: ' + xhr.status + ' ' + xhr.responseText; upMsg.className = 'msg err'; } }; xhr.onerror = () => { upMsg.textContent = 'Network error.'; upMsg.className = 'msg err'; }; xhr.open('POST', '/api/isos'); xhr.send(fd); } // ── ISO table (mixed local + NFS) ── const rows = isos.map(i => { const b = bootability(i, settings); const isNfs = i.source && i.source.kind === 'nfs'; const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [ el('td', {}, [ el('div', {}, i.filename), !b.ok ? el('div', {class:'row-warn'}, '⚠ ' + b.reason) : (b.warn ? el('div', {class:'row-warn'}, '⚠ ' + b.warn) : null), ]), el('td', {}, el('span', {class:'tag'}, familyLabel(i.introspection.family))), el('td', {class:'num'}, fmtBytes(i.size_bytes)), el('td', {}, el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')}, isNfs ? ('nfs:' + i.source.mount_id) : 'local')), el('td', {}, fmtAgo(i.uploaded_at)), el('td', {style:'text-align:right'}, isNfs ? el('span', {class:'tag', style:'opacity:.6'}, 'manage on NFS share') : el('button', {class:'danger', onclick: async () => { if (!confirm('Remove this image?')) return; await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'}); render('storage'); }}, 'Remove')), ]); return tr; }); 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',{},'Uploaded'), el('th',{},''), ])), el('tbody', {}, rows), ]) : el('div', {class:'empty'}, 'No images yet. Upload an ISO or mount an NFS share.'); // ── NFS section ── const nfsMsg = el('div', {class:'msg'}); const nfsServer = el('input', {type:'text', placeholder:'10.0.0.20'}); const nfsExport = el('input', {type:'text', placeholder:'/srv/isos'}); const nfsVer = el('select', {}, [ el('option', {value:'v41'}, 'NFSv4.1 (default)'), el('option', {value:'v3'}, 'NFSv3'), ]); const nfsRo = el('input', {type:'checkbox'}); nfsRo.checked = true; const addNfs = el('button', {onclick: async () => { if (!nfsServer.value || !nfsExport.value) { nfsMsg.textContent = 'Server and export are required.'; nfsMsg.className='msg err'; return; } nfsMsg.textContent = 'Mounting…'; nfsMsg.className = 'msg'; const r = await postJSON('/api/nfs', { server: nfsServer.value, export: nfsExport.value, version: nfsVer.value, read_only: nfsRo.checked, }); if (r.ok) { nfsMsg.textContent = 'Mounted.'; nfsMsg.className = 'msg ok'; render('storage'); } else { const t = await r.text(); nfsMsg.textContent = 'Mount failed: ' + t; nfsMsg.className = 'msg err'; } }}, 'Mount share'); const nfsRows = mounts.length ? mounts.map(m => el('div', {class: 'nfs-row' + (m.mounted ? '' : ' down')}, [ el('span', {class: 'dot ' + (m.mounted ? 'ok' : 'err')}), el('div', {}, [ el('div', {class:'id'}, m.server + ':' + m.export), el('div', {class:'meta'}, (m.version === 'v3' ? 'NFSv3' : 'NFSv4.1') + ' · ' + (m.read_only ? 'read-only' : 'read-write') + ' · ' + (m.mounted ? m.iso_count + ' isos' : 'not mounted')), m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null, ]), el('button', {class:'ghost', onclick: async () => { const r = await postJSON('/api/nfs/' + encodeURIComponent(m.id) + '/scan', {}); if (r.ok) render('storage'); }}, 'Re-scan'), el('button', {class:'danger', onclick: async () => { if (!confirm('Unmount ' + m.server + ':' + m.export + '?')) return; await fetch('/api/nfs/' + encodeURIComponent(m.id), {method:'DELETE'}); render('storage'); }}, 'Unmount'), el('span'), ])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')]; return el('div', {class:'grid'}, [ el('div', {class:'card'}, [ el('header', {}, el('h2', {}, 'Upload ISO')), el('div', {class:'body'}, [drop, file, prog, upMsg]), ]), el('div', {class:'card'}, [ el('header', {}, [ el('h2', {}, 'NFS shares'), el('span', {class:'sub'}, mounts.length + ' configured'), ]), el('div', {class:'body'}, [ el('div', {class:'form-row'}, [ el('label', {class:'field'}, [ el('span', {class:'name'}, 'NFS server'), nfsServer, ]), el('label', {class:'field'}, [ el('span', {class:'name'}, 'Export path'), nfsExport, ]), el('label', {class:'field'}, [ el('span', {class:'name'}, 'Version'), nfsVer, ]), el('label', {class:'check', style:'margin-top:18px'}, [ nfsRo, el('span', {}, 'Read-only'), ]), ]), addNfs, nfsMsg, el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows), el('p', {class:'msg', style:'margin-top:14px'}, 'Mounting NFS inside a container requires CAP_SYS_ADMIN and the ' + 'mount.nfs binary (bundled in the default Docker image). On ' + 'OpenShift, your SCC must allow CAP_SYS_ADMIN or you can run ' + 'NFS mounts as a CSI driver outside the pod.'), ]), ]), 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] = await Promise.all([ getJSON('/api/hosts'), getJSON('/api/isos'), ]); 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 => 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'}, el('button', {class:'danger', 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. ' + 'Inspired by Tinkerbell smee\'s MAC-prepended URL pattern.'), ]), ]), el('div', {class:'card'}, [ el('header', {}, [ el('h2', {}, 'Bound hosts'), el('span', {class:'sub'}, hosts.length + ' binding' + (hosts.length === 1 ? '' : 's')), ]), table, ]), ]); }, 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; }, about: async () => { const status = await getJSON('/api/status'); return 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('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.'), ]), ]); }, }; // ── shell ──────────────────────────────────────────────────────── const viewTitles = { dashboard: 'Dashboard', network: 'Network', gate: 'Queue', storage: 'Storage', hosts: 'Hosts', terminal: 'Terminal', about: 'About', }; // Theme toggle. The data-attribute is set on 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)); } } 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)); const chip = $('[data-bind=ready_chip]'); if (chip) { if (r.ok) { chip.textContent = '● ready'; chip.className = 'chip ready'; } else { chip.textContent = '● not ready'; chip.className = 'chip notready'; } } } catch { const chip = $('[data-bind=ready_chip]'); if (chip) { chip.textContent = '● unreachable'; chip.className = 'chip notready'; } } } document.addEventListener('click', (e) => { const a = e.target.closest('.sidebar nav a[data-view]'); if (a) { e.preventDefault(); render(a.dataset.view); } }); render('dashboard'); refreshChips(); setInterval(refreshChips, 3000); })();