// OpenPXE web UI — vanilla JS, no build step, no framework, no network // dependencies. Uses fetch() + EventSource only. // // Tabs (Phase 4): Dashboard / Network / Queue / Storage / Terminal / // About. The shell swaps a single view into #view-root. // // Keep this readable — nobody wants to debug a clever vanilla-JS // framework at 3 AM. Plain dumb DOM construction is the design. (function () { const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); const el = (tag, attrs = {}, children = []) => { const e = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === 'class') e.className = v; else if (k === 'html') e.innerHTML = v; else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v); else if (v !== false && v != null) e.setAttribute(k, v); } for (const c of [].concat(children)) { if (c == null || c === false) continue; if (typeof c === 'string') e.appendChild(document.createTextNode(c)); else e.appendChild(c); } return e; }; const fmtBytes = (n) => { const u = ['B','KB','MB','GB','TB']; let i = 0; while (n >= 1024 && i < u.length-1) { n /= 1024; i++; } return n.toFixed(n >= 10 || i === 0 ? 0 : 1) + ' ' + u[i]; }; const fmtAgo = (ts) => { const d = (ts instanceof Date) ? ts : new Date(ts); if (isNaN(d.getTime())) return '-'; const ds = Math.floor((Date.now() - d.getTime()) / 1000); if (ds < 0) return 'in ' + Math.abs(ds) + 's'; if (ds < 60) return ds + 's ago'; if (ds < 3600) return Math.floor(ds/60) + 'm ago'; if (ds < 86400) return Math.floor(ds/3600) + 'h ago'; return d.toLocaleString(); }; const fmtUptime = (secs) => { secs = Math.max(0, Math.floor(secs || 0)); const h = Math.floor(secs/3600), m = Math.floor((secs%3600)/60), s = secs%60; if (h) return h + 'h ' + m + 'm'; if (m) return m + 'm ' + s + 's'; return s + 's'; }; const familyLabel = (f) => ({ debian_ubuntu: 'Debian / Ubuntu', rhel_fedora: 'RHEL family', open_suse: 'openSUSE', arch: 'Arch', alpine: 'Alpine', windows_pe: 'Windows', unknown: 'Unknown', }[f] || f); const archLabel = (a) => { if (!a) return '—'; if (typeof a === 'string') return a; if (a && typeof a === 'object') { if ('Unknown' in a) return 'unknown(0x' + a.Unknown.toString(16) + ')'; return JSON.stringify(a); } return String(a); }; // ── network helpers ────────────────────────────────────────────── // All three helpers funnel through a 401 detector. When the server // says "auth required" mid-session — most commonly because the // operator's session expired while the tab was idle — we transparently // swap the SPA out for the login screen rather than letting the UI // throw a generic error. function maybeAuthBounce(r) { if (r && r.status === 401) { // Render the login screen without reloading; any in-flight // promises still return their values to the original caller. showAuthScreen('login'); } return r; } async function getJSON(url) { const r = maybeAuthBounce(await fetch(url)); if (!r.ok) throw new Error(url + ': ' + r.status); return r.json(); } async function putJSON(url, body) { return maybeAuthBounce(await fetch(url, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body), })); } async function postJSON(url, body) { return maybeAuthBounce(await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body), })); } // Animated brand-mark + progress bar widget. Built once here and inlined // wherever a card wants to convey "an image is being deployed onto a // queued client right now." Used on the Dashboard and the Queue tab. function queueProgressWidget(imaging, queueTotal) { const total = Math.max(queueTotal, imaging, 1); const pct = imaging > 0 ? Math.round((imaging / total) * 100) : 0; const root = el('div', {class: 'queue-progress' + (imaging === 0 ? ' idle' : '')}, [ el('div', {class: 'mark', 'aria-hidden': 'true'}), el('div', {class: 'info'}, [ el('div', {class: 'label'}, imaging === 0 ? 'No active imaging' : (imaging + ' of ' + total + ' device' + (total === 1 ? '' : 's') + ' deploying')), el('div', {class: 'bar-track'}, el('div', {class: 'bar-fill', style: 'width:' + pct + '%'})), ]), ]); return root; } // Disk-space card. Free + used + total for the volume hosting the ISO // directory, with a coloured bar. Warns at 80% and goes red at 95% so // the operator sees the runway shrinking before uploads start failing // with ENOSPC. Shared by the Storage tab and the Dashboard (v0.4.68). function diskSpaceCard(disk) { const total = Number(disk.total_bytes || 0); const avail = Number(disk.available_bytes || 0); const used = Number(disk.used_bytes || 0); const pctUsed = total > 0 ? (used / total) * 100 : 0; let barClass = 'diskbar'; if (pctUsed >= 95) barClass += ' full'; else if (pctUsed >= 80) barClass += ' warn'; return el('div', {class:'card'}, [ el('header', {}, [ el('h2', {}, 'Disk space'), el('span', {class:'sub'}, total > 0 ? (pctUsed.toFixed(1) + '% used') : 'unavailable'), ]), el('div', {class:'body'}, [ el('div', {style:'color:var(--fg-dim);font-size:12px;word-break:break-all'}, disk.path ? ('Volume: ' + disk.path) : 'Volume path unknown'), el('div', {class: barClass}, el('div', {class:'fill', style:'width:' + Math.min(100, pctUsed).toFixed(1) + '%'})), el('div', {class:'disk-meta'}, [ el('span', {}, ['Used ', el('strong', {}, fmtBytes(used))]), el('span', {}, ['Free ', el('strong', {}, fmtBytes(avail))]), el('span', {}, ['Total ', el('strong', {}, fmtBytes(total))]), ]), pctUsed >= 95 ? el('p', {class:'msg err', style:'margin-top:10px'}, '⚠ Less than 5% free. Remove old ISOs or grow the volume before uploading more.') : (pctUsed >= 80 ? el('p', {class:'msg', style:'color:var(--warn);margin-top:10px'}, 'Volume is getting full. Consider pruning old ISOs.') : null), ]), ]); } // Categorize an ISO row's "bootable now" status — drives the amber // tint borrowed from Bootimus v0.1.62. Returns {ok, reason}. function bootability(iso, settings) { const fam = iso.introspection.family; // v0.5.8: Windows ISOs boot via iPXE HTTP sanboot of the raw image — // no Settings toggle, no SMB, no size limit. Always bootable. if (fam === 'windows_pe') { return { ok: true }; } // Linux with a detected kernel/initrd — direct kernel+initrd boot. if (iso.introspection.kernel_path) { return { ok: true }; } // v0.5.9: any other ISO that carries an El Torito boot catalog is // bootable via iPXE sanboot (emulated CD) — BSDs, ESXi, firmware // tools, custom Linux spins. This replaces the old "> 1.5 GB ⇒ // unbootable" size guess with the authoritative on-disk boot signal, // so a large bootable ISO is no longer mislabeled and a Windows ISO // re-introspected on upgrade lights up correctly. if (iso.introspection.el_torito) { return { ok: true, warn: 'generic bootable ISO — boots via sanboot (emulated CD)' }; } // Remote-share ISOs aren't introspected (no random access over the // network), so el_torito is unknown — assume bootable and let sanboot // try rather than cry wolf. const remote = iso.source && iso.source.kind && iso.source.kind !== 'local'; if (remote) { return { ok: true, warn: 'remote ISO — not introspected; sanboot is attempted at boot' }; } // Local ISO with no Windows/Linux boot files and no El Torito catalog: // a data/appliance image (e.g. a VMware vCenter bundle), not a bootable // installer. return { ok: false, reason: 'data/appliance ISO — no El Torito boot catalog and no Windows/Linux installer files, so it can’t be PXE-booted' }; } // v0.5.2: pretty label for an unattended file's detected kind. function unattendedKindLabel(k) { return ({ kickstart: 'Kickstart', preseed: 'Preseed', autoinstall: 'Autoinstall', answer_file: 'Answer file', unknown: 'Unknown', })[k] || (k || 'Unknown'); } // v0.7.2: compact read-out of saved group rules — created from the // unified "Pin MAC" form on the Hosts tab (a prefix or an architecture // there saves a rule instead of a pin). First match wins, top to // bottom. The boot-decision webhook remains available via the API // (/api/boot-rules `webhook_url`) but no longer has a UI knob. function groupRulesCard(cfg, targetOptions) { const rules = (cfg && cfg.rules) || []; if (!rules.length) return null; const titleFor = id => { const t = targetOptions.find(x => x.id === id); return t ? t.title : id; }; const modeLabel = {firmware:'Firmware NIC', builtin:'iPXE drivers', shim:'Secure Boot (shim)'}; const rows = rules.map((r, i) => el('tr', r.enabled === false ? {style:'opacity:.5'} : {}, [ el('td', {class:'mono'}, r.mac_prefix || el('span', {class:'tag'}, 'any MAC')), el('td', {}, r.arch || el('span', {class:'tag'}, 'any arch')), el('td', {}, r.target ? titleFor(r.target) : el('span', {class:'tag'}, '—')), el('td', {}, r.driver_mode ? el('span', {class:'tag accent'}, modeLabel[r.driver_mode] || r.driver_mode) : el('span', {class:'tag'}, 'auto')), el('td', {}, r.note || ''), el('td', {style:'text-align:right'}, el('button', {class:'danger', onclick: async () => { if (!confirm('Remove this group rule?')) return; const fresh = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''})); (fresh.rules = fresh.rules || []).splice(i, 1); await putJSON('/api/boot-rules', fresh); render('hosts'); }}, 'Remove')), ])); return el('div', {class:'card'}, [ el('header', {}, [ el('h2', {}, 'Group rules'), el('span', {class:'sub'}, 'first match wins · checked top to bottom'), ]), el('table', {}, [ el('thead', {}, el('tr', {}, [ el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'), el('th',{},'Boot binary'), el('th',{},'Note'), el('th',{},''), ])), el('tbody', {}, rows), ]), ]); } // v0.5.2: build the shared "deployment profile" field group — auto // hostname, auto IP, and an unattended-file picker — reused by the // Hosts pin form and the Queue "Profile" modal. `files` is the // /api/unattended list; `profile` seeds the current values. Returns the // wrapper element plus a `read()` that yields the API body shape. function buildProfileFields(profile, files, layoutClass) { profile = profile || {}; files = files || []; const hostnameInput = el('input', {type:'text', spellcheck:'false', placeholder:'e.g. node-7', value: profile.auto_hostname || ''}); const ipInput = el('input', {type:'text', spellcheck:'false', placeholder:'e.g. 10.0.0.7', value: profile.auto_ip || ''}); const sel = el('select', {}, [el('option', {value:''}, '— none —')].concat( files.map(f => el('option', {value: f.id}, f.filename + ' · ' + unattendedKindLabel(f.kind))))); sel.value = profile.unattended_file || ''; const wrap = el('div', {class: layoutClass || 'form-row cols-3'}, [ el('label', {class:'field'}, [ el('span', {class:'name'}, 'Auto hostname (optional)'), hostnameInput]), el('label', {class:'field'}, [ el('span', {class:'name'}, 'Auto IP address (optional)'), ipInput]), el('label', {class:'field'}, [ el('span', {class:'name'}, 'Unattended file'), sel]), ]); return { wrap, read() { return { auto_hostname: hostnameInput.value.trim() || null, auto_ip: ipInput.value.trim() || null, unattended_file: sel.value || null, }; }, }; } // v0.5.2: minimal modal overlay. `onSave(msgEl)` runs on Save and may // return a falsy value to keep the modal open (e.g. on validation // error) or anything truthy to close it. function openModal(titleText, contentEls, onSave) { const overlay = el('div', {class:'modal-overlay'}); const close = () => { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }; const msg = el('div', {class:'msg', style:'margin-top:10px'}); const cancelBtn = el('button', {class:'ghost', type:'button', onclick: close}, 'Cancel'); const saveBtn = el('button', {class:'submit', type:'button'}, 'Save'); saveBtn.onclick = async () => { saveBtn.disabled = true; try { const ok = await onSave(msg); if (ok) close(); } finally { saveBtn.disabled = false; } }; overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); document.addEventListener('keydown', function esc(e) { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); } }); overlay.appendChild(el('div', {class:'modal-box'}, [ el('h2', {}, titleText), ...(Array.isArray(contentEls) ? contentEls : [contentEls]), msg, el('div', {class:'modal-actions'}, [cancelBtn, saveBtn]), ])); document.body.appendChild(overlay); return { close }; } // ── views ──────────────────────────────────────────────────────── const views = { dashboard: async () => { const status = await getJSON('/api/status'); const isos = await getJSON('/api/isos'); const clients = (await getJSON('/api/clients')).clients || []; const entries = (await getJSON('/api/queue')).entries || []; // v0.4.68: surface the same disk-space card the Storage tab shows, // so operators see capacity at a glance from the landing page. // Tolerate the endpoint being unavailable (e.g. statvfs failure) // the same way the Storage tab does. const disk = await getJSON('/api/storage/disk').catch(() => ({ total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?', })); const ipxeOk = (status.ipxe_assets || []).length > 0; const stats = el('div', {class: 'statstrip'}, [ el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'label'}, 'Server status'), el('div', {class: 'value', style: 'font-size:18px;color:' + (ipxeOk ? 'var(--ok)' : 'var(--err)')}, ipxeOk ? 'Ready' : 'Not ready'), el('div', {class: 'trend'}, ipxeOk ? 'Bootloaders bundled, accepting clients' : 'No iPXE binaries bundled'), ])), el('div', {class: 'card'}, [ el('div', {class: 'stat', style: 'padding-bottom:0'}, [ el('div', {class: 'label'}, 'Imaging now'), el('div', {class: 'value'}, String(status.imaging_count || 0)), el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting in queue'), ]), queueProgressWidget(status.imaging_count || 0, status.queue_count || 0), ]), el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'label'}, 'Images available'), el('div', {class: 'value'}, String(isos.length)), el('div', {class: 'trend'}, (() => { // v0.5.9: count families honestly. Anything that isn't a known // Linux family or Windows lands in "other" (data/appliance ISOs // like VMware VCSA, or as-yet-unclassified images) instead of // being lumped under "Linux". const LINUX = ['debian_ubuntu', 'rhel_fedora', 'opensuse', 'arch', 'alpine']; const win = isos.filter(i => i.introspection.family === 'windows_pe').length; const lin = isos.filter(i => LINUX.includes(i.introspection.family)).length; const other = isos.length - win - lin; // v0.4.67+v0.5.5: count all remote-share protocols. Label // generically since operators may use any mix of SMB/NFS/SFTP. const remote = (status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0); const parts = [win + ' Windows', lin + ' Linux']; if (other > 0) parts.push(other + ' other'); parts.push(remote + ' remote share' + (remote === 1 ? '' : 's')); return parts.join(' · '); })()), ])), el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'label'}, 'Uptime'), el('div', {class: 'value', style: 'font-size:22px'}, fmtUptime(status.uptime_secs)), el('div', {class: 'trend'}, 'OpenPXE ' + status.version), ])), ]); // Recent connections — the operator's at-a-glance "who tried to // boot" log. Use last_seen desc (already sorted by API). const recent = clients.slice(0, 8); const recentRows = recent.map(c => { const g = entries.find(g => g.mac === c.mac); let status = el('span', {class: 'tag ok'}, 'active'); if (g && g.assigned_target) status = el('span', {class:'tag ok'}, 'assigned: ' + g.assigned_target); else if (g) status = el('span', {class:'tag accent'}, '#' + g.position + ' in queue'); return el('tr', {}, [ el('td', {class: 'mono'}, c.mac), el('td', {}, c.last_ip ? String(c.last_ip) : '-'), el('td', {}, el('span', {class:'tag arch'}, archLabel(c.arch))), el('td', {}, fmtAgo(c.last_seen)), el('td', {}, status), ]); }); const recentBlock = el('div', {class: 'card'}, [ el('header', {}, [ el('h2', {}, 'Recent connections'), el('span', {class: 'sub'}, clients.length + ' total'), ]), recent.length ? el('table', {}, [ el('thead', {}, el('tr', {}, [ el('th',{},'MAC'), el('th',{},'IP'), el('th',{},'Arch'), el('th',{},'Last seen'), el('th',{},'Status'), ])), el('tbody', {}, recentRows), ]) : el('div', {class: 'empty'}, 'No PXE clients have contacted this server yet.'), ]); // At-a-glance pool of problem images — Bootimus-style early warning. const settings = status.settings; const problems = isos.map(i => ({i, b: bootability(i, settings)})).filter(x => !x.b.ok); const problemsBlock = problems.length ? el('div', {class:'card'}, [ el('header', {}, [el('h2', {}, 'Non-bootable images')]), el('div', {class:'body'}, problems.map(({i, b}) => el('div', {class:'row-warn'}, '⚠ ' + i.filename + ' — ' + b.reason))) ]) : null; return el('div', {class:'grid'}, [stats, diskSpaceCard(disk), recentBlock, problemsBlock].filter(Boolean)); }, network: async () => { const net = await getJSON('/api/network'); const dns = el('input', {type:'text', value: net.dns_server || '', placeholder: 'Optional, e.g. 8.8.8.8 or 1.1.1.1'}); const msg = el('div', {class:'msg'}); const save = el('button', {onclick: async () => { const r = await putJSON('/api/network', { dns_server: dns.value }); if (r.status === 204) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; } else { msg.textContent = 'Save failed: ' + r.status; msg.className = 'msg err'; } }}, 'Save'); const networkCard = el('div', {class:'card'}, [ el('header', {}, el('h2', {}, 'Network')), el('div', {class:'body'}, [ el('div', {class:'kv'}, [ el('div', {class:'k'}, 'Server IP'), el('div', {class:'v'}, net.server_ip || '?'), el('div', {class:'k'}, 'NIC name'), el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'), el('div', {class:'k'}, 'Subnet mask'), el('div', {class:'v'}, net.subnet_mask || '?'), el('div', {class:'k'}, 'Gateway'), el('div', {class:'v'}, net.gateway || '?'), el('div', {class:'k'}, 'Public base URL'), el('div', {class:'v'}, net.public_base_url), // v0.7.2: physical link details (operstate · speed · duplex · // port MAC) so the operator can confirm WHICH port answers PXE // in multi-NIC / trunked environments. Kept last — the joined // value runs long, so it wraps cleanly at the bottom of the list. el('div', {class:'k'}, 'Link'), el('div', {class:'v'}, net.nic_link || '—'), ]), el('p', {class:'msg'}, 'Server IP, NIC, mask, and gateway are auto-detected at startup. ' + 'To change them, set OPENPXE_PUBLIC_IP and restart — editing them ' + 'from a hot UI would silently break PXE for every client mid-boot.'), el('label', {class:'field', style:'margin-top:18px'}, [ el('span', {class:'name'}, 'DNS server (optional, informational)'), dns, el('span', {class:'hint'}, 'OpenPXE does not run a DNS server itself; this field records ' + 'what your upstream DNS is so you don\'t have to dig it out at ' + '3 AM during a deployment.'), ]), save, msg, ]), ]); return el('div', {class:'grid'}, [networkCard]); }, queue: async () => { const [{ entries = [] }, isos, unattRes] = await Promise.all([ getJSON('/api/queue'), getJSON('/api/isos'), getJSON('/api/unattended').catch(() => ({ files: [] })), ]); const unattendedFiles = unattRes.files || []; const targets = isos.flatMap(i => i.boot_entries.map(e => ({ id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family) }))); const pick = el('select', {}, [el('option', {value: ''}, '— choose an image —')] .concat(targets.map(t => el('option', {value: t.id}, t.title))) ); const launch = el('button', {}, 'Launch for all waiting'); const msg = el('div', {class:'msg'}); launch.onclick = async () => { if (!pick.value) { msg.textContent = 'Pick an image first.'; msg.className='msg err'; return; } const r = await postJSON('/api/queue/assign', { target: pick.value, entry_ids: [] }); if (!r.ok) { msg.textContent = 'Assign failed: ' + r.status; msg.className='msg err'; return; } const j = await r.json(); if (!j.ok) { msg.textContent = 'Assign failed: ' + (j.error || 'unknown'); msg.className='msg err'; return; } msg.textContent = 'Launched ' + j.assigned + ' client' + (j.assigned===1?'':'s') + ' → ' + j.target; msg.className = 'msg ok'; render('queue'); }; const track = entries.length ? el('div', {class:'queue-track'}, entries.map(g => { const prof = g.profile || {}; const hasProfile = prof.auto_hostname || prof.auto_ip || prof.unattended_file; const profSummary = hasProfile ? el('div', {class:'meta', style:'margin-top:2px'}, '⚙ ' + [ prof.auto_hostname ? 'host ' + prof.auto_hostname : null, prof.auto_ip ? 'ip ' + prof.auto_ip : null, prof.unattended_file ? 'unattended: ' + prof.unattended_file : null, ].filter(Boolean).join(' · ')) : null; return el('div', {class:'queue-row' + (g.assigned_target ? ' assigned' : '')}, [ el('div', {class:'pos'}, '#' + g.position), el('div', {}, [ el('div', {class:'mac'}, g.mac), el('div', {class:'meta'}, (g.ip ? String(g.ip) + ' · ' : '') + archLabel(g.arch) + ' · joined ' + fmtAgo(g.joined_at)), profSummary, ]), el('div', {}, g.assigned_target ? el('span', {class:'tag ok'}, '→ ' + g.assigned_target) : el('span', {class:'tag accent'}, 'waiting')), // v0.5.2: per-device deployment profile (auto hostname/IP + // unattended file), same fields as a Hosts pin. el('button', {class: hasProfile ? 'accent' : 'ghost', onclick: () => { const fields = buildProfileFields(prof, unattendedFiles, 'form-row'); openModal('Deployment profile · ' + g.mac, [ el('p', {class:'msg', style:'margin-bottom:12px'}, 'On assignment this device boots with the chosen unattended ' + 'file; {{HOSTNAME}}/{{IP}}/{{MAC}} are filled into the answer file.'), fields.wrap, ], async (msg) => { const r = await putJSON('/api/queue/' + encodeURIComponent(g.id) + '/profile', fields.read()); if (r.ok) { render('queue'); return true; } msg.textContent = 'Save failed: ' + (await r.text()); msg.className = 'msg err'; return false; }); }}, 'Profile'), el('button', {class:'ghost', onclick: async () => { await fetch('/api/queue/' + encodeURIComponent(g.id), {method:'DELETE'}); render('queue'); }}, 'Release'), ]); }) ) : el('div', {class:'empty'}, 'No clients queued. Boot a client and choose "Queued Deployment" in the PXE menu.'); const imaging = entries.filter(g => g.assigned_target).length; return el('div', {class:'grid'}, [ el('div', {class:'card'}, [ el('header', {}, el('h2', {}, 'Status')), queueProgressWidget(imaging, entries.length), ]), el('div', {class:'card'}, [ el('header', {}, el('h2', {}, 'Launch image for queued clients')), el('div', {class:'body'}, [ el('label', {class:'field'}, [ el('span', {class:'name'}, 'Target image'), pick, el('span', {class:'hint'}, 'Selecting "Launch" starts every waiting client on the chosen image simultaneously.'), ]), launch, msg, ]), ]), el('div', {class:'card'}, [ el('header', {}, [ el('h2', {}, 'Queue positions'), el('span', {class:'sub'}, entries.length + ' waiting'), ]), el('div', {class:'body'}, track), ]), ]); }, storage: async () => { // v0.4.65: kernel-mount NFS replaced with userspace SMB via // smbclient — works in any container regardless of host kernel // modules or capabilities. // v0.4.67: NFSv3 added back as an in-process Rust client // (nfs3_client crate). Both protocols available side-by-side; // operators pick whichever their NAS prefers. const [isos, settings, smbRes, nfsRes, sftpRes, disk, unattRes] = await Promise.all([ getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/smb-shares'), getJSON('/api/nfs-shares'), getJSON('/api/sftp-shares'), getJSON('/api/storage/disk').catch(() => ({ total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?', })), getJSON('/api/unattended').catch(() => ({ files: [] })), ]); const shares = smbRes.shares || []; const nfsShares = nfsRes.shares || []; const sftpShares = sftpRes.shares || []; const unattendedFiles = unattRes.files || []; // ── Upload card ── const drop = el('div', {class:'drop', id:'drop'}, [ el('div', {}, ['Drop an ', el('strong', {}, '.iso'), ' here, or click to choose.']), el('div', {style:'font-size:12px;margin-top:6px'}, 'Linux + Windows installers auto-detected on upload. Streaming, no 502s on big files.'), ]); const file = el('input', {type:'file', accept:'.iso,application/octet-stream', style:'display:none', id:'file'}); const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'})); const upMsg = el('div', {class:'msg', id:'upmsg'}); // v0.5.8: cancel button — shown only while an upload is in flight. const cancelUpload = el('button', {class:'danger', type:'button', style:'display:none;margin-top:12px', id:'cancel-upload'}, 'Cancel upload'); drop.onclick = () => file.click(); drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); }); drop.addEventListener('dragleave', () => drop.classList.remove('hover')); drop.addEventListener('drop', e => { e.preventDefault(); drop.classList.remove('hover'); if (e.dataTransfer.files[0]) upload(e.dataTransfer.files[0]); }); file.onchange = () => { if (file.files[0]) upload(file.files[0]); }; // Chunked upload telemetry. The old browser path posted one huge // multipart body, which left operators staring at 0% when a reverse // proxy buffered or rejected the request before OpenPXE saw it. This // path writes small raw chunks; each acknowledged chunk advances the // bar and leaves a visible .partial file in the ISO directory. async function upload(f) { const started = Date.now(); const bar = $('#bar'); const setStatus = (text, cls) => { upMsg.textContent = text; upMsg.className = 'msg ' + (cls || ''); }; const update = (loaded, total, phase) => { const pct = total > 0 ? Math.min(100, (loaded / total) * 100) : 100; bar.style.width = pct.toFixed(1) + '%'; const elapsed = Math.max(0.001, (Date.now() - started) / 1000); const rate = loaded > 0 ? loaded / elapsed : 0; const remain = rate > 0 ? (total - loaded) / rate : 0; setStatus( phase + ' ' + f.name + ' - ' + fmtBytes(loaded) + ' of ' + fmtBytes(total) + ' (' + pct.toFixed(1) + '%, ' + fmtBytes(rate) + '/s' + (remain > 0 ? ', ' + Math.ceil(remain) + 's left' : '') + ')'); }; const failText = async (r) => { const text = (await r.text()).slice(0, 240); let hint = ''; if (r.status === 413) hint = ' - body too large. A proxy likely rejected this chunk.'; else if (r.status === 502) hint = ' - bad gateway. Proxy lost the upstream mid-stream.'; else if (r.status === 504) hint = ' - gateway timeout. Try the LAN IP directly.'; else if (r.status === 409) hint = ' - name conflict or offset mismatch. Remove the old ISO and retry.'; return 'HTTP ' + r.status + ' ' + text + hint; }; let uploadId = null; // v0.5.8: cancel + leave-page guard. The AbortController stops the // in-flight chunk; the beforeunload listener warns the operator // that navigating away aborts the upload (the server-side partial // is then cleaned up by the DELETE in the catch below). const ac = new AbortController(); let canceled = false; const warnLeave = (e) => { e.preventDefault(); e.returnValue = ''; return ''; }; window.addEventListener('beforeunload', warnLeave); cancelUpload.style.display = ''; cancelUpload.onclick = () => { canceled = true; ac.abort(); }; setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')'); prog.classList.add('active'); bar.style.width = '1%'; try { const begin = await postJSON('/api/uploads', { filename: f.name, size_bytes: f.size, }); if (!begin.ok) throw new Error(await failText(begin)); const session = await begin.json(); uploadId = session.upload_id; const chunkSize = Math.max(1024 * 1024, Number(session.chunk_size || 8 * 1024 * 1024)); let offset = Number(session.offset || 0); let finished = null; do { const end = Math.min(offset + chunkSize, f.size); const complete = end >= f.size; const r = await fetch('/api/uploads/' + encodeURIComponent(uploadId), { method: 'PUT', headers: { 'x-openpxe-upload-offset': String(offset), 'x-openpxe-upload-complete': complete ? 'true' : 'false', }, body: f.slice(offset, end), signal: ac.signal, }); if (!r.ok) throw new Error(await failText(r)); const j = await r.json(); offset = Number(j.offset || end); update(offset, f.size, complete ? 'Analyzing' : 'Uploading'); if (j.complete) finished = j.iso || true; } while (!finished); setStatus('Uploaded and analyzed: ' + f.name + ' (' + fmtBytes(f.size) + ')', 'ok'); render('storage'); } catch (err) { if (uploadId) { try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); } catch {} } if (canceled || (err && err.name === 'AbortError')) { setStatus('Upload canceled — partial file discarded.', ''); } else { setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err'); } } finally { window.removeEventListener('beforeunload', warnLeave); cancelUpload.style.display = 'none'; cancelUpload.onclick = null; prog.classList.remove('active'); if (!upMsg.className.includes('ok')) bar.style.width = '0'; } } // ── ISO table (mixed local + SMB) ── // Each row gets a "Password" cell that toggles a small inline // editor (a checkbox + a password field + Save button) inside the // *next* row of the table. Keeps the markup flat and avoids the // overhead of a real modal. const rowsAndEditors = []; // v0.5.8: list Available images alphabetically by filename // (case-insensitive, natural numeric order) instead of newest-first. const sortedIsos = [...isos].sort((a, b) => (a.filename || '').localeCompare(b.filename || '', undefined, { sensitivity: 'base', numeric: true })); sortedIsos.forEach(i => { const b = bootability(i, settings); // v0.4.65: SMB userspace consumer (smbclient). // v0.4.67: NFS back as in-process Rust client (nfs3_client). // Both render with the same "remote" badge colour — they // share the same "on a remote share, can't be deleted from // here" semantics in the UI. const isSmb = i.source && i.source.kind === 'smb'; const isNfs = i.source && i.source.kind === 'nfs'; const isRemote = isSmb || isNfs; const protectedNow = !!i.password_hash; // The inline editor row is hidden by default; the Password // button toggles its `display`. Pre-built so toggle is cheap. const pwCheck = el('input', {type:'checkbox'}); pwCheck.checked = protectedNow; const pwInput = el('input', { type: 'password', spellcheck: 'false', autocomplete: 'new-password', autocapitalize: 'off', placeholder: protectedNow ? '(unchanged — type to replace)' : 'choose a password', }); const pwInputWrap = el('label', {class:'field', style:'flex:1;margin:0'}, [ el('span', {class:'name'}, 'Password'), pwInput, ]); // Toggle the password field's visibility off when the checkbox // is unchecked, so the operator's intent is unambiguous on Save. const refreshFieldVisibility = () => { pwInputWrap.style.display = pwCheck.checked ? '' : 'none'; }; pwCheck.onchange = refreshFieldVisibility; const pwMsg = el('div', {class:'msg', style:'margin-top:6px'}); const pwSave = el('button', {style:'flex:none', onclick: async () => { let resp; if (pwCheck.checked) { // Empty input + previously protected = keep the old password // (operator just toggled the box on but didn't type). We // detect this by sending the API only when the field has // content; otherwise no-op + show hint. if (!pwInput.value && !protectedNow) { pwMsg.textContent = 'Enter a password to enable.'; pwMsg.className = 'msg err'; return; } if (!pwInput.value && protectedNow) { pwMsg.textContent = 'Password unchanged.'; pwMsg.className = 'msg ok'; return; } resp = await putJSON( '/api/isos/' + encodeURIComponent(i.id) + '/password', { password: pwInput.value }); } else { resp = await fetch( '/api/isos/' + encodeURIComponent(i.id) + '/password', {method: 'DELETE'}); } if (resp.ok || resp.status === 204) { // Wipe the input field before re-rendering so the // plaintext doesn't sit in DOM longer than necessary. pwInput.value = ''; render('storage'); } else { const t = await resp.text(); pwMsg.textContent = 'Save failed: ' + t; pwMsg.className = 'msg err'; } }}, 'Save password'); const editorCells = el('td', {colspan: '7', style:'background:var(--bg-panel-2);padding:14px 18px'}, [ el('div', {style:'display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap'}, [ el('label', {class:'check', style:'flex:none;margin:0'}, [ pwCheck, el('span', {}, 'Password protect this image'), ]), pwInputWrap, pwSave, ]), el('div', {class:'msg', style:'margin-top:8px;font-size:11.5px'}, 'Operators booting this ISO will be prompted on the PXE client. ' + 'Stored bcrypt-hashed; the plaintext never leaves the request.'), pwMsg, ]); const editorRow = el('tr', {style:'display:none'}, editorCells); refreshFieldVisibility(); // Type cell becomes an OS/Tools