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:
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user