v0.8.1: add ISO by URL, zero-touch admin bootstrap
Ease-of-use pass inspired by Bootimus (Dnsmasq-PXE is a manual dnsmasq setup guide — nothing to adopt; OpenPXE already replaces that stack). Add ISO by URL: - New http-api `fetch` module: a small FetchJobs registry + a background streaming download (reqwest) that pipes a remote .iso through the same UploadHandle + introspection path as an upload, so a URL-fetched image classifies and gains boot entries identically. Progress is polled by the Storage view and rendered as rows, mirroring uploads. - Routes POST/GET/DELETE /api/isos/fetch. http/https only; .iso-only filename derived from Content-Disposition / URL basename with path traversal stripped; 16 GiB cap; cancel; credential-stripped URL display. Operator-gated, no boot-time outbound — offline boot is untouched. - Storage upload card gains an "Or add by URL" field with progress + cancel. Zero-touch admin bootstrap: - OPENPXE_ADMIN_USERNAME + OPENPXE_ADMIN_PASSWORD (or _PASSWORD_FILE for Docker/K8s secrets) auto-create the admin on first run, so a fresh container is usable with no setup wizard. Seeds the first run only — a lingering env var can't reset a rotated password. Tests: URL parse / filename / Content-Disposition unit tests + a wiremock end-to-end fetch-into-store integration test. clippy/fmt/node clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1c262a6d61
commit
5f98e6e03f
+75
-2
@@ -768,6 +768,75 @@
|
||||
}
|
||||
}
|
||||
|
||||
// v0.8.1: add ISO by URL. Paste a link and the server streams it
|
||||
// straight into the store and auto-detects it — no download-then-
|
||||
// reupload. Progress polls /api/isos/fetch and shows rows below,
|
||||
// mirroring uploads. On an air-gapped network, use the drop zone.
|
||||
const urlInput = el('input', {type:'url', id:'iso-url', style:'flex:1',
|
||||
placeholder:'https://example.com/systemrescue.iso'});
|
||||
const fetchBtn = el('button', {class:'ghost', type:'button', style:'margin-left:8px'}, 'Fetch');
|
||||
const fetchMsg = el('div', {class:'msg', style:'margin-top:6px'});
|
||||
const fetchList = el('div', {id:'fetches', style:'display:grid;gap:12px;margin-top:12px'});
|
||||
const urlRow = el('div', {style:'margin-top:14px'}, [
|
||||
el('label', {class:'field', style:'margin-bottom:0'}, [
|
||||
el('span', {class:'name'}, 'Or add by URL'),
|
||||
el('div', {style:'display:flex;align-items:center'}, [urlInput, fetchBtn]),
|
||||
el('span', {class:'hint'},
|
||||
'The server downloads the .iso into storage and auto-detects it — same result as a drag-drop. Any http(s) .iso link works.'),
|
||||
]),
|
||||
fetchMsg,
|
||||
]);
|
||||
|
||||
let fetchTimer = null;
|
||||
const renderFetchRows = (jobs) => {
|
||||
fetchList.replaceChildren(...jobs.map(j => {
|
||||
const pct = j.total > 0 ? Math.min(100, (j.downloaded / j.total) * 100)
|
||||
: (j.state === 'done' ? 100 : 0);
|
||||
let text, cls = '';
|
||||
if (j.state === 'downloading')
|
||||
text = 'Downloading ' + fmtBytes(j.downloaded) +
|
||||
(j.total ? ' of ' + fmtBytes(j.total) + ' (' + pct.toFixed(0) + '%)' : '');
|
||||
else if (j.state === 'done') { text = 'Downloaded and analyzed.'; cls = 'ok'; }
|
||||
else if (j.state === 'failed') { text = 'Failed: ' + (j.error || 'unknown error'); cls = 'err'; }
|
||||
else text = 'Canceled — partial discarded.';
|
||||
const btn = el('button', {class:'danger', type:'button', style:'margin-top:8px'},
|
||||
j.state === 'downloading' ? 'Cancel' : 'Dismiss');
|
||||
btn.onclick = async () => {
|
||||
try { await fetch('/api/isos/fetch/' + encodeURIComponent(j.id), {method:'DELETE'}); } catch (_) {}
|
||||
pollFetches();
|
||||
};
|
||||
return el('div', {}, [
|
||||
el('div', {style:'font-weight:600;font-size:13px;margin-bottom:6px;word-break:break-all'},
|
||||
j.filename + ' · ' + j.url),
|
||||
el('div', {class:'progress' + (j.state === 'downloading' ? ' active' : '')},
|
||||
el('div', {class:'bar', style:'width:' + pct.toFixed(1) + '%'})),
|
||||
el('div', {class:'msg ' + cls}, text),
|
||||
btn,
|
||||
]);
|
||||
}));
|
||||
};
|
||||
async function pollFetches() {
|
||||
if (fetchTimer) { clearTimeout(fetchTimer); fetchTimer = null; }
|
||||
let jobs = [];
|
||||
try { jobs = (await getJSON('/api/isos/fetch')).jobs || []; } catch (_) {}
|
||||
renderFetchRows(jobs);
|
||||
// A successful fetch is read-once on the server, so refreshing here
|
||||
// shows the new image and won't re-trigger on the next poll. Keep
|
||||
// polling only while a download is still in flight.
|
||||
if (jobs.some(j => j.state === 'done')) { render('storage'); return; }
|
||||
if (jobs.some(j => j.state === 'downloading')) fetchTimer = setTimeout(pollFetches, 1500);
|
||||
}
|
||||
async function startFetch() {
|
||||
const url = urlInput.value.trim();
|
||||
if (!url) return;
|
||||
fetchMsg.textContent = 'Starting…'; fetchMsg.className = 'msg';
|
||||
const r = await postJSON('/api/isos/fetch', { url });
|
||||
if (r.ok) { urlInput.value = ''; fetchMsg.textContent = ''; pollFetches(); }
|
||||
else { fetchMsg.textContent = 'Could not start: ' + (await r.text()).slice(0, 160); fetchMsg.className = 'msg err'; }
|
||||
}
|
||||
fetchBtn.onclick = startFetch;
|
||||
urlInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); startFetch(); } });
|
||||
|
||||
// ── 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
|
||||
@@ -1397,11 +1466,11 @@
|
||||
]),
|
||||
]);
|
||||
|
||||
return el('div', {}, [el('div', {class:'grid'}, [
|
||||
const root = el('div', {}, [el('div', {class:'grid'}, [
|
||||
diskCard,
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Upload ISO')),
|
||||
el('div', {class:'body'}, [drop, file, uploadsList]),
|
||||
el('div', {class:'body'}, [drop, file, urlRow, uploadsList, fetchList]),
|
||||
]),
|
||||
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a
|
||||
// protocol dropdown. Backend endpoints are unchanged; this is a
|
||||
@@ -1440,6 +1509,10 @@
|
||||
isoPager,
|
||||
]),
|
||||
]), unattendedAdvanced]);
|
||||
// v0.8.1: resume/kick URL-fetch progress polling; stop it on view swap.
|
||||
root._cleanup = () => { if (fetchTimer) clearTimeout(fetchTimer); };
|
||||
pollFetches();
|
||||
return root;
|
||||
},
|
||||
|
||||
hosts: async () => {
|
||||
|
||||
Reference in New Issue
Block a user