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).