Refactor dashboard to remove network boot support and update related UI elements</message>

<message>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.
This commit is contained in:
nearxos
2026-02-23 11:08:52 +02:00
parent 55b8661a2e
commit ca27727137
5 changed files with 54 additions and 198 deletions

View File

@@ -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"))) 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"))) 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"))) 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") 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 update (USB boot): tools and output files (shared with host script)
EEPROM_DIR = Path(os.environ.get("CM4_EEPROM_DIR", "/opt/cm4-provisioning/eeprom")) EEPROM_DIR = Path(os.environ.get("CM4_EEPROM_DIR", "/opt/cm4-provisioning/eeprom"))
@@ -225,7 +224,7 @@ ethernets:
DEFAULT_STATUS = { DEFAULT_STATUS = {
"phase": "idle", "phase": "idle",
"message": "Waiting for reTerminal in boot mode or network.", "message": "Waiting for reTerminal in USB boot mode.",
"progress": None, "progress": None,
"updated": None, "updated": None,
} }
@@ -650,171 +649,83 @@ def api_log():
@app.route("/api/eeprom-presets") @app.route("/api/eeprom-presets")
def 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 = [ presets = [
{"id": "0x1", "label": "eMMC only"}, {"id": "0x1", "label": "eMMC only"},
{"id": "0xf21", "label": "eMMC first, then network"},
{"id": "0xf12", "label": "Network first, then eMMC"},
] ]
return jsonify({"presets": presets}) return jsonify({"presets": presets})
@app.route("/api/pending-devices") @app.route("/api/pending-devices")
def 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() st = read_status()
usb = None usb = None
if st.get("phase") == "waiting_choice": if st.get("phase") == "waiting_choice":
usb = {"source": "usb", "message": st.get("message", "Device connected (USB). Choose action.")} usb = {"source": "usb", "message": st.get("message", "Device connected (USB). Choose action.")}
data = _load_network_devices() return jsonify({"usb": usb, "network": []})
network = [d for d in data.get("devices", []) if d.get("action") in (None, "wait")]
return jsonify({"usb": usb, "network": network})
@app.route("/api/device-action", methods=["POST"]) @app.route("/api/device-action", methods=["POST"])
def api_device_action(): 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 {} body = request.get_json(force=True, silent=True) or {}
source = (body.get("source") or "").strip().lower() source = (body.get("source") or "").strip().lower()
action = (body.get("action") or "").strip().lower() action = (body.get("action") or "").strip().lower()
if action not in ("backup", "deploy", "reboot", "eeprom_update"): if source != "usb":
return jsonify({"ok": False, "error": "action must be 'backup', 'deploy', 'reboot', or 'eeprom_update'"}), 400 return jsonify({"ok": False, "error": "Only USB boot is supported"}), 400
if action == "reboot" and source != "network": if action not in ("backup", "deploy", "eeprom_update"):
return jsonify({"ok": False, "error": "'reboot' is only for network devices"}), 400 return jsonify({"ok": False, "error": "action must be 'backup', 'deploy', or 'eeprom_update'"}), 400
if action == "eeprom_update" and source != "usb": if action == "eeprom_update":
return jsonify({"ok": False, "error": "'eeprom_update' is only for USB-connected devices"}), 400 boot_order = (body.get("boot_order") or "0x1").strip().lower()
if source == "usb": if boot_order != "0x1":
if action == "eeprom_update": return jsonify({"ok": False, "error": "boot_order must be 0x1 (eMMC only)"}), 400
boot_order = (body.get("boot_order") or "0xf21").strip().lower() ok, err = _generate_eeprom_update(boot_order)
if boot_order not in ("0x1", "0xf21", "0xf12"): if not ok:
return jsonify({"ok": False, "error": "boot_order must be 0x1, 0xf21, or 0xf12"}), 400 return jsonify({"ok": False, "error": err or "Failed to generate EEPROM update"}), 500
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
try: try:
os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True) 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: with open(ACTION_REQUEST_FILE, "w") as f:
f.write(action) f.write("eeprom_update")
if action == "deploy": _write_status("eeprom_update", "EEPROM update ready; host will write to device boot partition.")
_first_boot_status_write("idle", "", hostname="", ip="")
return jsonify({"ok": True}) return jsonify({"ok": True})
except (PermissionError, OSError): except (PermissionError, OSError):
return jsonify({"ok": False, "error": "Could not write action file"}), 500 return jsonify({"ok": False, "error": "Could not write action file"}), 500
if source == "network": try:
mac = (body.get("mac") or "").strip() os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True)
if not mac: if action == "backup" and body.get("shrink"):
return jsonify({"ok": False, "error": "mac required for network device"}), 400 try:
data = _load_network_devices() (BASE_DIR / "shrink_next_backup").write_text("1")
for d in data.get("devices", []): except (PermissionError, OSError):
if (d.get("mac") or "").lower() == mac.lower(): pass
d["action"] = action with open(ACTION_REQUEST_FILE, "w") as f:
d["action_at"] = time.time() f.write(action)
_save_network_devices(data) if action == "deploy":
ip = d.get("ip") or mac _first_boot_status_write("idle", "", hostname="", ip="")
if action == "deploy": return jsonify({"ok": True})
_write_status("flashing", f"Deploying to {ip} (network)...") except (PermissionError, OSError):
_first_boot_status_write("idle", "", hostname="", ip="") return jsonify({"ok": False, "error": "Could not write action file"}), 500
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
@app.route("/api/register-device", methods=["POST"]) @app.route("/api/register-device", methods=["POST"])
def api_register_device(): 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 body = request.get_json(force=True, silent=True) or request.form
mac = (body.get("mac") or "").strip() if not (body.get("mac") or "").strip():
ip = (body.get("ip") or request.remote_addr or "").strip()
if not mac:
return jsonify({"ok": False, "error": "mac required"}), 400 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"}) 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"]) @app.route("/api/action-done", methods=["POST"])
def api_action_done(): 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 "") 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: if mac:
data = _load_network_devices() data = _load_network_devices()
data["devices"] = [d for d in data.get("devices", []) if (d.get("mac") or "").lower() != mac.lower()] data["devices"] = [d for d in data.get("devices", []) if (d.get("mac") or "").lower() != mac.lower()]
_save_network_devices(data) _save_network_devices(data)
ok, _ = _dhcp_network_boot_run("disable") _write_status("done", f"Done ({mac or 'device'}).")
_write_status("done", f"Done ({mac or 'network device'}). Network boot disabled; device will boot from eMMC on next boot.") return jsonify({"ok": True, "message": "Done"})
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"})
def _read_dhcp_leases(): def _read_dhcp_leases():
@@ -1668,6 +1579,8 @@ def api_build_cloudinit():
image_name = re.sub(r"[^\w\-]", "", image_name) # only alphanumeric, underscore, dash image_name = re.sub(r"[^\w\-]", "", image_name) # only alphanumeric, underscore, dash
try: try:
BUILD_REQUEST_FILE.parent.mkdir(parents=True, exist_ok=True) 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: with open(BUILD_REQUEST_FILE, "w") as f:
json.dump({ json.dump({
"url": url, "url": url,

View File

@@ -146,7 +146,7 @@
var busy = ['resolving','downloading','decompressing','injecting','finalizing'].indexOf(d.phase) >= 0; var busy = ['resolving','downloading','decompressing','injecting','finalizing'].indexOf(d.phase) >= 0;
if (btn) btn.disabled = busy; if (btn) btn.disabled = busy;
if (cancelBtn) { cancelBtn.style.display = busy ? 'inline-block' : 'none'; cancelBtn.disabled = false; } 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 = ''; 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 === '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 === 'error') el.textContent = 'Error: ' + (d.error || '');

View File

@@ -129,10 +129,9 @@
<div class="card"> <div class="card">
<h2 class="card-title">Capture or deploy</h2> <h2 class="card-title">Capture or deploy</h2>
<p id="dhcpNetbootWrap" class="status-row" style="margin-bottom: 0.5rem; font-size: 0.85rem;"><span class="text-dim">Network boot (DHCP):</span> <span id="dhcpNetbootState"></span> <button type="button" id="dhcpNetbootDisableBtn" class="btn btn-outline btn-sm" style="display:none;">Disable network boot</button> <button type="button" id="dhcpNetbootEnableBtn" class="btn btn-outline btn-sm" style="display:none;">Enable network boot</button></p>
<p id="shrinkOptionWrap" style="display:none; margin-bottom: 0.5rem; font-size: 0.8rem;"><label><input type="checkbox" id="shrinkAfterBackup" /> Shrink after backup</label></p> <p id="shrinkOptionWrap" style="display:none; margin-bottom: 0.5rem; font-size: 0.8rem;"><label><input type="checkbox" id="shrinkAfterBackup" /> Shrink after backup</label></p>
<div id="pendingDevices"></div> <div id="pendingDevices"></div>
<p id="noPending" class="empty-msg" style="display:none;">No device connected. Use USB boot mode or register over network.</p> <p id="noPending" class="empty-msg" style="display:none;">No device connected. Use USB boot mode.</p>
</div> </div>
</div> </div>
@@ -167,11 +166,6 @@
<li><span class="num">2</span> Connect USB to host; choose <strong>Backup</strong> or <strong>Deploy</strong> above.</li> <li><span class="num">2</span> Connect USB to host; choose <strong>Backup</strong> or <strong>Deploy</strong> above.</li>
<li><span class="num">3</span> Remove jumper and power cycle when done.</li> <li><span class="num">3</span> Remove jumper and power cycle when done.</li>
</ol> </ol>
<p class="help-sub">Network</p>
<ol class="steps-list">
<li><span class="num">1</span> Enable network boot; device must reach this server.</li>
<li><span class="num">2</span> Boot with provisioning client; choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
</ol>
</div> </div>
</details> </details>
</div> </div>
@@ -210,16 +204,9 @@
shrinkWrap.style.display = 'block'; shrinkWrap.style.display = 'block';
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'device-item'; el.className = 'device-item';
el.innerHTML = '<div class="device-desc">USB device — choose Backup, Deploy, or Update EEPROM</div><div class="device-actions-row"><button type="button" class="btn btn-outline btn-sm" data-source="usb" data-action="backup">Backup</button> <button type="button" class="btn btn-primary btn-sm" data-source="usb" data-action="deploy">Deploy</button> <select class="eeprom-preset" title="Boot order"><option value="0xf21">eMMC first, then network</option><option value="0x1">eMMC only</option><option value="0xf12">Network first, then eMMC</option></select> <button type="button" class="btn btn-outline btn-sm" data-source="usb" data-action="eeprom_update">Update EEPROM</button></div>'; el.innerHTML = '<div class="device-desc">USB device — choose Backup, Deploy, or Update EEPROM</div><div class="device-actions-row"><button type="button" class="btn btn-outline btn-sm" data-source="usb" data-action="backup">Backup</button> <button type="button" class="btn btn-primary btn-sm" data-source="usb" data-action="deploy">Deploy</button> <select class="eeprom-preset" title="Boot order"><option value="0x1">eMMC only</option></select> <button type="button" class="btn btn-outline btn-sm" data-source="usb" data-action="eeprom_update">Update EEPROM</button></div>';
container.appendChild(el); container.appendChild(el);
} else shrinkWrap.style.display = 'none'; } else shrinkWrap.style.display = 'none';
(network || []).forEach(function(d) {
hasAny = true;
const el = document.createElement('div');
el.className = 'device-item';
el.innerHTML = '<div class="device-desc">' + escapeHtml(d.ip || '') + ' · ' + escapeHtml(d.mac || '') + '</div><div><button type="button" class="btn btn-outline btn-sm" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="backup">Backup</button> <button type="button" class="btn btn-primary btn-sm" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="deploy">Deploy</button> <button type="button" class="btn btn-outline btn-sm btn-disable-netboot" title="Stop advertising network boot via DHCP so devices boot from eMMC">Disable network boot</button></div>';
container.appendChild(el);
});
noPending.style.display = hasAny ? 'none' : 'block'; noPending.style.display = hasAny ? 'none' : 'block';
container.querySelectorAll('button[data-action]').forEach(function(btn) { container.querySelectorAll('button[data-action]').forEach(function(btn) {
btn.onclick = function() { btn.onclick = function() {
@@ -227,20 +214,12 @@
if (body.source === 'network') body.mac = btn.getAttribute('data-mac'); if (body.source === 'network') body.mac = btn.getAttribute('data-mac');
if (body.action === 'eeprom_update' && body.source === 'usb') { if (body.action === 'eeprom_update' && body.source === 'usb') {
const presetEl = btn.closest('.device-item') && btn.closest('.device-item').querySelector('.eeprom-preset'); 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; 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) }) fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { if (data.ok) { fetchPending(); fetchStatus(); fetchDhcpNetboot(); } else alert(data.error || 'Failed'); }) .then(function(data) { if (data.ok) { fetchPending(); fetchStatus(); } 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'); })
.catch(function() { alert('Request failed'); }); .catch(function() { alert('Request failed'); });
}; };
}); });
@@ -298,17 +277,6 @@
el.innerHTML = t; el.innerHTML = t;
}).catch(function(){ document.getElementById('goldenInfo').textContent = 'Could not load.'; }); }).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; var doneClearTimer = null;
function scheduleDoneClear() { function scheduleDoneClear() {
if (doneClearTimer) return; if (doneClearTimer) return;
@@ -331,25 +299,12 @@
}).catch(function(){ document.getElementById('leasesError').textContent = 'Could not load leases.'; document.getElementById('leasesError').style.display = 'block'; }); }).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('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(){ fetchStatus(); fetchLog(); fetchPending(); fetchGolden(); fetchDhcpLeases(); fetchFirstBootStatus();
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();
setInterval(fetchStatus, 2000); setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000); setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000); setInterval(fetchPending, 2000);
setInterval(fetchFirstBootStatus, 3000); setInterval(fetchFirstBootStatus, 3000);
setInterval(fetchGolden, 10000); setInterval(fetchGolden, 10000);
setInterval(fetchDhcpNetboot, 10000);
setInterval(fetchDhcpLeases, 10000); setInterval(fetchDhcpLeases, 10000);
</script> </script>
</body> </body>

View File

@@ -360,7 +360,7 @@
<div class="wrap"> <div class="wrap">
<header class="header"> <header class="header">
<h1>CM4 eMMC Provisioning</h1> <h1>CM4 eMMC Provisioning</h1>
<p>Deploy or backup reTerminal via USB boot mode or network</p> <p>Deploy or backup reTerminal via USB boot mode</p>
</header> </header>
<!-- 1. Current status --> <!-- 1. Current status -->
@@ -373,7 +373,7 @@
</div> </div>
<div id="statusErr" class="status-err" style="display:none;"></div> <div id="statusErr" class="status-err" style="display:none;"></div>
<div id="statusGoldenHint" class="backup-deploy-hint" style="display:none; margin-top:0.75rem;"> <div id="statusGoldenHint" class="backup-deploy-hint" style="display:none; margin-top:0.75rem;">
No golden image is required to <strong>capture</strong>. Connect a device in USB boot mode (or register over network); when it appears under “Capture image or deploy”, click <strong>Backup</strong> to save its image. Then set that backup as golden in the list below. No golden image is required to <strong>capture</strong>. Connect a device in USB boot mode; when it appears under “Capture image or deploy”, click <strong>Backup</strong> to save its image. Then set that backup as golden in the list below.
<button type="button" id="statusClearHintBtn" class="btn btn-outline btn-sm" style="margin-left:0.5rem;">Clear message</button> <button type="button" id="statusClearHintBtn" class="btn btn-outline btn-sm" style="margin-left:0.5rem;">Clear message</button>
</div> </div>
<div id="statusMeta" class="status-meta" style="display:none;"></div> <div id="statusMeta" class="status-meta" style="display:none;"></div>
@@ -385,7 +385,7 @@
<!-- 2. Capture (Backup) or Deploy --> <!-- 2. Capture (Backup) or Deploy -->
<section class="section"> <section class="section">
<h2 class="section-title">Capture image or deploy</h2> <h2 class="section-title">Capture image or deploy</h2>
<p class="backup-deploy-hint">To <strong>capture (backup)</strong> an image from a device: connect it in USB boot mode or register over network. When the device appears below, click <strong>Backup</strong> to save its eMMC to a file. To write an image to a device, click <strong>Deploy</strong> (requires a golden image).</p> <p class="backup-deploy-hint">To <strong>capture (backup)</strong> an image from a device: connect it in USB boot mode. When the device appears below, click <strong>Backup</strong> to save its eMMC to a file. To write an image to a device, click <strong>Deploy</strong> (requires a golden image).</p>
<p id="shrinkOptionWrap" class="backup-deploy-hint" style="display:none; margin-top:0.5rem;"> <p id="shrinkOptionWrap" class="backup-deploy-hint" style="display:none; margin-top:0.5rem;">
<label><input type="checkbox" id="shrinkAfterBackup" /> Shrink after backup</label> <span class="backups-mono" style="font-size:0.8rem;">(reduces size; requires PiShrink on host)</span> <label><input type="checkbox" id="shrinkAfterBackup" /> Shrink after backup</label> <span class="backups-mono" style="font-size:0.8rem;">(reduces size; requires PiShrink on host)</span>
</p> </p>
@@ -396,9 +396,9 @@
<button type="button" class="btn btn-outline btn-sm" disabled>Backup</button> <button type="button" class="btn btn-outline btn-sm" disabled>Backup</button>
<button type="button" class="btn btn-primary btn-sm" disabled>Deploy</button> <button type="button" class="btn btn-primary btn-sm" disabled>Deploy</button>
</span> </span>
<span>— USB boot mode or network registration</span> <span>— USB boot mode</span>
</div> </div>
<p id="noPending" class="empty-msg" style="display:none;">No device connected. Connect reTerminal in USB boot mode (eMMC disable jumper + USB to host) or register a network-booted device — then the <strong>Backup</strong> and <strong>Deploy</strong> buttons will appear above.</p> <p id="noPending" class="empty-msg" style="display:none;">No device connected. Connect reTerminal in USB boot mode (eMMC disable jumper + USB to host) — then the <strong>Backup</strong> and <strong>Deploy</strong> buttons will appear above.</p>
</section> </section>
<!-- 3. Saved backups --> <!-- 3. Saved backups -->
@@ -481,11 +481,6 @@
<li><span class="num">2</span> Connect <strong>USB slave</strong> to the host and power on. The device appears above; choose <strong>Backup</strong> or <strong>Deploy</strong>.</li> <li><span class="num">2</span> Connect <strong>USB slave</strong> to the host and power on. The device appears above; choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
<li><span class="num">3</span> When done, remove the jumper and power cycle to boot from eMMC.</li> <li><span class="num">3</span> When done, remove the jumper and power cycle to boot from eMMC.</li>
</ol> </ol>
<p class="help-sub">Network boot</p>
<ol class="steps-list">
<li><span class="num">1</span> Enable network boot (e.g. <code style="background:var(--bg-tertiary);padding:0.15rem 0.35rem;border-radius:4px;">BOOT_ORDER=0xf21</code>) and ensure the device can reach this server.</li>
<li><span class="num">2</span> Boot with the provisioning client; it will show above. Choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
</ol>
</div> </div>
</details> </details>
@@ -571,18 +566,11 @@
if (shrinkWrap) shrinkWrap.style.display = 'block'; if (shrinkWrap) shrinkWrap.style.display = 'block';
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'device-item'; el.className = 'device-item';
el.innerHTML = '<div class="device-info"><div class="device-type">USB boot</div><div class="device-desc">Device connected — choose Backup, Deploy, or Update EEPROM</div></div><div class="device-actions"><button type="button" class="btn btn-outline" data-source="usb" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="usb" data-action="deploy">Deploy</button><select class="eeprom-preset" title="Boot order"><option value="0xf21">eMMC first, then network</option><option value="0x1">eMMC only</option><option value="0xf12">Network first, then eMMC</option></select><button type="button" class="btn btn-outline" data-source="usb" data-action="eeprom_update">Update EEPROM</button></div>'; el.innerHTML = '<div class="device-info"><div class="device-type">USB boot</div><div class="device-desc">Device connected — choose Backup, Deploy, or Update EEPROM</div></div><div class="device-actions"><button type="button" class="btn btn-outline" data-source="usb" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="usb" data-action="deploy">Deploy</button><select class="eeprom-preset" title="Boot order"><option value="0x1">eMMC only</option></select><button type="button" class="btn btn-outline" data-source="usb" data-action="eeprom_update">Update EEPROM</button></div>';
container.appendChild(el); container.appendChild(el);
} else { } else {
if (shrinkWrap) shrinkWrap.style.display = 'none'; if (shrinkWrap) shrinkWrap.style.display = 'none';
} }
(network || []).forEach(function(d) {
hasAny = true;
const el = document.createElement('div');
el.className = 'device-item';
el.innerHTML = '<div class="device-info"><div class="device-type">Network</div><div class="device-desc">' + escapeHtml(d.ip || '') + ' · ' + escapeHtml(d.mac || '') + '</div></div><div class="device-actions"><button type="button" class="btn btn-outline" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="deploy">Deploy</button></div>';
container.appendChild(el);
});
const placeholder = document.getElementById('noPendingPlaceholder'); const placeholder = document.getElementById('noPendingPlaceholder');
noPending.style.display = hasAny ? 'none' : 'block'; noPending.style.display = hasAny ? 'none' : 'block';
@@ -597,7 +585,7 @@
if (mac) body.mac = mac; if (mac) body.mac = mac;
if (action === 'eeprom_update' && source === 'usb') { if (action === 'eeprom_update' && source === 'usb') {
const presetEl = btn.closest('.device-item') && btn.closest('.device-item').querySelector('.eeprom-preset'); 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'); const shrinkCb = document.getElementById('shrinkAfterBackup');
if (action === 'backup' && shrinkCb && shrinkCb.checked) body.shrink = true; if (action === 'backup' && shrinkCb && shrinkCb.checked) body.shrink = true;
@@ -902,7 +890,7 @@
var busy = ['resolving','downloading','decompressing','injecting','finalizing'].indexOf(d.phase) >= 0; var busy = ['resolving','downloading','decompressing','injecting','finalizing'].indexOf(d.phase) >= 0;
if (btn) btn.disabled = busy; if (btn) btn.disabled = busy;
if (cancelBtn) { cancelBtn.style.display = busy ? 'inline-block' : 'none'; cancelBtn.disabled = false; } 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) { if (d.phase === 'idle' && !d.message) {
el.textContent = ''; el.textContent = '';
} else if (d.phase === 'done') { } else if (d.phase === 'done') {

View File

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