Files
reterminal-dm4/emmc-provisioning/dashboard/templates/cloudinit_build.html
nearxos 55b8661a2e Update documentation and scripts for revision tracking and cloud-init enhancements</message>
<message>Introduce a revision tracking system across project files, allowing for easier identification of changes. Update the README files to include instructions for bumping revisions and auto-bumping on commits. Additionally, enhance cloud-init scripts with revision comments for better version control. Modify the dashboard API to improve build status management, including a forced clear option for stuck statuses, enhancing user experience and operational reliability.
2026-02-23 10:38:24 +02:00

270 lines
17 KiB
HTML

<!DOCTYPE html>
<!-- Revision: 2 -->
<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 &amp; 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>Image name (optional):</label>
<input type="text" id="buildImageName" placeholder="e.g. reterminal-kiosk" style="width:12rem; margin-left:0.25rem;" />
<span class="mono" style="font-size:0.85rem; margin-left:0.25rem;">+ date suffix</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 &amp; build</button>
<button type="button" id="buildCloudInitCancelBtn" class="btn btn-outline" style="display:none; margin-left:0.5rem;">Cancel build</button>
</div>
<div id="buildCloudInitStatus" class="mono" style="min-height:1.2em;"></div>
<a href="#" id="buildCloudInitDismiss" style="display:none;">Dismiss</a>
</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 cancelBtn = document.getElementById('buildCloudInitCancelBtn');
var dismissEl = document.getElementById('buildCloudInitDismiss');
var busy = ['resolving','downloading','decompressing','injecting','finalizing'].indexOf(d.phase) >= 0;
if (btn) btn.disabled = busy;
if (cancelBtn) { cancelBtn.style.display = busy ? 'inline-block' : 'none'; cancelBtn.disabled = false; }
if (dismissEl) dismissEl.style.display = (d.phase === 'done' || d.phase === 'error' || d.phase === 'cancelled') ? 'inline' : 'none';
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 if (d.phase === 'cancelled') {
el.textContent = 'Build cancelled.';
if (btn) btn.disabled = false;
if (!window._buildClearScheduled) {
window._buildClearScheduled = true;
setTimeout(function() {
authFetch('/api/build-cloudinit-status-clear?force=1', { method: 'POST', headers: {'Content-Type':'application/json'} }).then(function() { fetchBuildStatus(); }).finally(function() { window._buildClearScheduled = false; });
}, 3000);
}
} else {
window._buildClearScheduled = false;
el.textContent = (d.phase || '') + ': ' + (d.message || '');
}
if (busy) setTimeout(fetchBuildStatus, 5000);
}).catch(function() {});
}
function cancelBuild() {
var cancelBtn = document.getElementById('buildCloudInitCancelBtn');
if (cancelBtn) cancelBtn.disabled = true;
document.getElementById('buildCloudInitStatus').textContent = 'Cancelling…';
authFetch('/api/build-cloudinit-cancel', { method: 'POST', headers: {'Content-Type':'application/json'} }).then(function(r) { return r.json(); }).then(function(d) {
if (d.ok) setTimeout(fetchBuildStatus, 2000);
else { alert(d.error || 'Cancel failed'); if (cancelBtn) cancelBtn.disabled = false; }
}).catch(function() { if (cancelBtn) cancelBtn.disabled = false; });
}
function startBuild() {
var btn = document.getElementById('buildCloudInitBtn');
if (btn) btn.disabled = true;
var nameEl = document.getElementById('buildImageName');
var body = { variant: document.getElementById('buildVariant').value, set_as_golden_after: document.getElementById('buildSetGolden').checked };
if (nameEl && nameEl.value.trim()) body.image_name = nameEl.value.trim();
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;
var cancelBuildBtn = document.getElementById('buildCloudInitCancelBtn');
if (cancelBuildBtn) cancelBuildBtn.onclick = cancelBuild;
var dismissBuildBtn = document.getElementById('buildCloudInitDismiss');
if (dismissBuildBtn) dismissBuildBtn.onclick = function(e) { e.preventDefault(); authFetch('/api/build-cloudinit-status-clear?force=1', { method: 'POST', headers: {'Content-Type':'application/json'} }).then(function() { fetchBuildStatus(); }); };
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>