Files
nearxos 16bfc1e0e1 Enhance cloud-init scripts and dashboard for improved USB boot functionality</message>
<message>Update the bootstrap script to ensure hostname resolution by adding entries to /etc/hosts, preventing "sudo: unable to resolve host" errors. Modify user-data.bootstrap to include the same hostname resolution logic. Revise dashboard templates to reflect the new project name "GNSS Guard Provisioning" and improve user interface elements related to USB boot operations, including clearer instructions and status messages. These changes enhance the overall user experience and streamline the provisioning process.
2026-02-24 08:50:32 +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 · GNSS Guard 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 !== 'idle' || (d.message && d.message.trim())) ? '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>