Update the API to support streaming and decompressing of golden images in .img.xz and .img.gz formats on the fly. Modify the cloud-init build process to compress images to .img.xz for size reduction. Revise the dashboard templates to set 'desktop' as the default variant for Raspberry Pi OS builds, improving user experience and clarity in options. Update related scripts to ensure compatibility with the new image handling features.
229 lines
15 KiB
HTML
229 lines
15 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Cloud-init build · Admin · CM4 Provisioning</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg-primary: #0a0e14; --bg-secondary: #11151c; --bg-tertiary: #1a1f2b; --bg-card: #151a24;
|
|
--accent: #00d4aa; --accent-dim: #00b894; --text: #e6e8eb; --text-dim: #8b949e; --text-muted: #5c6370;
|
|
--border: #2d333b; --radius: 10px; --radius-sm: 6px;
|
|
}
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.5; }
|
|
.wrap { max-width: 1000px; margin: 0 auto; padding: 1rem; }
|
|
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
|
|
.header h1 { font-size: 1.25rem; font-weight: 700; }
|
|
.header span { color: var(--text-dim); font-size: 0.9rem; }
|
|
.nav a { color: var(--text-dim); text-decoration: none; margin-right: 1rem; }
|
|
.nav a:hover { color: var(--accent); }
|
|
.nav a.active { color: var(--accent); font-weight: 600; }
|
|
.header a { color: var(--text-dim); text-decoration: none; margin-left: 0.5rem; }
|
|
.header a:hover { color: var(--accent); }
|
|
.section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
|
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.75rem; }
|
|
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; font-weight: 500; font-family: inherit; border-radius: var(--radius-sm); cursor: pointer; border: none; transition: background 0.15s, color 0.15s; }
|
|
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
|
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
|
.btn-primary { background: var(--accent); color: var(--bg-primary); }
|
|
.btn-primary:hover { background: var(--accent-dim); }
|
|
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
|
|
.tabs { display: flex; gap: 0.25rem; margin-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
|
|
.tabs button { padding: 0.5rem 1rem; background: none; border: none; color: var(--text-dim); cursor: pointer; font: inherit; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
|
.tabs button:hover { color: var(--text); }
|
|
.tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
.tab-pane { display: none; }
|
|
.tab-pane.active { display: block; }
|
|
textarea { width: 100%; min-height: 220px; resize: vertical; font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.5rem; border-radius: 4px; }
|
|
.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8em; color: var(--text-muted); }
|
|
input[type="text"], select { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.35rem 0.5rem; border-radius: 4px; font: inherit; }
|
|
label { display: inline-block; margin-right: 0.5rem; }
|
|
ul { list-style: none; padding: 0; }
|
|
ul li { margin-bottom: 0.35rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<header class="header">
|
|
<div>
|
|
<h1>Cloud-init build</h1>
|
|
<nav class="nav" style="margin-top:0.35rem;">
|
|
<a href="/admin">Admin</a>
|
|
<a href="/admin/portal-files">Portal files</a>
|
|
<a href="/admin/cloudinit-build" class="active">Cloud-init build</a>
|
|
</nav>
|
|
</div>
|
|
<span>Logged in as <strong>{{ username }}</strong></span>
|
|
<a href="/">Deploy</a>
|
|
<a href="/logout">Log out</a>
|
|
</header>
|
|
|
|
<div class="section">
|
|
<h2 class="section-title">Download & build cloud-init image</h2>
|
|
<p style="font-size:0.9rem; color:var(--text-dim); margin-bottom:0.75rem;">Download latest Raspberry Pi OS (arm64), inject cloud-init. Output goes to <strong>Cloud-init images</strong> on the Admin page.</p>
|
|
<div style="margin-bottom:0.5rem;">
|
|
<label>Variant:</label>
|
|
<select id="buildVariant"><option value="desktop" selected>Desktop (recommended)</option><option value="lite">Lite</option><option value="full">Full</option></select>
|
|
<span id="buildRaspiosUrl" class="mono" style="margin-left:0.5rem;"></span>
|
|
</div>
|
|
<div style="margin-bottom:0.5rem;"><label><input type="checkbox" id="buildSetGolden" /> Set as golden after build</label></div>
|
|
<div style="margin-bottom:0.75rem;"><button type="button" id="buildCloudInitBtn" class="btn btn-primary">Download & build</button></div>
|
|
<div id="buildCloudInitStatus" class="mono" style="min-height:1.2em;"></div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2 class="section-title">Edit cloud-init files (templates)</h2>
|
|
<p style="font-size:0.85rem; color:var(--text-dim); margin-bottom:0.75rem;">Choose a file to edit below. These contents are injected into the image when you run the build.</p>
|
|
<div class="tabs">
|
|
<button type="button" class="tab-btn active" data-tab="user-data">user-data</button>
|
|
<button type="button" class="tab-btn" data-tab="meta-data">meta-data</button>
|
|
<button type="button" class="tab-btn" data-tab="network-config">network-config</button>
|
|
</div>
|
|
<div id="pane-user-data" class="tab-pane active">
|
|
<label>user-data (YAML, #cloud-config)</label>
|
|
<textarea id="buildUserData" rows="12" placeholder="#cloud-config..."></textarea>
|
|
</div>
|
|
<div id="pane-meta-data" class="tab-pane">
|
|
<label>meta-data (optional)</label>
|
|
<textarea id="buildMetaData" rows="8" placeholder="instance-id: ..."></textarea>
|
|
</div>
|
|
<div id="pane-network-config" class="tab-pane">
|
|
<label>network-config (optional)</label>
|
|
<textarea id="buildNetworkConfig" rows="8" placeholder="version: 2..."></textarea>
|
|
</div>
|
|
<div style="margin-top:0.75rem;">
|
|
<span>Templates:</span>
|
|
<select id="buildTemplateSelect"><option value="">— Load —</option></select>
|
|
<button type="button" id="buildTemplateLoad" class="btn btn-outline btn-sm">Load</button>
|
|
<button type="button" id="buildTemplateUpdate" class="btn btn-outline btn-sm" title="Save current content into the selected template">Update</button>
|
|
<button type="button" id="buildTemplateSave" class="btn btn-outline btn-sm">Save as template…</button>
|
|
</div>
|
|
<ul id="buildTemplateList" style="margin-top:0.5rem; font-size:0.85rem;"></ul>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function authFetch(url, opts) {
|
|
opts = opts || {};
|
|
return fetch(url, opts).then(function(r) {
|
|
if (r.status === 401) { window.location = '/login?next=' + encodeURIComponent(window.location.pathname); return Promise.reject(new Error('Login required')); }
|
|
return r;
|
|
});
|
|
}
|
|
function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
|
|
|
|
document.querySelectorAll('.tab-btn').forEach(function(btn) {
|
|
btn.onclick = function() {
|
|
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
|
|
document.querySelectorAll('.tab-pane').forEach(function(p) { p.classList.remove('active'); });
|
|
btn.classList.add('active');
|
|
var tab = btn.getAttribute('data-tab');
|
|
var pane = document.getElementById('pane-' + tab);
|
|
if (pane) pane.classList.add('active');
|
|
};
|
|
});
|
|
|
|
function fetchBuildStatus() {
|
|
authFetch('/api/build-cloudinit-status').then(function(r) { return r.json(); }).then(function(d) {
|
|
var el = document.getElementById('buildCloudInitStatus');
|
|
var btn = document.getElementById('buildCloudInitBtn');
|
|
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 || '') + ' — see Admin page Cloud-init images.'; }
|
|
else if (d.phase === 'error') el.textContent = 'Error: ' + (d.error || '');
|
|
else el.textContent = (d.phase || '') + ': ' + (d.message || '');
|
|
if (busy) setTimeout(fetchBuildStatus, 5000);
|
|
}).catch(function() {});
|
|
}
|
|
function startBuild() {
|
|
var btn = document.getElementById('buildCloudInitBtn');
|
|
if (btn) btn.disabled = true;
|
|
var body = { variant: document.getElementById('buildVariant').value, set_as_golden_after: document.getElementById('buildSetGolden').checked };
|
|
body.user_data = document.getElementById('buildUserData').value.trim();
|
|
body.meta_data = document.getElementById('buildMetaData').value.trim();
|
|
body.network_config = document.getElementById('buildNetworkConfig').value.trim();
|
|
authFetch('/api/build-cloudinit', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r) { return r.json(); }).then(function(d) {
|
|
if (d.ok) { document.getElementById('buildCloudInitStatus').textContent = 'Build started…'; setTimeout(fetchBuildStatus, 2000); }
|
|
else { alert(d.error); if (btn) btn.disabled = false; }
|
|
}).catch(function() { if (btn) btn.disabled = false; });
|
|
}
|
|
function fetchTemplates() {
|
|
authFetch('/api/cloudinit-templates').then(function(r) { return r.json(); }).then(function(d) {
|
|
var list = d.templates || [];
|
|
var sel = document.getElementById('buildTemplateSelect');
|
|
sel.innerHTML = '<option value="">— Load —</option>';
|
|
list.forEach(function(t) { var o = document.createElement('option'); o.value = t.id; o.textContent = t.name; sel.appendChild(o); });
|
|
var ul = document.getElementById('buildTemplateList');
|
|
ul.innerHTML = list.map(function(t) {
|
|
return '<li>' + escapeHtml(t.name) + ' <button type="button" class="btn btn-outline btn-sm load-tpl" data-id="' + t.id + '">Load</button> <button type="button" class="btn btn-outline btn-sm upd-tpl" data-id="' + t.id + '" title="Save current content into this template">Update</button> <button type="button" class="btn btn-outline btn-sm del-tpl" data-id="' + t.id + '">Delete</button></li>';
|
|
}).join('') || '<li>No templates</li>';
|
|
ul.querySelectorAll('.load-tpl').forEach(function(b) {
|
|
b.onclick = function() {
|
|
authFetch('/api/cloudinit-templates/' + b.getAttribute('data-id')).then(function(r) { return r.json(); }).then(function(t) {
|
|
document.getElementById('buildUserData').value = t.user_data || '';
|
|
document.getElementById('buildMetaData').value = t.meta_data || '';
|
|
document.getElementById('buildNetworkConfig').value = t.network_config || '';
|
|
});
|
|
};
|
|
});
|
|
ul.querySelectorAll('.upd-tpl').forEach(function(b) {
|
|
b.onclick = function() { updateTemplate(b.getAttribute('data-id')); };
|
|
});
|
|
ul.querySelectorAll('.del-tpl').forEach(function(b) {
|
|
b.onclick = function() {
|
|
if (!confirm('Delete template?')) return;
|
|
authFetch('/api/cloudinit-templates/' + b.getAttribute('data-id'), { method: 'DELETE' }).then(function(r) { return r.json(); }).then(function(d) { if (d.ok) fetchTemplates(); });
|
|
};
|
|
});
|
|
}).catch(function() {});
|
|
}
|
|
function updateTemplate(tid) {
|
|
var body = { user_data: document.getElementById('buildUserData').value, meta_data: document.getElementById('buildMetaData').value, network_config: document.getElementById('buildNetworkConfig').value };
|
|
authFetch('/api/cloudinit-templates/' + encodeURIComponent(tid), { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r) { return r.json(); }).then(function(d) {
|
|
if (d.ok) { fetchTemplates(); alert('Template updated'); } else alert(d.error || 'Update failed');
|
|
}).catch(function() { alert('Update failed'); });
|
|
}
|
|
function loadTemplateFromSelect() {
|
|
var s = document.getElementById('buildTemplateSelect');
|
|
if (s && s.value) authFetch('/api/cloudinit-templates/' + s.value).then(function(r) { return r.json(); }).then(function(t) {
|
|
document.getElementById('buildUserData').value = t.user_data || '';
|
|
document.getElementById('buildMetaData').value = t.meta_data || '';
|
|
document.getElementById('buildNetworkConfig').value = t.network_config || '';
|
|
});
|
|
}
|
|
function saveTemplate() {
|
|
var name = prompt('Template name');
|
|
if (!name || !name.trim()) return;
|
|
var body = { name: name.trim(), user_data: document.getElementById('buildUserData').value, meta_data: document.getElementById('buildMetaData').value, network_config: document.getElementById('buildNetworkConfig').value };
|
|
authFetch('/api/cloudinit-templates', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r) { return r.json(); }).then(function(d) { if (d.ok) { fetchTemplates(); alert('Saved'); } else alert(d.error); });
|
|
}
|
|
|
|
document.getElementById('buildCloudInitBtn').onclick = startBuild;
|
|
document.getElementById('buildVariant').onchange = function() {
|
|
authFetch('/api/raspios-latest-url?variant=' + encodeURIComponent(document.getElementById('buildVariant').value)).then(function(r) { return r.json(); }).then(function(d) {
|
|
document.getElementById('buildRaspiosUrl').textContent = (d.ok && d.filename) ? d.filename : (d.error || '');
|
|
});
|
|
};
|
|
document.getElementById('buildTemplateLoad').onclick = loadTemplateFromSelect;
|
|
document.getElementById('buildTemplateUpdate').onclick = function() {
|
|
var s = document.getElementById('buildTemplateSelect');
|
|
if (!s || !s.value) { alert('Select a template to update'); return; }
|
|
updateTemplate(s.value);
|
|
};
|
|
document.getElementById('buildTemplateSave').onclick = saveTemplate;
|
|
|
|
fetchBuildStatus();
|
|
fetchTemplates();
|
|
authFetch('/api/raspios-latest-url').then(function(r) { return r.json(); }).then(function(d) {
|
|
document.getElementById('buildRaspiosUrl').textContent = (d.ok && d.filename) ? d.filename : (d.error || '');
|
|
});
|
|
setInterval(fetchBuildStatus, 15000);
|
|
</script>
|
|
</body>
|
|
</html>
|