diff --git a/emmc-provisioning/dashboard/app.py b/emmc-provisioning/dashboard/app.py
index 5173953..a2f1672 100644
--- a/emmc-provisioning/dashboard/app.py
+++ b/emmc-provisioning/dashboard/app.py
@@ -42,6 +42,7 @@ CLOUDINIT_TEMPLATES_FILE = Path(os.environ.get("CM4_CLOUDINIT_TEMPLATES_FILE", s
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")
# --- Database (admin users + activity logs) ---
@@ -593,13 +594,46 @@ def api_dhcp_network_boot_post():
@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."""
- mac = (request.args.get("mac") or request.get_json(silent=True) or {}).get("mac", "")
+ mac = request.args.get("mac") or ((request.get_json(silent=True) or {}).get("mac") or "")
ok, _ = _dhcp_network_boot_run("disable")
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():
+ """Read dnsmasq lease file. Returns (leases_list, error_string). leases_list items: {expiry, mac, ip, hostname}."""
+ if not DHCP_LEASES_FILE or not os.path.isfile(DHCP_LEASES_FILE):
+ return [], None
+ try:
+ leases = []
+ with open(DHCP_LEASES_FILE, "r") as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ parts = line.split()
+ if len(parts) >= 4:
+ leases.append({
+ "expiry": int(parts[0]) if parts[0].isdigit() else 0,
+ "mac": parts[1],
+ "ip": parts[2],
+ "hostname": parts[3] if len(parts) > 3 else "",
+ })
+ return leases, None
+ except (OSError, PermissionError) as e:
+ return [], str(e)
+
+
+@app.route("/api/dhcp-leases")
+def api_dhcp_leases():
+ """Return current DHCP leases from dnsmasq (when dashboard runs on LXC with dnsmasq)."""
+ leases, err = _read_dhcp_leases()
+ if err:
+ return jsonify({"leases": [], "error": err})
+ return jsonify({"leases": leases, "error": None})
+
+
@app.route("/api/device-action-poll")
def api_device_action_poll():
"""Network device polls this to get its assigned action (deploy/backup) and URL."""
diff --git a/emmc-provisioning/dashboard/templates/home.html b/emmc-provisioning/dashboard/templates/home.html
index 8f009b8..0e80df0 100644
--- a/emmc-provisioning/dashboard/templates/home.html
+++ b/emmc-provisioning/dashboard/templates/home.html
@@ -79,6 +79,10 @@
.steps-list .num { flex-shrink: 0; width: 1.2rem; height: 1.2rem; background: var(--bg-secondary); color: var(--text-dim); border-radius: 50%; font-size: 0.7rem; font-weight: 600; display: inline-flex; align-items: center; justify-content: center; }
.steps-list strong { color: var(--text); }
.help-sub { font-weight: 600; color: var(--text); margin: 0.5rem 0 0.2rem 0; font-size: 0.85rem; }
+ .leases-table { width: 100%; font-size: 0.8rem; border-collapse: collapse; }
+ .leases-table th, .leases-table td { padding: 0.35rem 0.5rem; text-align: left; border-bottom: 1px solid var(--border); }
+ .leases-table th { color: var(--text-dim); font-weight: 600; }
+ .leases-table .mono { font-family: 'JetBrains Mono', monospace; }
@@ -104,7 +108,7 @@
Capture or deploy
-
Network boot (DHCP): —
+
Network boot (DHCP): —
No device connected. Use USB boot mode or register over network.
@@ -123,6 +127,16 @@
+
+ DHCP leases
+
+
Provisioning LAN (dnsmasq) — when dashboard runs on LXC.
+
+
+
No leases file or no active leases.
+
+
+
How to connect
@@ -154,6 +168,7 @@
if (data.error) { err.textContent = data.error; err.style.display = 'block'; } else { err.style.display = 'none'; }
var clearWrap = document.getElementById('statusClearWrap');
if (clearWrap) clearWrap.style.display = (phase === 'error' ? 'block' : 'none');
+ if (phase === 'done') scheduleDoneClear();
const progress = data.progress;
const inProgress = ['rpiboot', 'flashing', 'backup'].includes(phase);
const wrap = document.getElementById('progressWrap');
@@ -225,12 +240,35 @@
function fetchDhcpNetboot() {
fetch('/api/dhcp-network-boot').then(function(r){ return r.json(); }).then(function(d){
const stateEl = document.getElementById('dhcpNetbootState');
- const btn = document.getElementById('dhcpNetbootDisableBtn');
- if (d.error) { stateEl.textContent = '—'; if (btn) btn.style.display = 'none'; return; }
+ 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 (btn) btn.style.display = d.enabled ? 'inline-block' : 'none';
+ 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;
+ doneClearTimer = setTimeout(function(){ doneClearTimer = null; fetch('/api/status-clear', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchStatus(); }); }, 60000);
+ }
+ function fetchDhcpLeases() {
+ fetch('/api/dhcp-leases').then(function(r){ return r.json(); }).then(function(d){
+ const tbody = document.getElementById('leasesBody');
+ const errEl = document.getElementById('leasesError');
+ const emptyEl = document.getElementById('leasesEmpty');
+ errEl.style.display = 'none';
+ emptyEl.style.display = 'none';
+ if (d.error) { errEl.textContent = d.error; errEl.style.display = 'block'; tbody.innerHTML = ''; return; }
+ var rows = (d.leases || []).map(function(l){
+ var expStr = l.expiry ? (new Date(l.expiry*1000).toLocaleString()) : '—';
+ return '
| ' + escapeHtml(l.ip) + ' | ' + escapeHtml(l.mac) + ' | ' + escapeHtml(l.hostname || '') + ' | ' + expStr + ' |
';
+ }).join('');
+ tbody.innerHTML = rows || '';
+ if (!rows) emptyEl.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('dhcpNetbootDisableBtn').addEventListener('click', function(){
fetch('/api/dhcp-network-boot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: false }) })
@@ -238,12 +276,19 @@
.then(function(d){ if(d.ok) fetchDhcpNetboot(); else alert(d.error || 'Failed'); })
.catch(function(){ alert('Request failed'); });
});
- fetchStatus(); fetchLog(); fetchPending(); fetchGolden(); fetchDhcpNetboot();
+ 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();
setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000);
setInterval(fetchGolden, 10000);
setInterval(fetchDhcpNetboot, 10000);
+ setInterval(fetchDhcpLeases, 10000);