Enhance the dashboard UI by introducing an optional input field for the image name in the cloud-init build form. Update the API to process the image name, ensuring it is sanitized and included in the build request. Modify the build script to utilize the provided image name, allowing for customized output filenames during the image creation process. This improves user experience by offering more flexibility in naming cloud-init images.
236 lines
15 KiB
HTML
236 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>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 & 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 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;
|
|
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>
|