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:
@@ -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."""
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user