<message>Implement a new API endpoint for cancelling ongoing cloud-init builds, allowing users to request a build cancellation via the dashboard. Update the dashboard UI to include a cancel button that appears during the build process, enhancing user experience by providing control over long-running operations. Modify the build script to check for cancellation requests, ensuring that builds can be stopped gracefully. This feature improves usability and responsiveness in the cloud-init image building workflow.
253 lines
16 KiB
HTML
253 lines
16 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>
|
|
<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>
|
|
</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 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 (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.';
|
|
else 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;
|
|
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>
|