Add cloud-init image building functionality to eMMC provisioning dashboard: implement API for downloading the latest Raspberry Pi OS Lite, injecting cloud-init files, and updating UI for cloud-init image creation. Enhance backup options with compression support for raw .img files using PiShrink, and update documentation to reflect new features and usage instructions.

This commit is contained in:
nearxos
2026-02-18 22:51:43 +02:00
parent 3abc004465
commit 40b8e15e75
4 changed files with 359 additions and 5 deletions

View File

@@ -416,6 +416,28 @@
<p id="backupsEmpty" class="empty-msg" style="display:none;">No backups yet. Capture one from a device (Backup above), then set it as golden for future deploys.</p>
</section>
<!-- 3b. Build cloud-init image from official Raspberry Pi OS -->
<section class="section">
<h2 class="section-title">Build cloud-init image</h2>
<p class="backup-deploy-hint">Download the latest <strong>Raspberry Pi OS Lite (arm64)</strong> from the official repository and inject cloud-init NoCloud files so the image is ready for first-boot configuration.</p>
<p id="buildRaspiosUrl" class="backups-mono" style="font-size:0.8rem; margin-bottom:0.5rem;"></p>
<div style="margin-bottom:0.75rem;">
<button type="button" id="buildCloudInitBtn" class="btn btn-primary">Download latest Raspberry Pi OS Lite &amp; build cloud-init image</button>
</div>
<div id="buildCloudInitStatus" class="backups-mono" style="font-size:0.85rem; min-height:1.5em; margin-bottom:0.5rem;"></div>
<details style="margin-top:0.5rem;">
<summary>Customize cloud-init (optional)</summary>
<div class="inner" style="margin-top:0.5rem;">
<label>user-data (YAML)</label>
<textarea id="buildUserData" rows="8" style="width:100%; font-family:monospace; font-size:0.85rem; margin-bottom:0.5rem;" placeholder="Leave empty to use default (remote bootstrap example)"></textarea>
<label>meta-data (optional)</label>
<textarea id="buildMetaData" rows="3" style="width:100%; font-family:monospace; font-size:0.85rem; margin-bottom:0.5rem;"></textarea>
<label>network-config (optional)</label>
<textarea id="buildNetworkConfig" rows="5" style="width:100%; font-family:monospace; font-size:0.85rem;"></textarea>
</div>
</details>
</section>
<!-- 4. How to connect (collapsible) -->
<details class="section" style="padding:0;">
<summary>How to connect</summary>
@@ -568,6 +590,7 @@
const desc = (b.description || '');
const isRawImg = b.name.endsWith('.img') && !b.name.endsWith('.img.gz') && !b.name.endsWith('.img.xz');
const shrinkBtn = isRawImg ? '<button type="button" class="btn btn-outline btn-sm shrink-btn" data-name="' + escapeHtml(b.name) + '" title="Shrink image (PiShrink)">Shrink</button> ' : '';
const compressBtn = isRawImg ? '<button type="button" class="btn btn-outline btn-sm compress-btn" data-name="' + escapeHtml(b.name) + '" data-format="xz" title="Shrink and compress to .img.xz (minimum size)">Compress</button> ' : '';
tr.innerHTML =
'<td class="backup-name-edit" data-field="name" title="Click to rename">' + escapeHtml(displayName) + '</td>' +
'<td class="backup-desc-edit" data-field="description" title="Click to add description">' + escapeHtml(desc) + '</td>' +
@@ -575,6 +598,7 @@
'<td class="backups-mono">' + fmtDate(b.mtime) + '</td>' +
'<td class="actions-cell">' +
shrinkBtn +
compressBtn +
'<button type="button" class="btn btn-primary btn-sm set-golden-btn" data-name="' + escapeHtml(b.name) + '">Set as golden</button> ' +
'<button type="button" class="btn btn-outline btn-sm rename-file-btn" data-name="' + escapeHtml(b.name) + '" title="Rename file">Rename file</button> ' +
'<a href="/api/backups/' + encodeURIComponent(b.name) + '" download class="btn btn-outline btn-sm download-link">Download</a>' +
@@ -585,6 +609,7 @@
bindSetGolden();
bindRenameFile();
bindShrink();
bindCompress();
}
function bindRenameFile() {
@@ -594,7 +619,7 @@
const newName = prompt('New filename (e.g. production-v1.img)', name);
if (newName == null || newName.trim() === '') return;
const n = newName.trim();
if (!/\.(img|img\.gz)$/i.test(n)) { alert('Filename must end with .img or .img.gz'); return; }
if (!/\.(img|img\.gz|img\.xz)$/i.test(n)) { alert('Filename must end with .img, .img.gz or .img.xz'); return; }
if (n === name) return;
fetch('/api/backups/' + encodeURIComponent(name), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: n }) })
.then(function(r) { return r.json(); })
@@ -680,6 +705,26 @@
});
}
function bindCompress() {
document.querySelectorAll('.compress-btn').forEach(function(btn) {
btn.onclick = function() {
const name = btn.getAttribute('data-name');
const format = btn.getAttribute('data-format') || 'xz';
if (!confirm('Shrink and compress this image to .img.' + format + '? This minimizes size and may take several minutes.\n\n' + name)) return;
btn.disabled = true;
btn.textContent = 'Compressing…';
fetch('/api/backups/' + encodeURIComponent(name) + '/compress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ format: format }) })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) { fetchBackups(); }
else alert(data.error || data.detail || 'Failed');
})
.catch(function() { alert('Request failed'); })
.finally(function() { btn.disabled = false; btn.textContent = 'Compress'; });
};
});
}
function fetchGoldenInfo() {
fetch('/api/golden-info').then(function(r) { return r.json(); }).then(function(d) {
const el = document.getElementById('goldenHint');
@@ -700,6 +745,58 @@
fetch('/api/backups').then(function(r) { return r.json(); }).then(function(d) { renderBackups(d.backups || []); }).catch(function() {});
}
function fetchRaspiosUrl() {
fetch('/api/raspios-latest-url').then(function(r) { return r.json(); }).then(function(d) {
var el = document.getElementById('buildRaspiosUrl');
if (el) el.textContent = d.ok && d.filename ? ('Latest: ' + d.filename) : (d.error || 'Could not resolve latest image URL');
}).catch(function() {});
}
function fetchBuildStatus() {
fetch('/api/build-cloudinit-status').then(function(r) { return r.json(); }).then(function(d) {
var el = document.getElementById('buildCloudInitStatus');
var btn = document.getElementById('buildCloudInitBtn');
if (!el) return;
var busy = ['resolving','downloading','decompressing','injecting','finalizing'].indexOf(d.phase) >= 0;
if (btn) btn.disabled = busy;
if (d.phase === 'idle' && !d.message) {
el.textContent = '';
} else if (d.phase === 'done') {
el.textContent = 'Done: ' + (d.output_name || '') + ' — refresh the backups list below.';
fetchBackups();
} else if (d.phase === 'error') {
el.textContent = 'Error: ' + (d.error || d.message || 'Unknown');
} else {
el.textContent = (d.phase || '') + ': ' + (d.message || '');
}
if (busy) setTimeout(fetchBuildStatus, 5000);
}).catch(function() {});
}
function startBuildCloudInit() {
var btn = document.getElementById('buildCloudInitBtn');
if (btn) btn.disabled = true;
var body = {};
var ud = document.getElementById('buildUserData');
var md = document.getElementById('buildMetaData');
var nc = document.getElementById('buildNetworkConfig');
if (ud && ud.value.trim()) body.user_data = ud.value.trim();
if (md && md.value.trim()) body.meta_data = md.value.trim();
if (nc && nc.value.trim()) body.network_config = nc.value.trim();
fetch('/api/build-cloudinit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) {
document.getElementById('buildCloudInitStatus').textContent = 'Build started. Waiting…';
setTimeout(fetchBuildStatus, 2000);
} else {
alert(data.error || 'Failed to start build');
if (btn) btn.disabled = false;
}
})
.catch(function() { alert('Request failed'); if (btn) btn.disabled = false; });
}
function fmtSize(n) {
if (n >= 1e9) return (n / 1e9).toFixed(1) + ' GB';
if (n >= 1e6) return (n / 1e6).toFixed(1) + ' MB';
@@ -721,10 +818,15 @@
fetchPending();
fetchBackups();
fetchGoldenInfo();
fetchRaspiosUrl();
fetchBuildStatus();
var buildBtn = document.getElementById('buildCloudInitBtn');
if (buildBtn) buildBtn.onclick = startBuildCloudInit;
setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000);
setInterval(fetchBackups, 5000);
setInterval(fetchBuildStatus, 15000);
setInterval(fetchGoldenInfo, 10000);
</script>
</body>