Design-language cleanup of the v0.7.2 additions (no behaviour change). Hosts: - The Pin MAC form collapses to a single aligned 4-up row: MAC · Label · Architecture · Boot binary. Target drops full-width onto its own line beneath them. The per-field hints that broke the row's alignment moved into the explanatory note below, so every control shares one baseline. Storage: - The image and unattended filter inputs are now wrapped in a label.field, so they inherit the standard text-field chrome (border, radius, height, focus ring) instead of the raw browser <input type=search> look. They span the full card width for continuity with the rest of the page. Network: - The 'Link' row moved below 'Public base URL'. Its joined operstate · speed · duplex · MAC string runs long, so placing it last lets it wrap at the bottom without shoving the other rows around. Validation: clippy clean, fmt clean, 299 workspace tests green, webui syntax-checked. No protocol or boot-path changes in this release. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2722 lines
129 KiB
JavaScript
2722 lines
129 KiB
JavaScript
// 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 <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');
|
||
};
|
||
|
||
// v0.7.2: searchable haystack for the list filter — filename,
|
||
// detected family, category, and source all match.
|
||
const searchText = [
|
||
i.filename, familyLabel(i.introspection.family), i.category || '',
|
||
isSmb ? 'smb' : isNfs ? 'nfs' : 'local', i.id,
|
||
].join(' ').toLowerCase();
|
||
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'),
|
||
]),
|
||
]);
|
||
tr.dataset.search = searchText;
|
||
rowsAndEditors.push(tr, editorRow);
|
||
});
|
||
|
||
// v0.7.2: client-side filter over the image table. Rows travel in
|
||
// (row, password-editor) pairs; filtering hides both, and an open
|
||
// editor stays closed for filtered-out rows.
|
||
const isoSearch = el('input', {type:'search', placeholder:'Filter images by name, type, or source',
|
||
spellcheck:'false', oninput: () => {
|
||
const q = isoSearch.value.trim().toLowerCase();
|
||
for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) {
|
||
const row = rowsAndEditors[k];
|
||
const editor = rowsAndEditors[k + 1];
|
||
const show = !q || (row.dataset.search || '').includes(q);
|
||
row.style.display = show ? '' : 'none';
|
||
if (!show) editor.style.display = 'none';
|
||
}
|
||
}});
|
||
const isoTable = isos.length
|
||
? el('table', {}, [
|
||
el('thead', {}, el('tr', {}, [
|
||
el('th',{},'Name'), el('th',{},'Type'),
|
||
el('th',{class:'num'},'Size'),
|
||
el('th',{},'Source'), el('th',{},'Auth'),
|
||
el('th',{},'Uploaded'), el('th',{},''),
|
||
])),
|
||
el('tbody', {}, rowsAndEditors),
|
||
])
|
||
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.');
|
||
|
||
// ── Remote shares section (v0.5.1) ──
|
||
// SMB + NFS unified into one "Remote shares" card with a protocol
|
||
// dropdown. The two protocols keep their own backend endpoints
|
||
// (/api/smb-shares, /api/nfs-shares) and the same add/scan/remove
|
||
// UX; the form just swaps the relevant fields. This declutters the
|
||
// Storage tab and leaves room for a future "Config files" card.
|
||
const shareMsg = el('div', {class:'msg'});
|
||
|
||
// Shared structured-error renderer ({error, stderr, hint}) for both
|
||
// protocols' add calls.
|
||
const showShareError = async (r) => {
|
||
let bodyJson = null;
|
||
let raw = null;
|
||
try { bodyJson = await r.clone().json(); }
|
||
catch (_) { raw = await r.text().catch(() => 'connect failed'); }
|
||
const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed');
|
||
const hint = bodyJson && bodyJson.hint;
|
||
const parts = [el('div', {}, [
|
||
el('strong', {}, 'Connect failed: '),
|
||
document.createTextNode(msg),
|
||
])];
|
||
if (hint) {
|
||
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
|
||
}
|
||
shareMsg.replaceChildren(...parts);
|
||
shareMsg.className = 'msg err';
|
||
};
|
||
|
||
// Protocol picker — swaps which field block is visible. v0.5.2:
|
||
// NFS is the default (listed first) — it has no credential fields,
|
||
// so the form lands cleaner than the SMB guest/user/password row.
|
||
const protoSelect = el('select', {}, [
|
||
el('option', {value:'nfs'}, 'NFS (NFSv3)'),
|
||
el('option', {value:'smb'}, 'SMB / CIFS'),
|
||
el('option', {value:'sftp'}, 'SFTP (SSH)'),
|
||
]);
|
||
|
||
// SMB inputs.
|
||
const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'});
|
||
const smbShare = el('input', {type:'text', placeholder:'isos'});
|
||
const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true;
|
||
const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'});
|
||
const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'});
|
||
const syncAuthDisabled = () => {
|
||
smbUser.disabled = smbGuest.checked;
|
||
smbPass.disabled = smbGuest.checked;
|
||
smbUser.style.opacity = smbGuest.checked ? '0.55' : '1';
|
||
smbPass.style.opacity = smbGuest.checked ? '0.55' : '1';
|
||
};
|
||
smbGuest.addEventListener('change', syncAuthDisabled);
|
||
syncAuthDisabled();
|
||
const smbFields = el('div', {}, [
|
||
el('div', {class:'form-row cols-2'}, [
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'SMB server'), smbServer]),
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Share name'), smbShare]),
|
||
]),
|
||
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
|
||
el('label', {class:'check'}, [smbGuest, el('span', {}, 'Guest (anonymous read)')]),
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username'), smbUser]),
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Password'), smbPass]),
|
||
]),
|
||
]);
|
||
|
||
const smbRowEls = shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
||
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
|
||
el('div', {}, [
|
||
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'SMB'),
|
||
document.createTextNode('//' + m.server + '/' + m.share)]),
|
||
el('div', {class:'meta'},
|
||
(m.guest ? 'guest' : ('user: ' + (m.username || '?'))) + ' · ' +
|
||
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
||
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
||
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
|
||
]),
|
||
el('button', {class:'ghost', onclick: async () => {
|
||
const r = await postJSON('/api/smb-shares/' + encodeURIComponent(m.id) + '/scan', {});
|
||
if (r.ok) render('storage');
|
||
}}, 'Re-scan'),
|
||
el('button', {class:'danger', onclick: async () => {
|
||
if (!confirm('Forget //' + m.server + '/' + m.share + '?')) return;
|
||
await fetch('/api/smb-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
|
||
render('storage');
|
||
}}, 'Remove'),
|
||
el('span'),
|
||
]));
|
||
|
||
// NFS inputs. The NFSv3 client is in-process (nfs3_client crate) so
|
||
// NFS-sourced ISOs support HTTP Range — SMB-sourced ones can't seek
|
||
// mid-stream. No auth fields: NFSv3 access is gated by client IP on
|
||
// the server's export list, not client-supplied credentials.
|
||
const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
|
||
const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
|
||
const nfsFields = el('div', {}, [
|
||
el('div', {class:'form-row cols-2'}, [
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'NFS server'), nfsServerIn]),
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Export path'), nfsExportIn]),
|
||
]),
|
||
]);
|
||
|
||
// SFTP inputs (v0.5.5). Pure-Rust russh client, in-process, so
|
||
// SFTP-sourced ISOs support HTTP Range like NFS. Auth is password
|
||
// OR an SSH private key (PEM, optional passphrase); the server's
|
||
// host key is pinned trust-on-first-use on the first connect.
|
||
const sftpServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
|
||
const sftpExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
|
||
const sftpUserIn = el('input', {type:'text', placeholder:'root'});
|
||
const sftpPortIn = el('input', {type:'number', placeholder:'22', min:'1', max:'65535'});
|
||
const sftpAuthMode = el('select', {}, [
|
||
el('option', {value:'password'}, 'Password'),
|
||
el('option', {value:'key'}, 'SSH private key'),
|
||
]);
|
||
const sftpPassIn = el('input', {type:'password', placeholder:'••••••••'});
|
||
const sftpKeyIn = el('textarea', {rows:'4',
|
||
placeholder:'-----BEGIN OPENSSH PRIVATE KEY-----',
|
||
style:'width:100%;font-family:ui-monospace,monospace;font-size:12px;resize:vertical'});
|
||
const sftpPassphraseIn = el('input', {type:'password',
|
||
placeholder:'(only if the private key is encrypted)'});
|
||
const sftpPassBlock = el('label', {class:'field'},
|
||
[el('span', {class:'name'}, 'Password'), sftpPassIn]);
|
||
const sftpKeyBlock = el('div', {}, [
|
||
el('label', {class:'field'},
|
||
[el('span', {class:'name'}, 'SSH private key (PEM)'), sftpKeyIn]),
|
||
el('label', {class:'field', style:'margin-top:10px'},
|
||
[el('span', {class:'name'}, 'Key passphrase (optional)'), sftpPassphraseIn]),
|
||
]);
|
||
const syncSftpAuth = () => {
|
||
const key = sftpAuthMode.value === 'key';
|
||
sftpPassBlock.style.display = key ? 'none' : '';
|
||
sftpKeyBlock.style.display = key ? '' : 'none';
|
||
};
|
||
sftpAuthMode.addEventListener('change', syncSftpAuth);
|
||
syncSftpAuth();
|
||
const sftpFields = el('div', {}, [
|
||
el('div', {class:'form-row cols-2'}, [
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'SSH server'), sftpServerIn]),
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Export path'), sftpExportIn]),
|
||
]),
|
||
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username'), sftpUserIn]),
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Port'), sftpPortIn]),
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Auth'), sftpAuthMode]),
|
||
]),
|
||
el('div', {style:'margin-top:14px'}, [sftpPassBlock, sftpKeyBlock]),
|
||
]);
|
||
|
||
// Swap the visible field block + clear any stale message.
|
||
const syncProto = () => {
|
||
const p = protoSelect.value;
|
||
smbFields.style.display = p === 'smb' ? '' : 'none';
|
||
nfsFields.style.display = p === 'nfs' ? '' : 'none';
|
||
sftpFields.style.display = p === 'sftp' ? '' : 'none';
|
||
shareMsg.replaceChildren();
|
||
shareMsg.className = 'msg';
|
||
};
|
||
protoSelect.addEventListener('change', syncProto);
|
||
|
||
// One add button; dispatches to the selected protocol's endpoint.
|
||
const addShare = el('button', {onclick: async () => {
|
||
if (protoSelect.value === 'smb') {
|
||
if (!smbServer.value || !smbShare.value) {
|
||
shareMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
|
||
shareMsg.className = 'msg err'; return;
|
||
}
|
||
if (!smbGuest.checked && !smbUser.value) {
|
||
shareMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.'));
|
||
shareMsg.className = 'msg err'; return;
|
||
}
|
||
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
|
||
shareMsg.className = 'msg';
|
||
const body = { server: smbServer.value, share: smbShare.value, guest: smbGuest.checked };
|
||
if (!smbGuest.checked) { body.username = smbUser.value; body.password = smbPass.value; }
|
||
const r = await postJSON('/api/smb-shares', body);
|
||
if (r.ok) {
|
||
shareMsg.replaceChildren(document.createTextNode('Connected.'));
|
||
shareMsg.className = 'msg ok';
|
||
render('storage');
|
||
} else { await showShareError(r); }
|
||
} else if (protoSelect.value === 'sftp') {
|
||
if (!sftpServerIn.value || !sftpExportIn.value || !sftpUserIn.value) {
|
||
shareMsg.replaceChildren(document.createTextNode('Server, export, and username are required.'));
|
||
shareMsg.className = 'msg err'; return;
|
||
}
|
||
const useKey = sftpAuthMode.value === 'key';
|
||
if (useKey && !sftpKeyIn.value.trim()) {
|
||
shareMsg.replaceChildren(document.createTextNode('Paste the SSH private key, or switch Auth to Password.'));
|
||
shareMsg.className = 'msg err'; return;
|
||
}
|
||
if (!useKey && !sftpPassIn.value) {
|
||
shareMsg.replaceChildren(document.createTextNode('Password is required, or switch Auth to SSH private key.'));
|
||
shareMsg.className = 'msg err'; return;
|
||
}
|
||
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
|
||
shareMsg.className = 'msg';
|
||
const body = {
|
||
server: sftpServerIn.value,
|
||
export: sftpExportIn.value,
|
||
username: sftpUserIn.value,
|
||
};
|
||
if (sftpPortIn.value) { body.port = parseInt(sftpPortIn.value, 10); }
|
||
if (useKey) {
|
||
body.private_key = sftpKeyIn.value;
|
||
if (sftpPassphraseIn.value) { body.passphrase = sftpPassphraseIn.value; }
|
||
} else {
|
||
body.password = sftpPassIn.value;
|
||
}
|
||
const r = await postJSON('/api/sftp-shares', body);
|
||
if (r.ok) {
|
||
shareMsg.replaceChildren(document.createTextNode('Connected.'));
|
||
shareMsg.className = 'msg ok';
|
||
render('storage');
|
||
} else { await showShareError(r); }
|
||
} else {
|
||
if (!nfsServerIn.value || !nfsExportIn.value) {
|
||
shareMsg.replaceChildren(document.createTextNode('Server and export are required.'));
|
||
shareMsg.className = 'msg err'; return;
|
||
}
|
||
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
|
||
shareMsg.className = 'msg';
|
||
const r = await postJSON('/api/nfs-shares', { server: nfsServerIn.value, export: nfsExportIn.value });
|
||
if (r.ok) {
|
||
shareMsg.replaceChildren(document.createTextNode('Connected.'));
|
||
shareMsg.className = 'msg ok';
|
||
render('storage');
|
||
} else { await showShareError(r); }
|
||
}
|
||
}}, 'Add share');
|
||
|
||
const nfsRowEls = nfsShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
||
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
|
||
el('div', {}, [
|
||
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'NFS'),
|
||
document.createTextNode(m.server + ':' + m.export)]),
|
||
el('div', {class:'meta'},
|
||
'NFSv3 · ' + (m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
||
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
||
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
|
||
]),
|
||
el('button', {class:'ghost', onclick: async () => {
|
||
const r = await postJSON('/api/nfs-shares/' + encodeURIComponent(m.id) + '/scan', {});
|
||
if (r.ok) render('storage');
|
||
}}, 'Re-scan'),
|
||
el('button', {class:'danger', onclick: async () => {
|
||
if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return;
|
||
await fetch('/api/nfs-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
|
||
render('storage');
|
||
}}, 'Remove'),
|
||
el('span'),
|
||
]));
|
||
|
||
const sftpRowEls = sftpShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
||
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
|
||
el('div', {}, [
|
||
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'SFTP'),
|
||
document.createTextNode(m.username + '@' + m.server + ':' + m.export)]),
|
||
el('div', {class:'meta'},
|
||
'SSH · ' + (m.auth === 'key' ? 'key' : 'password') + ' · ' +
|
||
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
||
m.host_key_fingerprint
|
||
? el('div', {style:'margin-top:4px;opacity:.65;font-size:11px;font-family:ui-monospace,monospace;word-break:break-all'},
|
||
'host key ' + m.host_key_fingerprint)
|
||
: null,
|
||
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
||
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
|
||
]),
|
||
el('button', {class:'ghost', onclick: async () => {
|
||
const r = await postJSON('/api/sftp-shares/' + encodeURIComponent(m.id) + '/scan', {});
|
||
if (r.ok) render('storage');
|
||
}}, 'Re-scan'),
|
||
el('button', {class:'danger', onclick: async () => {
|
||
if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return;
|
||
await fetch('/api/sftp-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
|
||
render('storage');
|
||
}}, 'Remove'),
|
||
el('span'),
|
||
]));
|
||
|
||
const totalShares = shares.length + nfsShares.length + sftpShares.length;
|
||
const remoteRows = totalShares
|
||
? [...smbRowEls, ...nfsRowEls, ...sftpRowEls]
|
||
: [el('div', {class:'empty'}, 'No remote shares configured.')];
|
||
syncProto();
|
||
|
||
const diskCard = diskSpaceCard(disk);
|
||
|
||
// ── Advanced: unattended answer-file upload (v0.5.2) ──
|
||
// Mirrors the Settings "Advanced" disclosure. Kickstart / Preseed /
|
||
// Autoinstall / Windows answer files land in their own directory
|
||
// (never the ISO listing or PXE menu) and are referenced by host
|
||
// pins + queue profiles.
|
||
const unattMsg = el('div', {class:'msg', style:'margin-top:10px'});
|
||
const unattFile = el('input', {
|
||
type:'file',
|
||
accept:'.ks,.cfg,.seed,.yaml,.yml,.xml',
|
||
style:'display:none', id:'unatt-file',
|
||
});
|
||
async function uploadUnattended(f) {
|
||
const fd = new FormData(); fd.append('file', f, f.name);
|
||
unattMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…';
|
||
unattMsg.className = 'msg';
|
||
const r = await fetch('/api/unattended', {method:'POST', body: fd});
|
||
if (r.ok) {
|
||
unattMsg.textContent = 'Stored ' + f.name + '.';
|
||
unattMsg.className = 'msg ok';
|
||
render('storage');
|
||
} else {
|
||
unattMsg.textContent = 'Upload failed: ' + (await r.text());
|
||
unattMsg.className = 'msg err';
|
||
}
|
||
}
|
||
const unattDrop = el('div', {class:'drop', id:'unatt-drop'}, [
|
||
el('div', {}, 'Drop a Kickstart, Preseed, Autoinstall, or Answer File here.'),
|
||
el('div', {style:'font-size:12px;margin-top:6px'},
|
||
'Accepted: .ks · .cfg · .seed · .yaml · .yml · .xml (or user-data). ' +
|
||
'Use {{HOSTNAME}}, {{IP}}, {{MAC}} as placeholders — they are filled in per host at boot.'),
|
||
]);
|
||
unattDrop.onclick = () => unattFile.click();
|
||
unattDrop.addEventListener('dragover', e => { e.preventDefault(); unattDrop.classList.add('hover'); });
|
||
unattDrop.addEventListener('dragleave', () => unattDrop.classList.remove('hover'));
|
||
unattDrop.addEventListener('drop', e => {
|
||
e.preventDefault(); unattDrop.classList.remove('hover');
|
||
if (e.dataTransfer.files[0]) uploadUnattended(e.dataTransfer.files[0]);
|
||
});
|
||
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
|
||
|
||
const unattRows = unattendedFiles.length
|
||
? unattendedFiles.map(f => el('div', {
|
||
class:'nfs-row',
|
||
'data-search': (f.filename + ' ' + unattendedKindLabel(f.kind) + ' ' + f.id).toLowerCase(),
|
||
}, [
|
||
el('span', {class:'dot ok'}),
|
||
el('div', {}, [
|
||
el('div', {class:'id'}, [
|
||
el('span', {class:'proto-badge'}, unattendedKindLabel(f.kind)),
|
||
document.createTextNode(f.filename),
|
||
]),
|
||
el('div', {class:'meta'}, fmtBytes(f.size_bytes) + ' · id ' + f.id),
|
||
]),
|
||
el('span'),
|
||
el('button', {class:'danger', onclick: async () => {
|
||
if (!confirm('Delete unattended file ' + f.filename + '?')) return;
|
||
await fetch('/api/unattended/' + encodeURIComponent(f.id), {method:'DELETE'});
|
||
render('storage');
|
||
}}, 'Delete'),
|
||
el('span'),
|
||
]))
|
||
: [el('div', {class:'empty'}, 'No unattended files yet.')];
|
||
|
||
const unattendedAdvanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
|
||
el('summary', {class:'advanced-summary'}, 'Advanced'),
|
||
el('div', {class:'card', style:'margin-top:14px'}, [
|
||
el('header', {}, [
|
||
el('h2', {}, 'Unattended file upload'),
|
||
el('span', {class:'sub'}, unattendedFiles.length + ' file' + (unattendedFiles.length === 1 ? '' : 's')),
|
||
]),
|
||
el('div', {class:'body'}, [
|
||
unattDrop, unattFile, unattMsg,
|
||
// v0.7.2: filter for big answer-file libraries.
|
||
unattendedFiles.length > 1 ? (() => {
|
||
const search = el('input', {type:'search', placeholder:'Filter files by name or kind',
|
||
spellcheck:'false', oninput: () => {
|
||
const q = search.value.trim().toLowerCase();
|
||
unattRows.forEach(r => {
|
||
r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none';
|
||
});
|
||
}});
|
||
return el('label', {class:'field', style:'margin-top:14px;margin-bottom:0'}, search);
|
||
})() : null,
|
||
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
|
||
el('p', {class:'msg', style:'margin-top:14px'},
|
||
'These answer files drive unattended installs. Attach one to a ' +
|
||
'host pin (Hosts tab) or a queued device (Queue → Profile); on ' +
|
||
'boot OpenPXE injects the matching kernel argument and serves the ' +
|
||
'file with the host’s name/IP filled in. Stored separately from ISOs.'),
|
||
]),
|
||
]),
|
||
]);
|
||
|
||
return el('div', {}, [el('div', {class:'grid'}, [
|
||
diskCard,
|
||
el('div', {class:'card'}, [
|
||
el('header', {}, el('h2', {}, 'Upload ISO')),
|
||
el('div', {class:'body'}, [drop, file, prog, upMsg, cancelUpload]),
|
||
]),
|
||
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a
|
||
// protocol dropdown. Backend endpoints are unchanged; this is a
|
||
// pure UI consolidation that declutters the Storage tab.
|
||
el('div', {class:'card'}, [
|
||
el('header', {}, [
|
||
el('h2', {}, 'Remote shares'),
|
||
el('span', {class:'sub'}, totalShares + ' configured'),
|
||
]),
|
||
el('div', {class:'body'}, [
|
||
el('div', {class:'form-row cols-2'}, [
|
||
el('label', {class:'field'}, [
|
||
el('span', {class:'name'}, 'Protocol'),
|
||
protoSelect,
|
||
]),
|
||
el('span'),
|
||
]),
|
||
el('div', {style:'margin-top:14px'}, [smbFields, nfsFields, sftpFields]),
|
||
addShare, shareMsg,
|
||
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows),
|
||
el('p', {class:'msg', style:'margin-top:14px'},
|
||
'Remote .iso libraries are read on demand — no local cache to ' +
|
||
'preserve disk usage. Support for NFS 3.0, SMB, and SFTP (SSH). ' +
|
||
'Ensure that the hosts IP address is provisioned.'),
|
||
]),
|
||
]),
|
||
el('div', {class:'card'}, [
|
||
el('header', {}, [
|
||
el('h2', {}, 'Available images'),
|
||
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
|
||
]),
|
||
isos.length > 1
|
||
? el('div', {class:'list-search'}, el('label', {class:'field', style:'margin-bottom:0'}, isoSearch))
|
||
: null,
|
||
isoTable,
|
||
]),
|
||
]), unattendedAdvanced]);
|
||
},
|
||
|
||
hosts: async () => {
|
||
const [{ hosts = [] }, isos, bootLogRes, unattRes, rulesCfg] = await Promise.all([
|
||
getJSON('/api/hosts'), getJSON('/api/isos'),
|
||
getJSON('/api/boot-log').catch(() => ({ events: [] })),
|
||
getJSON('/api/unattended').catch(() => ({ files: [] })),
|
||
getJSON('/api/boot-rules').catch(() => ({ rules: [], webhook_url: '' })),
|
||
]);
|
||
const bootEvents = bootLogRes.events || [];
|
||
const unattendedFiles = unattRes.files || [];
|
||
const targets = isos.flatMap(i => i.boot_entries.map(e => ({
|
||
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family),
|
||
})));
|
||
|
||
// Reserved menu shortcuts that the operator might want to bind.
|
||
const reserved = [
|
||
{id: '_local', title: '↳ Boot from Local HDD (built-in)'},
|
||
{id: '_queue', title: '↳ Queued Deployment (built-in)'},
|
||
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
|
||
];
|
||
|
||
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff or aa:bb:cc', spellcheck:'false'});
|
||
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
|
||
// v0.7.2: the former separate "Boot rules" card folded into this
|
||
// form. A full MAC with no architecture saves a per-host pin
|
||
// exactly as before; a MAC *prefix* and/or an architecture saves a
|
||
// first-match-wins group rule instead. Same form, one mental model.
|
||
const archSel = el('select', {}, [
|
||
['', 'any (this exact MAC)'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'],
|
||
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'],
|
||
].map(([v, t]) => el('option', {value: v}, t)));
|
||
// v0.7.1's boot-binary pin keeps its home here too (auto = let the
|
||
// escalation ladder learn; shim = known Secure Boot fleet).
|
||
const binSel = el('select', {}, [
|
||
['', 'auto (learn per machine)'], ['firmware', 'Firmware NIC'],
|
||
['builtin', 'iPXE drivers'], ['shim', 'Secure Boot (shim)'],
|
||
].map(([v, t]) => el('option', {value: v}, t)));
|
||
const targetSel = el('select', {},
|
||
[el('option', {value:''}, '— choose a target —')]
|
||
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
|
||
.concat(targets.map(t => el('option', {value: t.id}, t.title))));
|
||
const msg = el('div', {class:'msg'});
|
||
// v0.5.2: optional unattended-install profile — auto hostname, auto
|
||
// IP, and an answer-file picker. On boot, a bound MAC with an
|
||
// unattended file selected has the right kernel arg injected
|
||
// (inst.ks / preseed url / autoinstall ds=nocloud) and the
|
||
// hostname/IP templated into the served answer file.
|
||
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
|
||
|
||
const FULL_MAC = /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i;
|
||
const upsertBtn = el('button', {onclick: async () => {
|
||
const mac = macInput.value.trim();
|
||
const isGroup = !!archSel.value || !!binSel.value || (mac !== '' && !FULL_MAC.test(mac));
|
||
if (!isGroup) {
|
||
// Exact-MAC pin — unchanged behavior.
|
||
if (!mac || !targetSel.value) {
|
||
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
|
||
}
|
||
const r = await postJSON('/api/hosts', Object.assign({
|
||
mac, target: targetSel.value, label: labelInput.value,
|
||
}, profileFields.read()));
|
||
if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; render('hosts'); }
|
||
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
|
||
return;
|
||
}
|
||
// Group rule (prefix and/or architecture). Per-host profile
|
||
// fields don't apply to a group — they're per-machine values.
|
||
if (!targetSel.value && !binSel.value) {
|
||
msg.textContent = 'A group rule needs a target or a boot binary.'; msg.className = 'msg err'; return;
|
||
}
|
||
const p = profileFields.read();
|
||
if (p.auto_hostname || p.auto_ip || p.unattended_file) {
|
||
msg.textContent = 'Auto-deploy fields are per-machine — clear them, or use a full MAC.'; msg.className = 'msg err'; return;
|
||
}
|
||
const cfg = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
|
||
(cfg.rules = cfg.rules || []).push({
|
||
mac_prefix: mac, arch: archSel.value, target: targetSel.value,
|
||
driver_mode: binSel.value, enabled: true, note: labelInput.value,
|
||
});
|
||
const r = await putJSON('/api/boot-rules', cfg);
|
||
if (r.ok) { msg.textContent = 'Group rule saved.'; msg.className = 'msg ok'; render('hosts'); }
|
||
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
|
||
}}, 'Bind to target');
|
||
|
||
const rows = hosts.map(h => {
|
||
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
|
||
// table is bound hosts). The button reports its own state inline
|
||
// so there's no shared toast to thread through.
|
||
const wakeBtn = el('button', {class:'ghost', onclick: async () => {
|
||
wakeBtn.disabled = true;
|
||
const original = wakeBtn.textContent;
|
||
wakeBtn.textContent = 'Waking…';
|
||
try {
|
||
const r = await postJSON('/api/hosts/' + encodeURIComponent(h.mac) + '/wol', {});
|
||
wakeBtn.textContent = r.ok ? 'Sent ✓' : 'Failed';
|
||
} catch (e) {
|
||
wakeBtn.textContent = 'Failed';
|
||
}
|
||
setTimeout(() => { wakeBtn.textContent = original; wakeBtn.disabled = false; }, 2500);
|
||
}}, 'Wake');
|
||
const autoDeploy = (h.auto_hostname || h.auto_ip || h.unattended_file)
|
||
? el('div', {style:'font-size:12px;line-height:1.5'}, [
|
||
h.unattended_file ? el('div', {}, [el('span', {class:'tag accent'}, 'unattended'), document.createTextNode(' ' + h.unattended_file)]) : null,
|
||
h.auto_hostname ? el('div', {class:'mono'}, 'host: ' + h.auto_hostname) : null,
|
||
h.auto_ip ? el('div', {class:'mono'}, 'ip: ' + h.auto_ip) : null,
|
||
])
|
||
: el('span', {class:'tag'}, '—');
|
||
return el('tr', {}, [
|
||
el('td', {class:'mono'}, h.mac),
|
||
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
|
||
el('td', {class:'mono'}, h.target),
|
||
el('td', {}, autoDeploy),
|
||
el('td', {}, fmtAgo(h.updated_at)),
|
||
el('td', {style:'text-align:right;white-space:nowrap'}, [
|
||
wakeBtn,
|
||
el('button', {class:'danger', style:'margin-left:8px', onclick: async () => {
|
||
if (!confirm('Remove binding for ' + h.mac + '?')) return;
|
||
await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
|
||
render('hosts');
|
||
}}, 'Remove'),
|
||
]),
|
||
]);
|
||
});
|
||
|
||
const table = hosts.length
|
||
? el('table', {}, [
|
||
el('thead', {}, el('tr', {}, [
|
||
el('th',{},'MAC'), el('th',{},'Label'),
|
||
el('th',{},'Target'), el('th',{},'Auto-deploy'),
|
||
el('th',{},'Updated'), el('th',{},''),
|
||
])),
|
||
el('tbody', {}, rows),
|
||
])
|
||
: el('div', {class:'empty'}, 'No host bindings yet. Pin a MAC to a boot target to skip the menu for that machine.');
|
||
|
||
return el('div', {class:'grid'}, [
|
||
el('div', {class:'card'}, [
|
||
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
|
||
el('div', {class:'body'}, [
|
||
// v0.7.3: MAC · Label · Architecture · Boot binary share one
|
||
// 4-up row so the controls line up across the page; the
|
||
// per-field guidance that used to sit under them moved into the
|
||
// note below to keep the inputs flush. Target spans full width
|
||
// on its own line beneath them.
|
||
el('div', {class:'form-row'}, [
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address or prefix'), macInput]),
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Architecture (optional)'), archSel]),
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Boot binary (optional)'), binSel]),
|
||
]),
|
||
el('label', {class:'field', style:'margin-top:14px'}, [
|
||
el('span', {class:'name'}, 'Target'),
|
||
targetSel,
|
||
]),
|
||
el('div', {style:'margin-top:16px'}, profileFields.wrap),
|
||
upsertBtn, msg,
|
||
el('p', {class:'msg', style:'margin-top:14px'},
|
||
'A full MAC pins one machine; a MAC prefix (OUI) or an architecture ' +
|
||
'saves a first-match group rule. Pin the boot binary to “shim” for ' +
|
||
'Secure Boot racks — zero failed boot cycles. When a matching client ' +
|
||
'requests boot.ipxe, OpenPXE short-circuits past the interactive menu ' +
|
||
'and chains directly; decision order is exact MAC pin → first matching ' +
|
||
'group rule → menu. If an unattended file is selected on a pin, the ' +
|
||
'matching kernel argument is injected and the hostname/IP are templated ' +
|
||
'into the answer file.'),
|
||
]),
|
||
]),
|
||
el('div', {class:'card'}, [
|
||
el('header', {}, [
|
||
el('h2', {}, 'Bound hosts'),
|
||
el('span', {class:'sub'}, hosts.length + ' binding' + (hosts.length === 1 ? '' : 's')),
|
||
]),
|
||
table,
|
||
]),
|
||
groupRulesCard(rulesCfg, reserved.concat(targets)),
|
||
el('div', {class:'card'}, [
|
||
el('header', {}, [
|
||
el('h2', {}, 'Host log'),
|
||
el('span', {class:'sub'},
|
||
bootEvents.length + ' event' + (bootEvents.length === 1 ? '' : 's')),
|
||
]),
|
||
bootEvents.length
|
||
? el('table', {}, [
|
||
el('thead', {}, el('tr', {}, [
|
||
el('th', {}, 'Time'),
|
||
el('th', {}, 'MAC'),
|
||
el('th', {}, 'IP'),
|
||
el('th', {}, 'Image'),
|
||
])),
|
||
el('tbody', {},
|
||
bootEvents.map(e => el('tr', {}, [
|
||
el('td', {}, fmtAgo(e.timestamp)),
|
||
el('td', {class:'mono'}, e.mac || el('span', {class:'tag'}, '(unknown)')),
|
||
el('td', {class:'mono'}, e.ip ? String(e.ip) : '—'),
|
||
el('td', {}, [
|
||
el('span', {style:'font-weight:600'}, e.target_title || e.target_id),
|
||
el('div', {class:'meta',
|
||
style:'color:var(--fg-dim);font-size:11.5px;margin-top:2px'},
|
||
e.target_id),
|
||
]),
|
||
]))),
|
||
])
|
||
: el('div', {class:'empty'},
|
||
'No boot events yet. When a PXE client chains a boot entry, ' +
|
||
'it lands here with the MAC, IP, and image it received.'),
|
||
]),
|
||
]);
|
||
},
|
||
|
||
terminal: async () => {
|
||
// Two-pane layout: live log on top (auto-scrolling), command line
|
||
// on bottom. Mirrors the Minecraft-server console feel from the
|
||
// brief — output and input share one continuous timeline.
|
||
const pane = el('div', {class:'pane'});
|
||
const input = el('input', {type:'text', placeholder:'type a command, or "help"', spellcheck:'false', autocapitalize:'off', autocomplete:'off'});
|
||
const auto = el('input', {type:'checkbox'}); auto.checked = true;
|
||
const clearBtn = el('button', {onclick: () => { pane.innerHTML = ''; }}, 'Clear pane');
|
||
const tailBtn = el('button', {onclick: () => { auto.checked = !auto.checked; }}, 'Auto-scroll');
|
||
|
||
const term = el('div', {class:'terminal'}, [
|
||
el('div', {class:'toolbar'}, [
|
||
el('span', {}, 'Live log + operator console'),
|
||
el('div', {class:'right'}, [
|
||
el('label', {class:'check', style:'border:0;padding:0;margin:0;background:transparent'},
|
||
[auto, el('span', {style:'color:var(--fg-dim);font-size:11px'}, 'Auto-scroll')]),
|
||
clearBtn,
|
||
]),
|
||
]),
|
||
pane,
|
||
el('div', {class:'input-row'}, [
|
||
el('span', {class:'prompt'}, '>'),
|
||
input,
|
||
]),
|
||
]);
|
||
|
||
function append(line, kind) {
|
||
const lvl = (line.level || 'info').toLowerCase();
|
||
const ts = (line.timestamp || new Date().toISOString()).replace(/\.\d+/, '').replace('T', ' ').replace('Z', '');
|
||
const span = el('span', {class: 'lvl-' + lvl}, [
|
||
el('span', {class:'ts'}, ts + ' '),
|
||
el('span', {class:'tg'}, '[' + (line.target || 'openpxe') + '] '),
|
||
line.message,
|
||
'\n',
|
||
]);
|
||
if (kind === 'echo') {
|
||
span.firstChild.nextSibling.textContent = '';
|
||
span.firstChild.textContent = '';
|
||
span.classList.add('echo');
|
||
}
|
||
pane.appendChild(span);
|
||
if (auto.checked) pane.scrollTop = pane.scrollHeight;
|
||
}
|
||
|
||
// Initial fetch — show recent buffer in case SSE is slow to open.
|
||
try {
|
||
const r = await getJSON('/api/log/recent');
|
||
for (const l of (r.lines || [])) append(l);
|
||
} catch (e) {
|
||
append({timestamp: new Date().toISOString(), level:'warn', target:'openpxe::ui',
|
||
message: 'failed to load recent logs: ' + e.message});
|
||
}
|
||
|
||
// Live SSE stream. EventSource auto-reconnects on disconnect.
|
||
const es = new EventSource('/api/log/stream');
|
||
es.onmessage = (ev) => {
|
||
try { append(JSON.parse(ev.data)); }
|
||
catch { append({timestamp: new Date().toISOString(), level:'debug', target:'openpxe::ui', message: ev.data}); }
|
||
};
|
||
es.addEventListener('lagged', (ev) => {
|
||
const j = JSON.parse(ev.data || '{}');
|
||
append({timestamp: new Date().toISOString(), level:'warn',
|
||
target:'openpxe::ui',
|
||
message: 'log stream lagged: ' + (j.skipped || '?') + ' lines skipped'});
|
||
});
|
||
es.onerror = () => {
|
||
// EventSource quietly retries; surface a hint without spamming.
|
||
// We only append once, on transition from connected → erroring.
|
||
if (!term._notedErr) {
|
||
term._notedErr = true;
|
||
append({timestamp: new Date().toISOString(), level:'warn', target:'openpxe::ui',
|
||
message: 'log stream connection lost — auto-reconnecting'});
|
||
setTimeout(() => { term._notedErr = false; }, 5000);
|
||
}
|
||
};
|
||
// Close the SSE when the view changes — avoids piling up streams.
|
||
term._cleanup = () => es.close();
|
||
|
||
// Command history (in-memory only, ↑/↓ to recall).
|
||
const history = [];
|
||
let hi = -1;
|
||
|
||
input.addEventListener('keydown', async (e) => {
|
||
if (e.key === 'Enter') {
|
||
const cmd = input.value;
|
||
if (!cmd.trim()) return;
|
||
input.value = '';
|
||
history.unshift(cmd); if (history.length > 100) history.pop();
|
||
hi = -1;
|
||
// The echo also comes back from the server in the live tail,
|
||
// so we don't append it locally — keeps the order consistent.
|
||
try {
|
||
const r = await postJSON('/api/terminal', {command: cmd});
|
||
const j = await r.json();
|
||
const out = j.output || '';
|
||
if (out === '\f') { pane.innerHTML = ''; return; }
|
||
// Output also gets pushed onto the LogBus by the server, but
|
||
// include it locally so even if the SSE stream dropped we
|
||
// see it. Tagged "echo" so it stands out from regular logs.
|
||
append({timestamp: new Date().toISOString(), level: j.ok ? 'info' : 'warn',
|
||
target: 'terminal-output', message: out});
|
||
} catch (err) {
|
||
append({timestamp: new Date().toISOString(), level:'error',
|
||
target:'openpxe::ui', message: 'command failed: ' + err.message});
|
||
}
|
||
} else if (e.key === 'ArrowUp') {
|
||
if (history.length === 0) return;
|
||
hi = Math.min(hi + 1, history.length - 1);
|
||
input.value = history[hi];
|
||
e.preventDefault();
|
||
} else if (e.key === 'ArrowDown') {
|
||
hi = Math.max(hi - 1, -1);
|
||
input.value = hi < 0 ? '' : history[hi];
|
||
e.preventDefault();
|
||
}
|
||
});
|
||
|
||
// Welcome banner.
|
||
append({timestamp: new Date().toISOString(), level:'info', target:'openpxe::terminal',
|
||
message: 'Connected. Type "help" for available commands.'});
|
||
|
||
// Focus the input on next tick (after view swap completes).
|
||
setTimeout(() => input.focus(), 50);
|
||
|
||
return term;
|
||
},
|
||
|
||
settings: async () => {
|
||
const [status, me, sso, notify, docs] = await Promise.all([
|
||
getJSON('/api/status'),
|
||
getJSON('/api/me').catch(() => ({})),
|
||
getJSON('/api/sso').catch(() => ({
|
||
enabled:false, idp_name:'', metadata:'', metadata_url:'',
|
||
})),
|
||
// v0.5.1: the former Advanced tab folds in here, so Settings
|
||
// fetches the notify config + API docs it needs too.
|
||
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
|
||
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
||
]);
|
||
|
||
// ── Account card (Forms admin credentials, v0.4.5).
|
||
// Sonarr/Radarr-style: the admin enters their current password
|
||
// before changing username or password. On success the server
|
||
// revokes every other session, so a forgotten browser tab can't
|
||
// keep operating with stale credentials.
|
||
const currentPw = el('input', {type:'password', autocomplete:'current-password'});
|
||
const newUser = el('input', {type:'text', autocomplete:'username',
|
||
placeholder: (me.user && me.user.username) || 'admin'});
|
||
const newPw = el('input', {type:'password', autocomplete:'new-password',
|
||
placeholder: 'leave blank to keep current'});
|
||
const newPwConfirm = el('input', {type:'password', autocomplete:'new-password',
|
||
placeholder: 'confirm new password'});
|
||
const accountMsg = el('div', {class:'msg', style:'margin-top:8px'});
|
||
// v0.4.63: explicit top margin so the action button sits clearly
|
||
// beneath the input row instead of butting against the password
|
||
// fields. Mirrors the `Save SSO settings` button below for visual
|
||
// parity between the two settings cards.
|
||
const accountSave = el('button', {onclick: async () => {
|
||
accountMsg.textContent = ''; accountMsg.className = 'msg';
|
||
if (!currentPw.value) {
|
||
accountMsg.textContent = 'Current password is required.';
|
||
accountMsg.className = 'msg err';
|
||
return;
|
||
}
|
||
if (newPw.value && newPw.value !== newPwConfirm.value) {
|
||
accountMsg.textContent = 'New password and confirmation do not match.';
|
||
accountMsg.className = 'msg err';
|
||
return;
|
||
}
|
||
if (!newUser.value && !newPw.value) {
|
||
accountMsg.textContent = 'Nothing to change. Fill in a new username or password.';
|
||
accountMsg.className = 'msg err';
|
||
return;
|
||
}
|
||
const body = { current_password: currentPw.value };
|
||
if (newUser.value) body.new_username = newUser.value;
|
||
if (newPw.value) body.new_password = newPw.value;
|
||
const r = await putJSON('/api/me/credentials', body);
|
||
// Clear the typed plaintext immediately — minimises DOM dwell time.
|
||
currentPw.value = ''; newPw.value = ''; newPwConfirm.value = '';
|
||
if (r.ok) {
|
||
accountMsg.textContent = 'Credentials updated. Other sessions were signed out.';
|
||
accountMsg.className = 'msg ok';
|
||
// Refresh the settings view to pick up the new "logged in as" display.
|
||
setTimeout(() => render('settings'), 600);
|
||
} else {
|
||
const j = await r.json().catch(() => ({}));
|
||
accountMsg.textContent = j.error || ('Update failed: HTTP ' + r.status);
|
||
accountMsg.className = 'msg err';
|
||
}
|
||
}}, 'Update credentials');
|
||
const accountCard = el('div', {class:'card'}, [
|
||
el('header', {}, [
|
||
el('h2', {}, 'Administrator account'),
|
||
el('span', {class:'sub'},
|
||
(me && me.user && me.user.username)
|
||
? ('signed in as ' + me.user.username)
|
||
: 'signed in'),
|
||
]),
|
||
el('div', {class:'body'}, [
|
||
el('p', {class:'msg', style:'margin-bottom:14px'},
|
||
'Rotate the administrator login. Your current password is required ' +
|
||
'to make any change; on success every other browser session is ' +
|
||
'signed out so a stale cookie can\'t keep operating.'),
|
||
el('div', {class:'form-row'}, [
|
||
el('label', {class:'field'}, [
|
||
el('span', {class:'name'}, 'Current password'),
|
||
currentPw,
|
||
]),
|
||
el('label', {class:'field'}, [
|
||
el('span', {class:'name'}, 'New username (optional)'),
|
||
newUser,
|
||
]),
|
||
el('label', {class:'field'}, [
|
||
el('span', {class:'name'}, 'New password (optional)'),
|
||
newPw,
|
||
]),
|
||
el('label', {class:'field'}, [
|
||
el('span', {class:'name'}, 'Confirm new password'),
|
||
newPwConfirm,
|
||
]),
|
||
]),
|
||
accountSave, accountMsg,
|
||
]),
|
||
]);
|
||
|
||
// ── SSO card (FleetDM-shaped, storage-only for v0.4.5).
|
||
// Operators paste either a metadata URL or the raw XML; tabs
|
||
// switch the visible field. Saving validates server-side. The
|
||
// actual SAML login flow ships in a later release — we surface
|
||
// a yellow "config saved, runtime pending" line when usable.
|
||
const ssoEnabled = el('input', {type:'checkbox'});
|
||
ssoEnabled.checked = !!sso.enabled;
|
||
const ssoName = el('input', {type:'text', placeholder:'e.g. Okta, Azure AD',
|
||
value: sso.idp_name || ''});
|
||
// v0.4.6: optional FleetDM-style IdP logo URL. The login screen
|
||
// will render this as the brand mark on the "Sign in with X"
|
||
// button once the runtime SSO flow ships; for v0.4.6 we just
|
||
// persist it.
|
||
const ssoLogo = el('input', {type:'text',
|
||
placeholder:'https://idp.example.com/logo.svg',
|
||
value: sso.idp_logo_url || ''});
|
||
const ssoUrl = el('input', {type:'text', placeholder:'https://idp.example.com/metadata',
|
||
value: sso.metadata_url || ''});
|
||
// The textarea inherits the same chrome via the global
|
||
// `label.field textarea` rule, plus the monospace family for
|
||
// pasting raw XML. Children come after the attrs object — the
|
||
// initial value is the only "child".
|
||
const ssoXml = el('textarea',
|
||
{rows:'6',
|
||
spellcheck:'false', autocapitalize:'off',
|
||
placeholder:'<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"…',
|
||
style:'font-family:var(--mono);font-size:12px;resize:vertical'},
|
||
sso.metadata || '');
|
||
// The mode picker is a styled <select> so it aligns with text
|
||
// inputs in the same `.form-row` — the global `label.field
|
||
// select` rule takes care of the chrome.
|
||
const ssoMode = el('select', {}, [
|
||
el('option', {value:'url'}, 'Metadata URL'),
|
||
el('option', {value:'xml'}, 'Metadata XML'),
|
||
]);
|
||
ssoMode.value = sso.metadata && !sso.metadata_url ? 'xml' : 'url';
|
||
// v0.4.63: the IdP metadata URL now sits inside the 4-col header
|
||
// grid as column 4, so the SSO row is column-for-column aligned with
|
||
// the Administrator account row above. When the operator switches
|
||
// to XML mode, column 4 collapses (display:none) and the multi-line
|
||
// XML textarea takes its own full-width row below — there's no way
|
||
// to fit a 6-row textarea into a single grid cell without making
|
||
// the rest of the row look stretched.
|
||
const urlWrap = el('label', {class:'field'}, [
|
||
el('span', {class:'name'}, 'IdP metadata URL'),
|
||
ssoUrl,
|
||
]);
|
||
const xmlWrap = el('label', {class:'field', style:'margin-top:14px'}, [
|
||
el('span', {class:'name'}, 'IdP metadata XML'),
|
||
ssoXml,
|
||
el('span', {class:'hint'},
|
||
'Paste the raw <EntityDescriptor>…</EntityDescriptor> document from your IdP.'),
|
||
]);
|
||
// Hint that used to live under the URL field; surfaced once below
|
||
// the whole row so it doesn't compete with the in-grid layout.
|
||
const urlHint = el('p', {class:'msg', style:'margin-top:10px;margin-bottom:0'},
|
||
'OpenPXE fetches this metadata URL at sign-in to verify the IdP’s signature. ' +
|
||
'Any IdP-authenticated user gets an operator session.');
|
||
const refreshSsoFields = () => {
|
||
if (ssoMode.value === 'url') {
|
||
urlWrap.style.display = ''; xmlWrap.style.display = 'none';
|
||
urlHint.style.display = '';
|
||
} else {
|
||
urlWrap.style.display = 'none'; xmlWrap.style.display = '';
|
||
urlHint.style.display = 'none';
|
||
}
|
||
};
|
||
ssoMode.onchange = refreshSsoFields;
|
||
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
|
||
const ssoSave = el('button', {onclick: async () => {
|
||
ssoMsg.textContent = ''; ssoMsg.className = 'msg';
|
||
const payload = {
|
||
enabled: ssoEnabled.checked,
|
||
idp_name: ssoName.value,
|
||
idp_logo_url: ssoLogo.value,
|
||
metadata: ssoMode.value === 'xml' ? ssoXml.value : '',
|
||
metadata_url: ssoMode.value === 'url' ? ssoUrl.value : '',
|
||
};
|
||
const r = await putJSON('/api/sso', payload);
|
||
if (r.ok) {
|
||
ssoMsg.textContent = ssoEnabled.checked
|
||
? 'SSO saved and live. The login page now shows a “Sign in with …” button.'
|
||
: 'SSO configuration saved (disabled).';
|
||
ssoMsg.className = 'msg ok';
|
||
} else {
|
||
const t = await r.text();
|
||
ssoMsg.textContent = 'Save failed: ' + t;
|
||
ssoMsg.className = 'msg err';
|
||
}
|
||
}}, 'Save SSO settings');
|
||
const ssoCard = el('div', {class:'card'}, [
|
||
el('header', {}, [
|
||
el('h2', {}, 'Single sign-on (SAML)'),
|
||
el('span', {class:'sub'},
|
||
sso.enabled
|
||
? (sso.metadata_url || sso.metadata
|
||
? 'live · active'
|
||
: 'enabled but missing source')
|
||
: 'disabled'),
|
||
]),
|
||
el('div', {class:'body'}, [
|
||
el('p', {class:'msg', style:'margin-bottom:14px'},
|
||
'SAML single sign-on is live. With it enabled, the login page shows ' +
|
||
'a “Sign in with …” button that hands off to your IdP; OpenPXE ' +
|
||
'verifies the signed assertion against the IdP metadata and mints an ' +
|
||
'operator session for any authenticated user. The local administrator ' +
|
||
'account above always remains available as a fallback.'),
|
||
el('label', {class:'check', style:'margin-bottom:14px;max-width:280px'}, [
|
||
ssoEnabled,
|
||
el('span', {}, 'Enable single sign-on'),
|
||
]),
|
||
// v0.4.63: 4-column form-row that matches the Administrator
|
||
// account card above column-for-column — display name / logo
|
||
// URL / metadata source / metadata URL. All four controls share
|
||
// the same `label.field` chrome so they line up cleanly. When
|
||
// the operator picks "Metadata XML" the URL column collapses
|
||
// and the multi-line textarea drops below the row.
|
||
el('div', {class:'form-row'}, [
|
||
el('label', {class:'field'}, [
|
||
el('span', {class:'name'}, 'IdP display name'),
|
||
ssoName,
|
||
]),
|
||
el('label', {class:'field'}, [
|
||
el('span', {class:'name'}, 'IdP logo URL'),
|
||
ssoLogo,
|
||
]),
|
||
el('label', {class:'field'}, [
|
||
el('span', {class:'name'}, 'Metadata source'),
|
||
ssoMode,
|
||
]),
|
||
urlWrap,
|
||
]),
|
||
xmlWrap,
|
||
urlHint,
|
||
ssoSave, ssoMsg,
|
||
]),
|
||
]);
|
||
// Wire up + paint the initial visibility now that all elements
|
||
// referenced by `refreshSsoFields` are attached.
|
||
refreshSsoFields();
|
||
|
||
// ── Custom logos (v0.5.2). Three independent slots on one row,
|
||
// FleetDM-style: Light + Dark feed the WebUI top-left and the form
|
||
// login page (whichever theme is active picks its variant); Client
|
||
// is the raster painted above the PXE boot menu. Each slot has a
|
||
// preview, an upload (PNG/SVG/JPEG/WebP/GIF up to 2 MB; the Client
|
||
// slot is raster-only), and a clear.
|
||
const logoMsg = el('div', {class:'msg', style:'margin-top:10px'});
|
||
const bust = '?v=' + Date.now(); // bust the preview cache after a change
|
||
const brandingPresence = status.branding || { light:false, dark:false, client:false };
|
||
// Each swatch previews on a background matching where the mark
|
||
// lands (light page / dark page / dark PXE screen), independent of
|
||
// the operator's current page theme — so the Dark slot always reads
|
||
// as dark even while viewing Settings in light mode.
|
||
const slotDefs = [
|
||
{ slot:'light', title:'Light mode', preview:'/assets/logo.svg?theme=light' + '&' + bust.slice(1),
|
||
swatchBg:'#f4f5f7', hint:'Shown on light-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
|
||
{ slot:'dark', title:'Dark mode', preview:'/assets/logo.svg?theme=dark' + '&' + bust.slice(1),
|
||
swatchBg:'#0e1014', hint:'Shown on dark-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
|
||
{ slot:'client', title:'Client', preview:'/branding/pxe-logo' + bust,
|
||
swatchBg:'#0e1014', hint:'Above the PXE boot menu.', accept:'image/png,image/jpeg,image/webp,image/gif' },
|
||
];
|
||
const slotCol = (def) => {
|
||
const set = !!brandingPresence[def.slot];
|
||
const input = el('input', {type:'file', accept:def.accept, style:'display:none'});
|
||
input.onchange = async () => {
|
||
if (!input.files[0]) return;
|
||
const f = input.files[0];
|
||
const fd = new FormData(); fd.append('file', f, f.name);
|
||
logoMsg.textContent = 'Uploading ' + def.title + ' logo (' + fmtBytes(f.size) + ')…';
|
||
logoMsg.className = 'msg';
|
||
const r = await fetch('/api/branding/logo/' + def.slot, {method:'POST', body: fd});
|
||
if (r.ok) {
|
||
logoMsg.textContent = def.title + ' logo installed. Reloading…';
|
||
logoMsg.className = 'msg ok';
|
||
setTimeout(() => location.reload(), 600);
|
||
} else {
|
||
logoMsg.textContent = 'Upload failed: ' + (await r.text());
|
||
logoMsg.className = 'msg err';
|
||
}
|
||
};
|
||
return el('div', {class:'logo-slot'}, [
|
||
el('div', {class:'logo-slot-head'}, [
|
||
el('span', {class:'name'}, def.title),
|
||
set ? el('span', {class:'tag ok'}, 'set') : el('span', {class:'tag'}, 'default'),
|
||
]),
|
||
el('div', {class:'swatch', style:'background:' + def.swatchBg},
|
||
el('img', {src: def.preview, alt: def.title + ' logo'})),
|
||
el('div', {class:'logo-slot-hint'}, def.hint),
|
||
el('div', {style:'display:flex;gap:6px;flex-wrap:wrap'}, [
|
||
el('button', {class:'ghost', onclick: () => input.click()}, set ? 'Replace' : 'Upload'),
|
||
set ? el('button', {class:'danger', onclick: async () => {
|
||
if (!confirm('Remove the ' + def.title + ' logo?')) return;
|
||
const r = await fetch('/api/branding/logo/' + def.slot, {method:'DELETE'});
|
||
if (r.ok || r.status === 204) {
|
||
logoMsg.textContent = def.title + ' logo cleared. Reloading…';
|
||
logoMsg.className = 'msg ok';
|
||
setTimeout(() => location.reload(), 500);
|
||
} else {
|
||
logoMsg.textContent = 'Clear failed: ' + (await r.text());
|
||
logoMsg.className = 'msg err';
|
||
}
|
||
}}, 'Remove') : null,
|
||
]),
|
||
input,
|
||
]);
|
||
};
|
||
const logoCard = el('div', {class:'card'}, [
|
||
el('header', {}, el('h2', {}, 'Branding')),
|
||
el('div', {class:'body'}, [
|
||
el('p', {class:'msg', style:'margin-bottom:14px'},
|
||
'Upload your own brand marks. Up to 2 MB each; PNG, SVG, JPEG, ' +
|
||
'WebP, or GIF (the Client logo must be a raster). The Light and Dark ' +
|
||
'marks appear in the top-left and on the sign-in page depending on ' +
|
||
'theme; the Client mark sits above the PXE boot menu. The favicon ' +
|
||
'and the version string in the bottom-left always stay OpenPXE.'),
|
||
el('div', {class:'logo-slots'}, slotDefs.map(slotCol)),
|
||
logoMsg,
|
||
]),
|
||
]);
|
||
|
||
// v0.5.1: the former "Advanced" sidebar tab now lives here, folded
|
||
// into a collapsible disclosure beneath the core settings cards —
|
||
// webhook/email notifications + the API reference. Keeps Settings
|
||
// clean by default while leaving the knobs one click away.
|
||
const [notifyCard, apiCard] = views._advancedCards(notify, docs);
|
||
const advanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
|
||
el('summary', {class:'advanced-summary'}, 'Advanced'),
|
||
el('div', {class:'grid', style:'margin-top:14px'}, [notifyCard, apiCard]),
|
||
]);
|
||
return el('div', {}, [
|
||
el('div', {class:'grid'}, [accountCard, ssoCard, logoCard]),
|
||
advanced,
|
||
]);
|
||
},
|
||
|
||
// v0.5.1: builds the two "Advanced" cards — webhook/email notifications
|
||
// and the API reference. There is no longer an Advanced sidebar tab;
|
||
// the Settings view folds these into a collapsible disclosure and
|
||
// passes in the pre-fetched `notify` + `docs` payloads.
|
||
_advancedCards: (notify, docs) => {
|
||
|
||
// ── Notification config ──
|
||
const nMsg = el('div', {class:'msg', style:'margin-top:12px'});
|
||
const nEnabled = el('input', {type:'checkbox'}); nEnabled.checked = !!notify.enabled;
|
||
const nKind = el('select', {}, [
|
||
el('option', {value:'slack'}, 'Slack'),
|
||
el('option', {value:'discord'}, 'Discord'),
|
||
el('option', {value:'teams'}, 'Microsoft Teams'),
|
||
el('option', {value:'smtp'}, 'Email (SMTP)'),
|
||
]);
|
||
nKind.value = notify.kind || 'slack';
|
||
const nWebhook = el('input', {type:'text', placeholder:'https://hooks.slack.com/services/…',
|
||
value: notify.webhook_url || ''});
|
||
// SMTP fields.
|
||
const sHost = el('input', {type:'text', placeholder:'smtp.example.com', value: notify.smtp_host || ''});
|
||
const sPort = el('input', {type:'number', value: String(notify.smtp_port || 587)});
|
||
const sUser = el('input', {type:'text', placeholder:'(optional)', value: notify.smtp_username || ''});
|
||
// GET returns the password as a redaction sentinel when one is
|
||
// set; show an empty field with an "(unchanged)" placeholder and
|
||
// let collectNotify() re-send the sentinel so the stored secret
|
||
// is preserved unless the operator types a new one.
|
||
const hasStoredPass = !!notify.smtp_password;
|
||
const sPass = el('input', {type:'password',
|
||
placeholder: hasStoredPass ? '•••••• (unchanged)' : '', value: ''});
|
||
const sFrom = el('input', {type:'text', placeholder:'[email protected]', value: notify.smtp_from || ''});
|
||
const sTo = el('input', {type:'text', placeholder:'[email protected]', value: notify.smtp_to || ''});
|
||
const sTls = el('input', {type:'checkbox'}); sTls.checked = !!notify.smtp_implicit_tls;
|
||
|
||
const webhookBlock = el('div', {class:'form-row'}, [
|
||
el('label', {class:'field', style:'grid-column:1 / -1'}, [
|
||
el('span', {class:'name'}, 'Incoming webhook URL'),
|
||
nWebhook,
|
||
el('span', {class:'hint'},
|
||
'Slack/Discord/Teams all use an "incoming webhook" URL you create in that app.'),
|
||
]),
|
||
]);
|
||
const smtpBlock = el('div', {}, [
|
||
el('div', {class:'form-row cols-2'}, [
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'SMTP host'), sHost]),
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Port'), sPort]),
|
||
]),
|
||
el('div', {class:'form-row cols-2', style:'margin-top:12px'}, [
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username (optional)'), sUser]),
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Password'), sPass]),
|
||
]),
|
||
el('div', {class:'form-row cols-2', style:'margin-top:12px'}, [
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'From'), sFrom]),
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'To'), sTo]),
|
||
]),
|
||
el('label', {class:'check', style:'margin-top:12px'}, [
|
||
sTls, el('span', {}, 'Implicit TLS (port 465). Leave off for STARTTLS (587).'),
|
||
]),
|
||
]);
|
||
// Toggle which provider block shows.
|
||
const syncKind = () => {
|
||
const smtp = nKind.value === 'smtp';
|
||
webhookBlock.style.display = smtp ? 'none' : '';
|
||
smtpBlock.style.display = smtp ? '' : 'none';
|
||
};
|
||
nKind.addEventListener('change', syncKind);
|
||
syncKind();
|
||
|
||
const collectNotify = () => ({
|
||
enabled: nEnabled.checked,
|
||
kind: nKind.value,
|
||
webhook_url: nWebhook.value,
|
||
smtp_host: sHost.value,
|
||
smtp_port: Number(sPort.value) || 587,
|
||
smtp_username: sUser.value,
|
||
// Empty field + a stored password → send the sentinel so the
|
||
// server keeps it. Otherwise send whatever was typed (a new
|
||
// password, or empty to clear when none was stored).
|
||
smtp_password: (sPass.value === '' && hasStoredPass) ? '__keep__' : sPass.value,
|
||
smtp_from: sFrom.value,
|
||
smtp_to: sTo.value,
|
||
smtp_implicit_tls: sTls.checked,
|
||
});
|
||
|
||
const saveBtn = el('button', {onclick: async () => {
|
||
nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
|
||
const r = await putJSON('/api/notify', collectNotify());
|
||
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); }
|
||
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
|
||
}}, 'Save notification settings');
|
||
const testBtn = el('button', {class:'ghost', style:'margin-left:8px',
|
||
onclick: async () => {
|
||
nMsg.textContent = 'Sending test…'; nMsg.className = 'msg';
|
||
// Save first so the test uses exactly what's on screen.
|
||
const rs = await putJSON('/api/notify', collectNotify());
|
||
if (!rs.ok) { nMsg.textContent = 'Save failed: ' + (await rs.text()); nMsg.className = 'msg err'; return; }
|
||
const r = await postJSON('/api/notify/test', {});
|
||
if (r.ok) { nMsg.textContent = 'Test notification sent — check your channel/inbox.'; nMsg.className = 'msg ok'; }
|
||
else { nMsg.textContent = 'Test failed: ' + (await r.text()); nMsg.className = 'msg err'; }
|
||
}}, 'Send test');
|
||
|
||
const notifyCard = el('div', {class:'card'}, [
|
||
el('header', {}, [
|
||
el('h2', {}, 'Webhook notifications'),
|
||
el('span', {class:'sub'}, notify.enabled ? 'enabled' : 'disabled'),
|
||
]),
|
||
el('div', {class:'body'}, [
|
||
el('p', {class:'msg', style:'margin-bottom:14px'},
|
||
'Get pinged when a machine PXE-boots an image, a deployment is assigned, ' +
|
||
'or a host is woken. One provider at a time — pick yours, paste the URL ' +
|
||
'(or SMTP details), and Send test.'),
|
||
el('div', {class:'form-row cols-2'}, [
|
||
el('label', {class:'check'}, [nEnabled, el('span', {}, 'Enable notifications')]),
|
||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Provider'), nKind]),
|
||
]),
|
||
el('div', {style:'margin-top:14px'}, [webhookBlock, smtpBlock]),
|
||
saveBtn, testBtn, nMsg,
|
||
]),
|
||
]);
|
||
|
||
// ── API reference (relocated from Settings) ──
|
||
const groups = docs.groups || [];
|
||
const apiCard = el('div', {class:'card'}, [
|
||
el('header', {}, [
|
||
el('h2', {}, 'API reference'),
|
||
el('span', {class:'sub'},
|
||
groups.reduce((n, g) => n + (g.endpoints || []).length, 0) + ' endpoints'),
|
||
]),
|
||
el('div', {class:'api-ref'},
|
||
groups.length
|
||
? groups.map(g => el('div', {class:'group'}, [
|
||
el('h3', {}, g.name),
|
||
...(g.endpoints || []).map(ep => el('div', {class:'ep'}, [
|
||
el('span', {class:'method ' + (ep.method || 'get').toLowerCase()},
|
||
ep.method || 'GET'),
|
||
el('span', {class:'path'}, ep.path || '?'),
|
||
el('span', {class:'desc'}, ep.summary || ''),
|
||
])),
|
||
]))
|
||
: el('div', {class:'empty'},
|
||
'No API documentation returned by /api/docs.')),
|
||
]);
|
||
|
||
return [notifyCard, apiCard];
|
||
},
|
||
|
||
about: async () => {
|
||
const status = await getJSON('/api/status');
|
||
|
||
// ── Check for updates ──
|
||
const updMsg = el('div', {class:'msg', style:'margin-top:10px'});
|
||
const updBtn = el('button', {onclick: async () => {
|
||
updMsg.textContent = 'Checking the OpenPXE release feed…'; updMsg.className = 'msg';
|
||
try {
|
||
const r = await getJSON('/api/updates/check');
|
||
if (r.error) {
|
||
updMsg.replaceChildren(document.createTextNode('Couldn’t check: ' + r.error));
|
||
updMsg.className = 'msg err';
|
||
} else if (r.update_available) {
|
||
const parts = [document.createTextNode('Update available: '),
|
||
el('strong', {}, r.latest), document.createTextNode(' (you’re 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(
|
||
'You’re up to date — running the latest release (' + r.current + ').'));
|
||
updMsg.className = 'msg ok';
|
||
}
|
||
} catch (e) {
|
||
updMsg.textContent = 'Couldn’t 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'},
|
||
'The network-boot platform for modern infrastructure. Drop in an ISO ' +
|
||
'and every machine on your network — BIOS, UEFI, Secure Boot — can ' +
|
||
'boot it, image from it, and install unattended. One container, one ' +
|
||
'static binary, nothing installed on clients, nothing leaving your network.'),
|
||
el('div', {style:'display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:18px 0'}, [
|
||
['Boot anything', 'Linux, Windows, hypervisors, rescue tools — uploaded ' +
|
||
'ISOs become menu entries automatically, served on demand from local ' +
|
||
'disk or your existing NFS, SMB, or SFTP libraries.'],
|
||
['Adapt to every machine', 'Per-machine boot intelligence: firmware quirks, ' +
|
||
'NIC driver fallback, and a Microsoft-signed Secure Boot chain are ' +
|
||
'negotiated automatically and remembered — no toggles, no client prep.'],
|
||
['Run it in production', 'SAML single sign-on, token-scoped answer files, ' +
|
||
'fleet routing rules, Wake-on-LAN, queued mass deployment, Prometheus ' +
|
||
'metrics. Built in Rust for boot infrastructure that cannot flinch.'],
|
||
].map(([h, body]) => el('div', {}, [
|
||
el('h3', {style:'margin:0 0 6px;font-size:13.5px'}, h),
|
||
el('p', {class:'msg', style:'font-size:12px;margin:0'}, body),
|
||
]))),
|
||
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'},
|
||
'Private by design: no telemetry, no CDN calls, no runtime ' +
|
||
'dependencies on the outside world. Air-gapped labs, customer sites ' +
|
||
'without internet, and locked-down OpenShift clusters run the same ' +
|
||
'image, the same way, indefinitely.'),
|
||
el('p', {class:'msg'},
|
||
'Principled by default: OpenPXE never asks an operator to install ' +
|
||
'test-signed drivers, modify a client’s trust store, or weaken ' +
|
||
'Secure Boot. Everything the firmware executes is generated from the ' +
|
||
'settings on these tabs — there are no hand-written boot scripts to ' +
|
||
'maintain and no internals to learn.'),
|
||
]),
|
||
]);
|
||
|
||
// ── Licenses ──
|
||
const licenseCard = el('div', {class:'card'}, [
|
||
el('header', {}, el('h2', {}, 'License')),
|
||
el('div', {class:'body'}, [
|
||
el('p', {class:'msg', style:'margin-bottom:12px'}, [
|
||
'OpenPXE is dual-licensed under ',
|
||
el('strong', {}, 'MIT'), document.createTextNode(' OR '),
|
||
el('strong', {}, 'Apache License 2.0'),
|
||
document.createTextNode(
|
||
' — use it under whichever fits your organization (SPDX: MIT OR Apache-2.0).'),
|
||
]),
|
||
el('div', {class:'license-grid', style:'display:grid;grid-template-columns:1fr 1fr;gap:14px'}, [
|
||
el('div', {}, [
|
||
el('h3', {style:'margin:0 0 6px'}, 'MIT License'),
|
||
el('p', {class:'msg', style:'font-size:12px'},
|
||
'Permission is hereby granted, free of charge, to any person obtaining a copy ' +
|
||
'of this software and associated documentation files, to deal in the Software ' +
|
||
'without restriction… THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.'),
|
||
el('a', {href:'https://opensource.org/license/mit', target:'_blank', rel:'noopener noreferrer'},
|
||
'Full MIT text →'),
|
||
]),
|
||
el('div', {}, [
|
||
el('h3', {style:'margin:0 0 6px'}, 'Apache License 2.0'),
|
||
el('p', {class:'msg', style:'font-size:12px'},
|
||
'Licensed under the Apache License, Version 2.0. Includes an express grant of ' +
|
||
'patent rights from contributors. Distributed on an "AS IS" BASIS, WITHOUT ' +
|
||
'WARRANTIES OR CONDITIONS OF ANY KIND.'),
|
||
el('a', {href:'https://www.apache.org/licenses/LICENSE-2.0', target:'_blank', rel:'noopener noreferrer'},
|
||
'Full Apache 2.0 text →'),
|
||
]),
|
||
]),
|
||
el('p', {class:'msg', style:'margin-top:14px;font-size:12px;opacity:.8'},
|
||
'Bundled components keep their own licenses: iPXE (GPLv2 / UBDL), Samba smbclient, ' +
|
||
'wimtools, and the Rust crates in this build. See the source repository for the ' +
|
||
'complete NOTICE.'),
|
||
]),
|
||
]);
|
||
|
||
return el('div', {class:'grid'}, [heroCard, licenseCard]);
|
||
},
|
||
};
|
||
|
||
// ── shell ────────────────────────────────────────────────────────
|
||
const viewTitles = {
|
||
dashboard: 'Dashboard',
|
||
network: 'Network',
|
||
queue: 'Queue',
|
||
storage: 'Storage',
|
||
hosts: 'Hosts',
|
||
terminal: 'Terminal',
|
||
settings: 'Settings',
|
||
about: 'About',
|
||
};
|
||
|
||
// Theme toggle. The data-attribute is set on <html> by the inline
|
||
// script in index.html before paint; we just flip it here and persist.
|
||
function applyTheme(theme) {
|
||
document.documentElement.setAttribute('data-theme', theme);
|
||
try { localStorage.setItem('openpxe-theme', theme); } catch {}
|
||
applyBrandLogos(theme);
|
||
}
|
||
function currentTheme() {
|
||
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||
}
|
||
// v0.5.2: point the sidebar + login brand marks at the theme's logo
|
||
// slot so a light/dark toggle swaps the logo too (FleetDM-style).
|
||
function applyBrandLogos(theme) {
|
||
theme = theme || currentTheme();
|
||
const rev = (brandInfo && brandInfo.logo_rev) || 0;
|
||
const url = '/assets/logo.svg?theme=' + theme + '&r=' + rev;
|
||
document.querySelectorAll('.sidebar .brand img, .brand-row img').forEach(img => {
|
||
img.src = url;
|
||
});
|
||
}
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
applyBrandLogos();
|
||
const btn = $('#theme-toggle');
|
||
if (btn) {
|
||
btn.addEventListener('click', () => {
|
||
applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
|
||
});
|
||
}
|
||
});
|
||
// Keyboard shortcut: T toggles theme (skip when typing in an input).
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key !== 't' && e.key !== 'T') return;
|
||
if (/^(INPUT|TEXTAREA|SELECT)$/.test((e.target && e.target.tagName) || '')) return;
|
||
applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
|
||
});
|
||
|
||
let currentBody = null;
|
||
|
||
async function render(view) {
|
||
view = view || 'dashboard';
|
||
$$('.sidebar nav a').forEach(a => a.classList.toggle('active', a.dataset.view === view));
|
||
$('[data-bind=view_title]').textContent = viewTitles[view] || view;
|
||
const root = $('#view-root');
|
||
// Clean up any per-view resources (e.g. terminal SSE) before swap.
|
||
if (currentBody && typeof currentBody._cleanup === 'function') {
|
||
try { currentBody._cleanup(); } catch (e) { /* ignore */ }
|
||
}
|
||
root.innerHTML = '';
|
||
// Animated rainbow-mark loader replacing the old plain-text
|
||
// \"Loading…\". The SVG drives all animation via SMIL — no JS, no
|
||
// CSS keyframes.
|
||
root.appendChild(el('div', {class:'loader'}, [
|
||
el('div', {class:'mark'}),
|
||
el('div', {}, 'Loading…'),
|
||
]));
|
||
try {
|
||
const body = await views[view]();
|
||
root.innerHTML = '';
|
||
root.appendChild(body);
|
||
currentBody = body;
|
||
} catch (e) {
|
||
root.innerHTML = '';
|
||
root.appendChild(el('div', {class:'msg err'}, 'Error: ' + e.message));
|
||
}
|
||
}
|
||
|
||
// Set the sidebar footer "Service status:" line. The chip itself moved
|
||
// off the topbar in v0.4.x: operators wanted readiness, advertised
|
||
// URL, and the boot IP grouped together as the bottom-left summary.
|
||
function setReady(state) {
|
||
const dot = $('[data-bind=ready_dot]');
|
||
const lbl = $('[data-bind=ready_label]');
|
||
if (!dot || !lbl) return;
|
||
const map = {
|
||
ready: { cls: 'ok', text: 'Ready' },
|
||
notready: { cls: 'err', text: 'Not ready' },
|
||
unreachable: { cls: 'err', text: 'Unreachable' },
|
||
};
|
||
const m = map[state] || { cls: 'warn', text: 'Checking…' };
|
||
dot.className = 'dot ' + m.cls;
|
||
lbl.className = 'status-value ' + m.cls;
|
||
lbl.textContent = m.text;
|
||
}
|
||
|
||
async function refreshChips() {
|
||
try {
|
||
const s = await getJSON('/api/status');
|
||
const r = await fetch('/readyz');
|
||
$$('[data-bind=version]').forEach(n => n.textContent = s.version);
|
||
$$('[data-bind=iso_count],[data-bind=iso_count2]').forEach(n => n.textContent = String(s.iso_count));
|
||
$$('[data-bind=client_count],[data-bind=client_count2]').forEach(n => n.textContent = String(s.client_count));
|
||
$$('[data-bind=queue_count],[data-bind=queue_count2]').forEach(n => n.textContent = String(s.queue_count));
|
||
$$('[data-bind=host_count]').forEach(n => n.textContent = String(s.host_bindings || 0));
|
||
setReady(r.ok ? 'ready' : 'notready');
|
||
} catch {
|
||
setReady('unreachable');
|
||
}
|
||
}
|
||
|
||
document.addEventListener('click', (e) => {
|
||
const a = e.target.closest('.sidebar nav a[data-view]');
|
||
if (a) { e.preventDefault(); render(a.dataset.view); }
|
||
});
|
||
|
||
// ── Auth bootstrap (v0.4.5) ──────────────────────────────────────
|
||
// Before painting the dashboard, ask /api/me whether the operator
|
||
// needs to bootstrap an admin (`setup_required`), sign in
|
||
// (`!authenticated`), or just load the dashboard. The auth screen
|
||
// takes over the viewport completely — no half-rendered chrome
|
||
// bleeding through. Sonarr/Radarr-style.
|
||
let chipsInterval = null;
|
||
let authScreenEl = null;
|
||
let ssoConfig = null;
|
||
// v0.5.0: branding bootstrap for the pre-auth screens, populated from
|
||
// /api/me (public). Lets the login/setup cards render the same
|
||
// FleetDM-style full-width custom logo the dashboard sidebar uses.
|
||
let brandInfo = { has_custom_logo: false, logo_rev: 0 };
|
||
|
||
// Brand row for the auth screens. With a custom logo uploaded: the
|
||
// logo spans the card, no "OpenPXE" wordmark (the logo is the brand).
|
||
// Default: bundled mark + "OpenPXE".
|
||
function authBrandRow() {
|
||
const src = '/assets/logo.svg?theme=' + currentTheme() + '&r=' + (brandInfo.logo_rev || 0);
|
||
if (brandInfo.has_custom_logo) {
|
||
return el('div', {class:'brand-row has-custom-logo'}, [
|
||
el('img', {src, alt:'logo'}),
|
||
]);
|
||
}
|
||
return el('div', {class:'brand-row'}, [
|
||
el('img', {src, alt:''}),
|
||
el('div', {class:'name'}, 'OpenPXE'),
|
||
]);
|
||
}
|
||
|
||
function teardownAuthScreen() {
|
||
if (authScreenEl && authScreenEl.parentNode) {
|
||
authScreenEl.parentNode.removeChild(authScreenEl);
|
||
}
|
||
authScreenEl = null;
|
||
document.querySelector('.shell').style.display = '';
|
||
}
|
||
|
||
function buildLoginCard() {
|
||
const usernameInput = el('input', {type:'text', name:'username', autocomplete:'username', autofocus:'autofocus', spellcheck:'false'});
|
||
const passwordInput = el('input', {type:'password', name:'password', autocomplete:'current-password'});
|
||
const err = el('div', {class:'auth-err', style:'display:none'});
|
||
const submit = el('button', {class:'submit', type:'submit'}, 'Sign in');
|
||
|
||
// v0.5.1: surface a failed/blocked SSO round-trip. The ACS handler
|
||
// redirects back to "/?sso_error=..." on any failure; we show a
|
||
// generic, non-leaky message and scrub the query so a refresh is clean.
|
||
const ssoErr = new URLSearchParams(window.location.search).get('sso_error');
|
||
if (ssoErr) {
|
||
err.textContent = ssoErr === 'idp_initiated'
|
||
? 'IdP-initiated SSO is disabled. Use the “Sign in with …” button, or enable it under Settings → SSO.'
|
||
: (ssoErr === 'unavailable' || ssoErr === 'metadata')
|
||
? 'Single sign-on is unavailable right now. Sign in with the local admin, or check the SSO settings.'
|
||
: 'SSO sign-in failed. Please try again, or sign in with the local admin.';
|
||
err.style.display = '';
|
||
window.history.replaceState({}, '', window.location.pathname);
|
||
}
|
||
|
||
// v0.5.2: FleetDM-style separation. The local credential form is its
|
||
// own self-contained <form>; when SSO is enabled, a distinct
|
||
// "Sign in with …" button sits below a divider — the credential
|
||
// fields no longer double as the SSO trigger.
|
||
// `enabled` from /api/me already means "usable" (enabled AND a metadata
|
||
// source is configured), so the button only shows when SSO will work.
|
||
const ssoLive = !!(ssoConfig && ssoConfig.enabled);
|
||
const ssoBlock = ssoLive
|
||
? el('div', {class:'sso-block'}, [
|
||
el('div', {class:'auth-divider'}, el('span', {}, 'or')),
|
||
el('button', {type:'button', class:'sso-btn', onclick: () => {
|
||
// SP-initiated SAML login: hand off to the IdP. /api/sso/acs
|
||
// verifies the response, mints the operator session, and
|
||
// redirects back to the dashboard.
|
||
window.location.assign('/api/sso/login');
|
||
}}, [
|
||
ssoConfig.idp_logo_url
|
||
? el('img', {class:'sso-logo', src: ssoConfig.idp_logo_url, alt:'', onerror: function(){ this.style.display='none'; }})
|
||
: null,
|
||
el('span', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')),
|
||
]),
|
||
])
|
||
: null;
|
||
|
||
const form = el('form', {class:'auth-form local-login', onsubmit: async (e) => {
|
||
e.preventDefault();
|
||
err.style.display = 'none';
|
||
submit.disabled = true;
|
||
submit.textContent = 'Signing in…';
|
||
try {
|
||
const r = await fetch('/api/login', {
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({username: usernameInput.value, password: passwordInput.value}),
|
||
});
|
||
if (r.ok) {
|
||
passwordInput.value = '';
|
||
teardownAuthScreen();
|
||
await startDashboard();
|
||
return;
|
||
}
|
||
const j = await r.json().catch(() => ({}));
|
||
err.textContent = j.error || ('Sign-in failed: HTTP ' + r.status);
|
||
err.style.display = '';
|
||
} catch (ex) {
|
||
err.textContent = 'Network error: ' + (ex && ex.message ? ex.message : ex);
|
||
err.style.display = '';
|
||
} finally {
|
||
submit.disabled = false;
|
||
submit.textContent = 'Sign in';
|
||
}
|
||
}}, [
|
||
el('label', {class:'field'}, [
|
||
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Username'),
|
||
usernameInput,
|
||
]),
|
||
el('label', {class:'field'}, [
|
||
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Password'),
|
||
passwordInput,
|
||
]),
|
||
submit,
|
||
]);
|
||
return el('div', {class:'login-stack'}, [
|
||
authBrandRow(),
|
||
el('h2', {}, 'Sign in'),
|
||
el('p', {class:'lede'}, 'Enter your administrator credentials. Forgot them? SSH to the host and remove work_dir/auth.json — the next launch will re-prompt for setup.'),
|
||
form,
|
||
ssoBlock,
|
||
err,
|
||
el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')),
|
||
]);
|
||
}
|
||
|
||
function buildSetupCard() {
|
||
const usernameInput = el('input', {type:'text', name:'username', autocomplete:'username', autofocus:'autofocus', spellcheck:'false'});
|
||
const passwordInput = el('input', {type:'password', name:'password', autocomplete:'new-password'});
|
||
const confirmInput = el('input', {type:'password', name:'confirm', autocomplete:'new-password'});
|
||
const err = el('div', {class:'auth-err', style:'display:none'});
|
||
const submit = el('button', {class:'submit', type:'submit'}, 'Create administrator');
|
||
|
||
const form = el('form', {class:'auth-form', onsubmit: async (e) => {
|
||
e.preventDefault();
|
||
err.style.display = 'none';
|
||
if (passwordInput.value !== confirmInput.value) {
|
||
err.textContent = 'Passwords do not match.';
|
||
err.style.display = '';
|
||
return;
|
||
}
|
||
if (passwordInput.value.length < 8) {
|
||
err.textContent = 'Password must be at least 8 characters.';
|
||
err.style.display = '';
|
||
return;
|
||
}
|
||
submit.disabled = true;
|
||
submit.textContent = 'Creating…';
|
||
try {
|
||
const r = await fetch('/api/setup', {
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({username: usernameInput.value, password: passwordInput.value}),
|
||
});
|
||
if (r.ok) {
|
||
passwordInput.value = '';
|
||
confirmInput.value = '';
|
||
teardownAuthScreen();
|
||
await startDashboard();
|
||
return;
|
||
}
|
||
const j = await r.json().catch(() => ({}));
|
||
err.textContent = j.error || ('Setup failed: HTTP ' + r.status);
|
||
err.style.display = '';
|
||
} catch (ex) {
|
||
err.textContent = 'Network error: ' + (ex && ex.message ? ex.message : ex);
|
||
err.style.display = '';
|
||
} finally {
|
||
submit.disabled = false;
|
||
submit.textContent = 'Create administrator';
|
||
}
|
||
}}, [
|
||
authBrandRow(),
|
||
el('h2', {}, 'First-run setup'),
|
||
el('p', {class:'lede'}, 'Welcome. Create the administrator account that will own this OpenPXE deployment. Additional users come in through SSO later.'),
|
||
el('label', {class:'field'}, [
|
||
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Username'),
|
||
usernameInput,
|
||
]),
|
||
el('label', {class:'field'}, [
|
||
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Password (≥8 chars)'),
|
||
passwordInput,
|
||
]),
|
||
el('label', {class:'field'}, [
|
||
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Confirm password'),
|
||
confirmInput,
|
||
]),
|
||
submit,
|
||
err,
|
||
el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')),
|
||
]);
|
||
return form;
|
||
}
|
||
|
||
function showAuthScreen(mode) {
|
||
// Tear down any previous screen + the dashboard chrome.
|
||
if (authScreenEl && authScreenEl.parentNode) {
|
||
authScreenEl.parentNode.removeChild(authScreenEl);
|
||
}
|
||
const shell = document.querySelector('.shell');
|
||
if (shell) shell.style.display = 'none';
|
||
if (chipsInterval) { clearInterval(chipsInterval); chipsInterval = null; }
|
||
|
||
const card = (mode === 'setup' ? buildSetupCard() : buildLoginCard());
|
||
authScreenEl = el('div', {class:'auth-screen'},
|
||
el('div', {class:'auth-card'}, card));
|
||
document.body.appendChild(authScreenEl);
|
||
// Focus the first visible input (autofocus on dynamically created
|
||
// inputs doesn't fire in all browsers).
|
||
setTimeout(() => {
|
||
const inp = authScreenEl.querySelector('input[type="text"], input[type="password"]');
|
||
if (inp) inp.focus();
|
||
}, 30);
|
||
}
|
||
|
||
async function startDashboard() {
|
||
// v0.4.6: light up the top-right user-menu chip. The button is
|
||
// hidden in index.html until /api/me confirms a signed-in session,
|
||
// so we don't show the icon (then hide it) when the user lands
|
||
// on /login. Clicking the icon opens a small popover with
|
||
// Name / Edit account / Sign out.
|
||
try {
|
||
const me = await fetch('/api/me').then(r => r.ok ? r.json() : null);
|
||
const wrap = $('[data-bind=user_menu_wrap]');
|
||
const pop = $('[data-bind=user_menu_pop]');
|
||
const name = $('[data-bind=user_pop_name]');
|
||
const edit = $('[data-bind=user_pop_edit]');
|
||
const out = $('[data-bind=user_pop_logout]');
|
||
const btn = $('#user-menu-btn');
|
||
if (wrap && me && me.authenticated && me.user) {
|
||
wrap.style.display = '';
|
||
if (name) name.textContent = me.user.username;
|
||
if (btn) btn.title = 'Signed in as ' + me.user.username;
|
||
if (btn && !btn._wired) {
|
||
btn._wired = true;
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const open = !pop.hidden;
|
||
pop.hidden = open;
|
||
btn.setAttribute('aria-expanded', String(!open));
|
||
});
|
||
}
|
||
if (edit && !edit._wired) {
|
||
edit._wired = true;
|
||
edit.addEventListener('click', () => {
|
||
pop.hidden = true;
|
||
btn.setAttribute('aria-expanded', 'false');
|
||
render('settings');
|
||
});
|
||
}
|
||
if (out && !out._wired) {
|
||
out._wired = true;
|
||
out.addEventListener('click', async () => {
|
||
pop.hidden = true;
|
||
btn.setAttribute('aria-expanded', 'false');
|
||
await fetch('/api/logout', {method:'POST'}).catch(() => {});
|
||
wrap.style.display = 'none';
|
||
showAuthScreen('login');
|
||
});
|
||
}
|
||
// Click-outside-to-close, wired once. Stored on document so we
|
||
// don't re-attach every render.
|
||
if (!document._userPopWired) {
|
||
document._userPopWired = true;
|
||
document.addEventListener('click', (e) => {
|
||
if (pop.hidden) return;
|
||
if (e.target.closest('.user-menu')) return;
|
||
pop.hidden = true;
|
||
btn.setAttribute('aria-expanded', 'false');
|
||
});
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && !pop.hidden) {
|
||
pop.hidden = true;
|
||
btn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
} catch (e) { /* surfaces elsewhere */ }
|
||
render('dashboard');
|
||
await refreshChips();
|
||
if (!chipsInterval) chipsInterval = setInterval(refreshChips, 3000);
|
||
}
|
||
|
||
async function bootstrap() {
|
||
let me;
|
||
try {
|
||
me = await fetch('/api/me').then(r => r.json());
|
||
// Capture branding so the auth cards render the custom logo.
|
||
brandInfo = {
|
||
has_custom_logo: !!me.has_custom_logo,
|
||
logo_rev: me.logo_rev || 0,
|
||
};
|
||
} catch (e) {
|
||
// /api/me is unauthenticated in every state — if we can't reach
|
||
// it the server is genuinely down, not an auth problem.
|
||
document.body.appendChild(el('div', {class:'auth-screen'},
|
||
el('div', {class:'auth-card'}, [
|
||
authBrandRow(),
|
||
el('h2', {}, 'Connection error'),
|
||
el('p', {class:'lede'}, 'Could not reach the OpenPXE server. Refresh once it is back up.'),
|
||
])));
|
||
return;
|
||
}
|
||
// v0.5.9: the login card's "Sign in with …" button keys off the SSO
|
||
// descriptor that /api/me now carries (public, non-sensitive: enabled
|
||
// + idp_name + idp_logo_url). It's available signed in or out, so the
|
||
// button is static — it no longer relied on the auth-gated /api/sso,
|
||
// which 401s pre-auth and made the button vanish on fresh login loads.
|
||
ssoConfig = me.sso || null;
|
||
|
||
if (me.setup_required) {
|
||
showAuthScreen('setup');
|
||
return;
|
||
}
|
||
if (!me.authenticated) {
|
||
showAuthScreen('login');
|
||
return;
|
||
}
|
||
await startDashboard();
|
||
}
|
||
|
||
bootstrap();
|
||
})();
|