From ca27727137d7aa8b8bcc9b840e2180ab7f2cabf8 Mon Sep 17 00:00:00 2001 From: nearxos Date: Mon, 23 Feb 2026 11:08:52 +0200 Subject: [PATCH] Refactor dashboard to remove network boot support and update related UI elements Eliminate network boot options from the dashboard, including API endpoints and UI elements, to streamline the provisioning process for USB boot only. Update messages and documentation to reflect the removal of network boot functionality, ensuring clarity for users. Adjust the cloud-init build process and related templates to focus solely on USB boot mode, enhancing the overall user experience and simplifying the workflow. --- emmc-provisioning/dashboard/app.py | 165 +++++------------- .../dashboard/templates/cloudinit_build.html | 2 +- .../dashboard/templates/home.html | 55 +----- .../dashboard/templates/index.html | 28 +-- emmc-provisioning/host/README.md | 2 +- 5 files changed, 54 insertions(+), 198 deletions(-) diff --git a/emmc-provisioning/dashboard/app.py b/emmc-provisioning/dashboard/app.py index 0916aa5..474672b 100644 --- a/emmc-provisioning/dashboard/app.py +++ b/emmc-provisioning/dashboard/app.py @@ -52,7 +52,6 @@ FIRST_BOOT_STATUS_FILE = Path(os.environ.get("CM4_FIRST_BOOT_STATUS_FILE", str(B CLOUDINIT_TEMPLATES_FILE = Path(os.environ.get("CM4_CLOUDINIT_TEMPLATES_FILE", str(BASE_DIR / "cloudinit_templates.json"))) PORTAL_DESCRIPTIONS_FILE = Path(os.environ.get("CM4_PORTAL_DESCRIPTIONS_FILE", str(BASE_DIR / "portal_descriptions.json"))) DB_PATH = Path(os.environ.get("CM4_DASHBOARD_DB", str(BASE_DIR / "dashboard.db"))) -TOGGLE_NETWORK_BOOT_SCRIPT = os.environ.get("CM4_TOGGLE_NETWORK_BOOT_SCRIPT", "/opt/cm4-provisioning/toggle-network-boot-dhcp.sh") DHCP_LEASES_FILE = os.environ.get("CM4_DHCP_LEASES_FILE", "/var/lib/misc/dnsmasq.leases") # EEPROM update (USB boot): tools and output files (shared with host script) EEPROM_DIR = Path(os.environ.get("CM4_EEPROM_DIR", "/opt/cm4-provisioning/eeprom")) @@ -225,7 +224,7 @@ ethernets: DEFAULT_STATUS = { "phase": "idle", - "message": "Waiting for reTerminal in boot mode or network.", + "message": "Waiting for reTerminal in USB boot mode.", "progress": None, "updated": None, } @@ -650,171 +649,83 @@ def api_log(): @app.route("/api/eeprom-presets") def api_eeprom_presets(): - """Return available boot order presets for the Update EEPROM dropdown.""" + """Return available boot order presets for the Update EEPROM dropdown (eMMC only; network boot removed from portal).""" presets = [ {"id": "0x1", "label": "eMMC only"}, - {"id": "0xf21", "label": "eMMC first, then network"}, - {"id": "0xf12", "label": "Network first, then eMMC"}, ] return jsonify({"presets": presets}) @app.route("/api/pending-devices") def api_pending_devices(): - """Returns USB (if waiting_choice) and registered network devices so the UI can show Backup/Deploy.""" + """Returns USB device if waiting_choice. Network boot removed from portal; DHCP still provides internet.""" st = read_status() usb = None if st.get("phase") == "waiting_choice": usb = {"source": "usb", "message": st.get("message", "Device connected (USB). Choose action.")} - data = _load_network_devices() - network = [d for d in data.get("devices", []) if d.get("action") in (None, "wait")] - return jsonify({"usb": usb, "network": network}) + return jsonify({"usb": usb, "network": []}) @app.route("/api/device-action", methods=["POST"]) def api_device_action(): - """User chose Backup, Deploy, or Update EEPROM for a device. source=usb | network; for network pass mac=.""" + """User chose Backup, Deploy, or Update EEPROM for a USB device. Network boot removed from portal.""" body = request.get_json(force=True, silent=True) or {} source = (body.get("source") or "").strip().lower() action = (body.get("action") or "").strip().lower() - if action not in ("backup", "deploy", "reboot", "eeprom_update"): - return jsonify({"ok": False, "error": "action must be 'backup', 'deploy', 'reboot', or 'eeprom_update'"}), 400 - if action == "reboot" and source != "network": - return jsonify({"ok": False, "error": "'reboot' is only for network devices"}), 400 - if action == "eeprom_update" and source != "usb": - return jsonify({"ok": False, "error": "'eeprom_update' is only for USB-connected devices"}), 400 - if source == "usb": - if action == "eeprom_update": - boot_order = (body.get("boot_order") or "0xf21").strip().lower() - if boot_order not in ("0x1", "0xf21", "0xf12"): - return jsonify({"ok": False, "error": "boot_order must be 0x1, 0xf21, or 0xf12"}), 400 - ok, err = _generate_eeprom_update(boot_order) - if not ok: - return jsonify({"ok": False, "error": err or "Failed to generate EEPROM update"}), 500 - try: - os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True) - with open(ACTION_REQUEST_FILE, "w") as f: - f.write("eeprom_update") - _write_status("eeprom_update", "EEPROM update ready; host will write to device boot partition.") - return jsonify({"ok": True}) - except (PermissionError, OSError): - return jsonify({"ok": False, "error": "Could not write action file"}), 500 + if source != "usb": + return jsonify({"ok": False, "error": "Only USB boot is supported"}), 400 + if action not in ("backup", "deploy", "eeprom_update"): + return jsonify({"ok": False, "error": "action must be 'backup', 'deploy', or 'eeprom_update'"}), 400 + if action == "eeprom_update": + boot_order = (body.get("boot_order") or "0x1").strip().lower() + if boot_order != "0x1": + return jsonify({"ok": False, "error": "boot_order must be 0x1 (eMMC only)"}), 400 + ok, err = _generate_eeprom_update(boot_order) + if not ok: + return jsonify({"ok": False, "error": err or "Failed to generate EEPROM update"}), 500 try: os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True) - # If user requested "shrink after backup", create flag so host runs PiShrink after dd - if action == "backup" and body.get("shrink"): - try: - (BASE_DIR / "shrink_next_backup").write_text("1") - except (PermissionError, OSError): - pass # host may still have SHRINK_BACKUP=1 with open(ACTION_REQUEST_FILE, "w") as f: - f.write(action) - if action == "deploy": - _first_boot_status_write("idle", "", hostname="", ip="") + f.write("eeprom_update") + _write_status("eeprom_update", "EEPROM update ready; host will write to device boot partition.") return jsonify({"ok": True}) except (PermissionError, OSError): return jsonify({"ok": False, "error": "Could not write action file"}), 500 - if source == "network": - mac = (body.get("mac") or "").strip() - if not mac: - return jsonify({"ok": False, "error": "mac required for network device"}), 400 - data = _load_network_devices() - for d in data.get("devices", []): - if (d.get("mac") or "").lower() == mac.lower(): - d["action"] = action - d["action_at"] = time.time() - _save_network_devices(data) - ip = d.get("ip") or mac - if action == "deploy": - _write_status("flashing", f"Deploying to {ip} (network)...") - _first_boot_status_write("idle", "", hostname="", ip="") - elif action == "backup": - _write_status("backup", f"Backing up {ip} (network)...") - return jsonify({"ok": True}) - return jsonify({"ok": False, "error": "Device not found"}), 404 - return jsonify({"ok": False, "error": "source must be 'usb' or 'network'"}), 400 + try: + os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True) + if action == "backup" and body.get("shrink"): + try: + (BASE_DIR / "shrink_next_backup").write_text("1") + except (PermissionError, OSError): + pass + with open(ACTION_REQUEST_FILE, "w") as f: + f.write(action) + if action == "deploy": + _first_boot_status_write("idle", "", hostname="", ip="") + return jsonify({"ok": True}) + except (PermissionError, OSError): + return jsonify({"ok": False, "error": "Could not write action file"}), 500 @app.route("/api/register-device", methods=["POST"]) def api_register_device(): - """Called by a network-booted device to register (mac, ip).""" + """Legacy: network boot removed from portal; accept registration but do not list devices.""" body = request.get_json(force=True, silent=True) or request.form - mac = (body.get("mac") or "").strip() - ip = (body.get("ip") or request.remote_addr or "").strip() - if not mac: + if not (body.get("mac") or "").strip(): return jsonify({"ok": False, "error": "mac required"}), 400 - data = _load_network_devices() - devices = data.get("devices", []) - for d in devices: - if (d.get("mac") or "").lower() == mac.lower(): - d["ip"] = ip - d["registered_at"] = time.time() - d["action"] = d.get("action") or "wait" - _save_network_devices(data) - return jsonify({"ok": True, "message": "registered"}) - devices.append({"mac": mac, "ip": ip, "registered_at": time.time(), "action": "wait"}) - data["devices"] = devices - _save_network_devices(data) return jsonify({"ok": True, "message": "registered"}) -def _dhcp_network_boot_run(cmd): - """Run toggle script with enable|disable|status. Returns (ok, output_or_error).""" - if not os.path.isfile(TOGGLE_NETWORK_BOOT_SCRIPT) or not os.access(TOGGLE_NETWORK_BOOT_SCRIPT, os.X_OK): - return False, "Toggle script not installed" - try: - out = subprocess.run( - [TOGGLE_NETWORK_BOOT_SCRIPT, cmd], - capture_output=True, - text=True, - timeout=10, - ) - if out.returncode != 0: - return False, (out.stderr or out.stdout or "script failed").strip() - return True, (out.stdout or "").strip() - except subprocess.TimeoutExpired: - return False, "Timeout" - except Exception as e: - return False, str(e) - - -@app.route("/api/dhcp-network-boot", methods=["GET"]) -def api_dhcp_network_boot_get(): - """Return whether DHCP network-boot options (66/67) are enabled.""" - ok, out = _dhcp_network_boot_run("status") - if not ok: - return jsonify({"enabled": None, "error": out}), 200 - return jsonify({"enabled": out.strip().lower() == "enabled"}) - - -@app.route("/api/dhcp-network-boot", methods=["POST"]) -def api_dhcp_network_boot_post(): - """Enable or disable DHCP network-boot options (DHCP server keeps running). Body: { \"enabled\": true|false }.""" - body = request.get_json(force=True, silent=True) or {} - enabled = body.get("enabled") - if enabled is None: - return jsonify({"ok": False, "error": "enabled required (true|false)"}), 400 - cmd = "enable" if enabled else "disable" - ok, out = _dhcp_network_boot_run(cmd) - if not ok: - return jsonify({"ok": False, "error": out}), 500 - return jsonify({"ok": True, "enabled": enabled}) - - @app.route("/api/action-done", methods=["POST"]) def api_action_done(): - """Called by a device when deploy or backup has completed. Disables DHCP network-boot so the device boots from eMMC next time.""" + """Legacy: network boot removed from portal; acknowledge completion without toggling DHCP.""" mac = request.args.get("mac") or ((request.get_json(silent=True) or {}).get("mac") or "") - # Remove device from network-devices list so it doesn't keep showing if mac: data = _load_network_devices() data["devices"] = [d for d in data.get("devices", []) if (d.get("mac") or "").lower() != mac.lower()] _save_network_devices(data) - ok, _ = _dhcp_network_boot_run("disable") - _write_status("done", f"Done ({mac or 'network device'}). Network boot disabled; device will boot from eMMC on next boot.") - if not ok: - return jsonify({"ok": False, "error": "Could not disable DHCP network boot"}), 500 - return jsonify({"ok": True, "message": "Network boot disabled; device will boot from eMMC on next boot"}) + _write_status("done", f"Done ({mac or 'device'}).") + return jsonify({"ok": True, "message": "Done"}) def _read_dhcp_leases(): @@ -1668,6 +1579,8 @@ def api_build_cloudinit(): image_name = re.sub(r"[^\w\-]", "", image_name) # only alphanumeric, underscore, dash try: BUILD_REQUEST_FILE.parent.mkdir(parents=True, exist_ok=True) + # Remove any stale cancel file from a previous run so the new build is not cancelled immediately + BUILD_CANCEL_FILE.unlink(missing_ok=True) with open(BUILD_REQUEST_FILE, "w") as f: json.dump({ "url": url, diff --git a/emmc-provisioning/dashboard/templates/cloudinit_build.html b/emmc-provisioning/dashboard/templates/cloudinit_build.html index d707ff5..3884161 100644 --- a/emmc-provisioning/dashboard/templates/cloudinit_build.html +++ b/emmc-provisioning/dashboard/templates/cloudinit_build.html @@ -146,7 +146,7 @@ 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 (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 || ''); diff --git a/emmc-provisioning/dashboard/templates/home.html b/emmc-provisioning/dashboard/templates/home.html index 64dfd89..d774d4c 100644 --- a/emmc-provisioning/dashboard/templates/home.html +++ b/emmc-provisioning/dashboard/templates/home.html @@ -129,10 +129,9 @@

Capture or deploy

-

Network boot (DHCP):

- +
@@ -167,11 +166,6 @@
  • 2 Connect USB to host; choose Backup or Deploy above.
  • 3 Remove jumper and power cycle when done.
  • -

    Network

    -
      -
    1. 1 Enable network boot; device must reach this server.
    2. -
    3. 2 Boot with provisioning client; choose Backup or Deploy.
    4. -
    @@ -210,16 +204,9 @@ shrinkWrap.style.display = 'block'; const el = document.createElement('div'); el.className = 'device-item'; - el.innerHTML = '
    USB device — choose Backup, Deploy, or Update EEPROM
    '; + el.innerHTML = '
    USB device — choose Backup, Deploy, or Update EEPROM
    '; container.appendChild(el); } else shrinkWrap.style.display = 'none'; - (network || []).forEach(function(d) { - hasAny = true; - const el = document.createElement('div'); - el.className = 'device-item'; - el.innerHTML = '
    ' + escapeHtml(d.ip || '') + ' · ' + escapeHtml(d.mac || '') + '
    '; - container.appendChild(el); - }); noPending.style.display = hasAny ? 'none' : 'block'; container.querySelectorAll('button[data-action]').forEach(function(btn) { btn.onclick = function() { @@ -227,20 +214,12 @@ if (body.source === 'network') body.mac = btn.getAttribute('data-mac'); if (body.action === 'eeprom_update' && body.source === 'usb') { const presetEl = btn.closest('.device-item') && btn.closest('.device-item').querySelector('.eeprom-preset'); - body.boot_order = (presetEl && presetEl.value) ? presetEl.value : '0xf21'; + body.boot_order = (presetEl && presetEl.value) ? presetEl.value : '0x1'; } if (body.action === 'backup' && document.getElementById('shrinkAfterBackup').checked) body.shrink = true; fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .then(function(r) { return r.json(); }) - .then(function(data) { if (data.ok) { fetchPending(); fetchStatus(); fetchDhcpNetboot(); } else alert(data.error || 'Failed'); }) - .catch(function() { alert('Request failed'); }); - }; - }); - container.querySelectorAll('button.btn-disable-netboot').forEach(function(btn) { - btn.onclick = function() { - fetch('/api/dhcp-network-boot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: false }) }) - .then(function(r) { return r.json(); }) - .then(function(data) { if (data.ok) { fetchDhcpNetboot(); } else alert(data.error || 'Failed'); }) + .then(function(data) { if (data.ok) { fetchPending(); fetchStatus(); } else alert(data.error || 'Failed'); }) .catch(function() { alert('Request failed'); }); }; }); @@ -298,17 +277,6 @@ el.innerHTML = t; }).catch(function(){ document.getElementById('goldenInfo').textContent = 'Could not load.'; }); } - function fetchDhcpNetboot() { - fetch('/api/dhcp-network-boot').then(function(r){ return r.json(); }).then(function(d){ - const stateEl = document.getElementById('dhcpNetbootState'); - const disableBtn = document.getElementById('dhcpNetbootDisableBtn'); - const enableBtn = document.getElementById('dhcpNetbootEnableBtn'); - if (d.error) { stateEl.textContent = '—'; if (disableBtn) disableBtn.style.display = 'none'; if (enableBtn) enableBtn.style.display = 'none'; return; } - stateEl.textContent = d.enabled ? 'on' : 'off'; - if (disableBtn) disableBtn.style.display = d.enabled ? 'inline-block' : 'none'; - if (enableBtn) enableBtn.style.display = d.enabled ? 'none' : 'inline-block'; - }).catch(function(){ document.getElementById('dhcpNetbootState').textContent = '—'; }); - } var doneClearTimer = null; function scheduleDoneClear() { if (doneClearTimer) return; @@ -331,25 +299,12 @@ }).catch(function(){ document.getElementById('leasesError').textContent = 'Could not load leases.'; document.getElementById('leasesError').style.display = 'block'; }); } document.getElementById('statusClearBtn').addEventListener('click', function(){ fetch('/api/status-clear', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchStatus(); }); }); - document.getElementById('dhcpNetbootDisableBtn').addEventListener('click', function(){ - fetch('/api/dhcp-network-boot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: false }) }) - .then(function(r){ return r.json(); }) - .then(function(d){ if(d.ok) fetchDhcpNetboot(); else alert(d.error || 'Failed'); }) - .catch(function(){ alert('Request failed'); }); - }); - document.getElementById('dhcpNetbootEnableBtn').addEventListener('click', function(){ - fetch('/api/dhcp-network-boot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true }) }) - .then(function(r){ return r.json(); }) - .then(function(d){ if(d.ok) fetchDhcpNetboot(); else alert(d.error || 'Failed'); }) - .catch(function(){ alert('Request failed'); }); - }); - fetchStatus(); fetchLog(); fetchPending(); fetchGolden(); fetchDhcpNetboot(); fetchDhcpLeases(); fetchFirstBootStatus(); + fetchStatus(); fetchLog(); fetchPending(); fetchGolden(); fetchDhcpLeases(); fetchFirstBootStatus(); setInterval(fetchStatus, 2000); setInterval(fetchLog, 4000); setInterval(fetchPending, 2000); setInterval(fetchFirstBootStatus, 3000); setInterval(fetchGolden, 10000); - setInterval(fetchDhcpNetboot, 10000); setInterval(fetchDhcpLeases, 10000); diff --git a/emmc-provisioning/dashboard/templates/index.html b/emmc-provisioning/dashboard/templates/index.html index 0ec32bb..43149a9 100644 --- a/emmc-provisioning/dashboard/templates/index.html +++ b/emmc-provisioning/dashboard/templates/index.html @@ -360,7 +360,7 @@

    CM4 eMMC Provisioning

    -

    Deploy or backup reTerminal via USB boot mode or network

    +

    Deploy or backup reTerminal via USB boot mode

    @@ -373,7 +373,7 @@
    @@ -385,7 +385,7 @@

    Capture image or deploy

    -

    To capture (backup) an image from a device: connect it in USB boot mode or register over network. When the device appears below, click Backup to save its eMMC to a file. To write an image to a device, click Deploy (requires a golden image).

    +

    To capture (backup) an image from a device: connect it in USB boot mode. When the device appears below, click Backup to save its eMMC to a file. To write an image to a device, click Deploy (requires a golden image).

    @@ -396,9 +396,9 @@ - — USB boot mode or network registration + — USB boot mode - +
    @@ -481,11 +481,6 @@
  • 2 Connect USB slave to the host and power on. The device appears above; choose Backup or Deploy.
  • 3 When done, remove the jumper and power cycle to boot from eMMC.
  • -

    Network boot

    -
      -
    1. 1 Enable network boot (e.g. BOOT_ORDER=0xf21) and ensure the device can reach this server.
    2. -
    3. 2 Boot with the provisioning client; it will show above. Choose Backup or Deploy.
    4. -
    @@ -571,18 +566,11 @@ if (shrinkWrap) shrinkWrap.style.display = 'block'; const el = document.createElement('div'); el.className = 'device-item'; - el.innerHTML = '
    USB boot
    Device connected — choose Backup, Deploy, or Update EEPROM
    '; + el.innerHTML = '
    USB boot
    Device connected — choose Backup, Deploy, or Update EEPROM
    '; container.appendChild(el); } else { if (shrinkWrap) shrinkWrap.style.display = 'none'; } - (network || []).forEach(function(d) { - hasAny = true; - const el = document.createElement('div'); - el.className = 'device-item'; - el.innerHTML = '
    Network
    ' + escapeHtml(d.ip || '') + ' · ' + escapeHtml(d.mac || '') + '
    '; - container.appendChild(el); - }); const placeholder = document.getElementById('noPendingPlaceholder'); noPending.style.display = hasAny ? 'none' : 'block'; @@ -597,7 +585,7 @@ if (mac) body.mac = mac; if (action === 'eeprom_update' && source === 'usb') { const presetEl = btn.closest('.device-item') && btn.closest('.device-item').querySelector('.eeprom-preset'); - body.boot_order = (presetEl && presetEl.value) ? presetEl.value : '0xf21'; + body.boot_order = (presetEl && presetEl.value) ? presetEl.value : '0x1'; } const shrinkCb = document.getElementById('shrinkAfterBackup'); if (action === 'backup' && shrinkCb && shrinkCb.checked) body.shrink = true; @@ -902,7 +890,7 @@ 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 (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') { diff --git a/emmc-provisioning/host/README.md b/emmc-provisioning/host/README.md index 3caba93..5817fe7 100644 --- a/emmc-provisioning/host/README.md +++ b/emmc-provisioning/host/README.md @@ -29,4 +29,4 @@ touch "$PROV/build_cloudinit_cancel" echo '{"phase":"idle","message":"","output_name":null,"error":null,"updated":'$(date +%s)'}' > "$PROV/build_cloudinit_status.json" ``` -Then refresh the dashboard or click **Dismiss** to clear the message. +The status lives in **`build_cloudinit_status.json`** (host: `$PROV_DIR/`; dashboard: `CM4_BUILD_STATUS_FILE` or `BASE_DIR/`). You can refresh the dashboard or click **Dismiss** to clear the message (Dismiss force-clears even when stuck).