diff --git a/emmc-provisioning/dashboard/app.py b/emmc-provisioning/dashboard/app.py index 7e191c8..8842d9b 100644 --- a/emmc-provisioning/dashboard/app.py +++ b/emmc-provisioning/dashboard/app.py @@ -44,6 +44,7 @@ GOLDEN_IMAGE = Path(os.environ.get("CM4_GOLDEN_IMAGE", str(BASE_DIR / "golden.im NETWORK_DEVICES_FILE = Path(os.environ.get("CM4_NETWORK_DEVICES_FILE", str(BASE_DIR / "network_devices.json"))) BUILD_STATUS_FILE = Path(os.environ.get("CM4_BUILD_STATUS_FILE", str(BASE_DIR / "build_cloudinit_status.json"))) BUILD_REQUEST_FILE = Path(os.environ.get("CM4_BUILD_REQUEST_FILE", str(BASE_DIR / "build_cloudinit_request.json"))) +BUILD_CANCEL_FILE = BUILD_REQUEST_FILE.parent / "build_cloudinit_cancel" SHRINK_REQUEST_FILE = Path(os.environ.get("CM4_SHRINK_REQUEST_FILE", str(BASE_DIR / "shrink_request.json"))) SHRINK_STATUS_FILE = Path(os.environ.get("CM4_SHRINK_STATUS_FILE", str(BASE_DIR / "shrink_status.json"))) FIRST_BOOT_STATUS_FILE = Path(os.environ.get("CM4_FIRST_BOOT_STATUS_FILE", str(BASE_DIR / "first_boot_status.json"))) @@ -1616,6 +1617,18 @@ def api_build_cloudinit_status(): return jsonify(_build_status_read()) +@app.route("/api/build-cloudinit-cancel", methods=["POST"]) +@require_admin +def api_build_cloudinit_cancel(): + """Request cancellation of the current build (host script checks for cancel file).""" + try: + BUILD_CANCEL_FILE.parent.mkdir(parents=True, exist_ok=True) + BUILD_CANCEL_FILE.write_text("") + return jsonify({"ok": True, "message": "Cancel requested. Build will stop at next check."}) + except OSError as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + @app.route("/api/build-cloudinit", methods=["POST"]) @require_admin def api_build_cloudinit(): diff --git a/emmc-provisioning/dashboard/templates/cloudinit_build.html b/emmc-provisioning/dashboard/templates/cloudinit_build.html index 174fa79..a0d8175 100644 --- a/emmc-provisioning/dashboard/templates/cloudinit_build.html +++ b/emmc-provisioning/dashboard/templates/cloudinit_build.html @@ -76,7 +76,10 @@ + date suffix
-
+
+ + +
@@ -136,15 +139,27 @@ 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; @@ -211,6 +226,8 @@ } 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 || ''); diff --git a/emmc-provisioning/dashboard/templates/home.html b/emmc-provisioning/dashboard/templates/home.html index e220bed..b16c576 100644 --- a/emmc-provisioning/dashboard/templates/home.html +++ b/emmc-provisioning/dashboard/templates/home.html @@ -52,6 +52,11 @@ .progress-fill { height: 100%; background: var(--accent); transition: width 0.3s; } .progress-fill.indeterminate { width: 35%; animation: slide 1.2s ease-in-out infinite; } @keyframes slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } } + .firstboot-progress-wrap { margin-top: 0.6rem; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; } + .firstboot-progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent-dim), var(--accent)); border-radius: 3px; transition: width 0.4s ease-out; } + .firstboot-progress-fill.animated { background: linear-gradient(90deg, var(--accent-dim), var(--accent) 50%, var(--accent-dim)); background-size: 200% 100%; animation: firstboot-shimmer 1.5s ease-in-out infinite; } + @keyframes firstboot-shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } } + .firstboot-step-label { font-size: 0.8rem; color: var(--text-dim); margin-bottom: 0.35rem; } .device-item { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.6rem 0.75rem; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-sm); @@ -110,7 +115,11 @@