Add DHCP leases management to dashboard and UI

Implement a new API endpoint to retrieve current DHCP leases from dnsmasq, enhancing the dashboard's functionality for monitoring network devices. Update the home.html template to display DHCP lease information in a structured table format, including IP, MAC, hostname, and expiry details. Introduce buttons for enabling and disabling DHCP network boot, improving user interaction. Enhance JavaScript to fetch and display lease data dynamically, ensuring users have real-time visibility of network activity.
This commit is contained in:
nearxos
2026-02-20 17:30:23 +02:00
parent 7e1bf8a4c2
commit 90296498f5
6 changed files with 158 additions and 7 deletions

View File

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

View File

@@ -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; }
</style>
</head>
<body>
@@ -104,7 +108,7 @@
<div class="card">
<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></p>
<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>
<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>
@@ -123,6 +127,16 @@
<div class="inner"><pre id="log" class="log-pre"></pre></div>
</details>
<details class="card" style="padding: 0; margin-top: 1rem;">
<summary>DHCP leases</summary>
<div class="inner">
<p id="leasesNote" class="help-sub" style="margin-top:0;">Provisioning LAN (dnsmasq) — when dashboard runs on LXC.</p>
<div id="leasesContent"><table class="leases-table"><thead><tr><th>IP</th><th>MAC</th><th>Hostname</th><th>Expires</th></tr></thead><tbody id="leasesBody"></tbody></table></div>
<p id="leasesError" style="display:none; font-size: 0.8rem; color: var(--text-muted);"></p>
<p id="leasesEmpty" style="display:none; font-size: 0.85rem; color: var(--text-muted);">No leases file or no active leases.</p>
</div>
</details>
<details class="card" style="padding: 0; margin-top: 1rem;">
<summary>How to connect</summary>
<div class="inner">
@@ -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 '<tr><td class="mono">' + escapeHtml(l.ip) + '</td><td class="mono">' + escapeHtml(l.mac) + '</td><td>' + escapeHtml(l.hostname || '') + '</td><td>' + expStr + '</td></tr>';
}).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);
</script>
</body>
</html>

View File

@@ -86,6 +86,17 @@ To refresh or populate TFTP without re-running the full setup:
The TFTP root contains e.g. `start4cd.elf`, `fixup4cd.dat`, `config.txt`, `cmdline.txt`, `kernel8.img`, and other boot files. For a custom kernel or initramfs (e.g. for provisioning), add or replace files in `/srv/tftpboot` and adjust `config.txt` / `cmdline.txt` as needed.
## DHCP leases
On the LXC, dnsmasq stores DHCP leases in **`/var/lib/misc/dnsmasq.leases`** (Debian/Ubuntu default). To see which devices got an IP on the provisioning LAN:
```bash
# On the LXC (or via SSH)
cat /var/lib/misc/dnsmasq.leases
```
Each line is: *expiry_epoch MAC IP hostname client_id*. Example: `1734567890 aa:bb:cc:dd:ee:ff 10.20.50.101 reterminal 01:aa:bb:cc:dd:ee:ff`
## Summary
| Component | Where | Purpose |

View File

@@ -205,6 +205,7 @@ for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do
fi
log "Backup complete: $BACKUPS_DIR/$final_name"
write_status "done" "Backup complete: $final_name" "100"
( sleep 90 && write_status "idle" "Waiting for reTerminal in boot mode or network." "null" ) &
else
write_status "error" "Backup failed" "null" "dd failed"
fi
@@ -219,6 +220,8 @@ for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do
if dd if="$GOLDEN_IMAGE" of="$target_dev" bs=4M status=progress conv=fsync; then
log "Flash complete. Remove eMMC disable jumper and power cycle the reTerminal."
write_status "done" "Flash complete. Remove eMMC disable jumper and power cycle the reTerminal." "100"
# Auto-reset status to idle after 90s so dashboard does not stay on this message (dashboard also auto-clears after 60s when open)
( sleep 90 && write_status "idle" "Waiting for reTerminal in boot mode or network." "null" ) &
else
write_status "error" "Flash failed" "null" "dd failed"
fi

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Check if network boot is set as first priority on a Pi 4 / CM4 (reTerminal).
# Run on the device: ./check-network-boot-priority.sh
# Or from your machine: ssh pi@<device-ip> 'bash -s' < scripts/check-network-boot-priority.sh
set -e
# BOOT_ORDER: 0x2 = network, 0x1 = SD/eMMC. 0x21 = network first, then local storage.
WANT_BOOT_ORDER="0x21"
get_config() {
if command -v vcgencmd >/dev/null 2>&1; then
vcgencmd bootloader_config 2>/dev/null || true
fi
if command -v rpi-eeprom-update >/dev/null 2>&1 && command -v rpi-eeprom-config >/dev/null 2>&1; then
PEE="$(rpi-eeprom-update -l 2>/dev/null)"
if [[ -n "$PEE" && -f "$PEE" ]]; then
rpi-eeprom-config "$PEE" 2>/dev/null || true
fi
fi
}
BOOTCONF="$(get_config)"
BOOT_ORDER="$(echo "$BOOTCONF" | grep -oE 'BOOT_ORDER=[0-9A-Fa-fx]+' | head -1 | cut -d= -f2)"
if [[ -z "$BOOT_ORDER" ]]; then
echo "Could not read EEPROM config (vcgencmd bootloader_config or rpi-eeprom-config)."
echo "Is this a Raspberry Pi 4 or CM4 with rpi-eeprom / vcgencmd?"
exit 1
fi
echo "BOOT_ORDER=$BOOT_ORDER (current)"
echo "Expected for network first: $WANT_BOOT_ORDER (0x2=network, 0x1=SD/eMMC; 0x21 = network then local)"
if [[ "$(echo "$BOOT_ORDER" | tr '[:upper:]' '[:lower:]')" == "$(echo "$WANT_BOOT_ORDER" | tr '[:upper:]' '[:lower:]')" ]]; then
echo "Result: Network boot is set as first priority."
exit 0
fi
echo "Result: Network boot is NOT first (current: $BOOT_ORDER). To set network first, set BOOT_ORDER=0x21 (e.g. via cloud-init first-boot or rpi-eeprom-config --edit)."
exit 2

View File

@@ -12,6 +12,12 @@ if [[ -n "$TARGET" ]]; then
REPO_DIR="$(dirname "$SCRIPT_DIR")"
echo "Syncing lxc config and script to $TARGET ..."
rsync -a "$REPO_DIR/lxc/" "$TARGET:/tmp/cm4-network-boot-lxc/" --exclude='.git'
if [[ -f "$REPO_DIR/network-boot-initramfs/initrd.img" ]]; then
echo "Copying initrd.img to $TARGET ..."
scp "$REPO_DIR/network-boot-initramfs/initrd.img" "$TARGET:/tmp/cm4-network-boot-lxc/initrd.img"
else
echo "Note: network-boot-initramfs/initrd.img not found (run build.sh first); skipping."
fi
scp "$SCRIPT_DIR/setup-network-boot-on-lxc.sh" "$TARGET:/tmp/cm4-network-boot-lxc/setup.sh"
ssh "$TARGET" "bash /tmp/cm4-network-boot-lxc/setup.sh"
echo "Done."
@@ -68,6 +74,18 @@ else
echo "TFTP root already has boot files (start4cd.elf present), skipping fetch."
fi
# 3b) Copy provisioning initrd.img to TFTP root if provided
if [[ -f /tmp/cm4-network-boot-lxc/initrd.img ]]; then
cp /tmp/cm4-network-boot-lxc/initrd.img /srv/tftpboot/initrd.img
echo "Copied initrd.img to /srv/tftpboot"
if [[ -f /srv/tftpboot/config.txt ]] && ! grep -q 'initramfs initrd.img' /srv/tftpboot/config.txt 2>/dev/null; then
echo "" >> /srv/tftpboot/config.txt
echo "# Provisioning initramfs (network-boot-initramfs)" >> /srv/tftpboot/config.txt
echo "initramfs initrd.img followkernel" >> /srv/tftpboot/config.txt
echo "Added initramfs line to config.txt"
fi
fi
# 4) IP forwarding (LAN clients use WAN)
echo 'net.ipv4.ip_forward=1' > /etc/sysctl.d/99-cm4-network-boot.conf
sysctl -p /etc/sysctl.d/99-cm4-network-boot.conf 2>/dev/null || sysctl -w net.ipv4.ip_forward=1
@@ -105,4 +123,4 @@ systemctl restart dnsmasq
echo "Network boot setup done."
echo " - DHCP + TFTP on eth1 (10.20.50.1), range 10.20.50.100-200"
echo " - NAT: 10.20.50.0/24 -> eth0 (internet)"
echo " - TFTP root: /srv/tftpboot (RPi boot files from GitHub)"
echo " - TFTP root: /srv/tftpboot (RPi boot files; initrd.img if provided)"