--- /dev/null
+# backend/routes/dhcp.py
+
+# import standard modules
+import csv
+import os
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+# Import Settings & Logging
+from backend.settings.settings import settings
+from backend.log.log import get_logger
+from backend.utils import to_bool, to_int
+
+# -----------------------------
+# Normalizes column names to expected keys
+# -----------------------------
+def _norm(col: str) -> str:
+ col = (col or "").strip()
+ aliases = {
+ "client_id": "client-id",
+ "valid_lifetime": "valid-lft",
+ "subnet_id": "subnet-id",
+ "fqdn_fwd": "fqdn-fwd",
+ "fqdn_rev": "fqdn-rev",
+ "user_context": "user-context",
+ "pool_id": "pool-id",
+ }
+ return aliases.get(col, col)
+
+# Logger initialization
+logger = get_logger(__name__)
+
+# -----------------------------
+# SELECT ALL LEASES
+# -----------------------------
+def get_leases(filter_devices: bool = False) -> List[Dict[str, Any]]:
+ leases = []
+ index = 1 # 1-based id for frontend
+
+ path = Path(settings.DHCP4_LEASES_FILE)
+ if not path.exists():
+ raise FileNotFoundError(f"File not found: {path}")
+
+ # Open the file in lettura (non locking): ok per kea memfile
+ with path.open("r", encoding="utf-8", newline="") as f:
+ reader = csv.DictReader(f)
+ if not reader.fieldnames:
+ return []
+
+ if(filter_devices != True):
+ for raw in reader:
+ rec = { _norm(k): (v if v is not None else "") for k, v in raw.items() }
+
+ item = {
+ "id": index,
+ "ipv4": rec.get("address", "").strip() or None,
+ "mac": rec.get("hwaddr", "").strip().lower() or None,
+ "client_id": rec.get("client-id", "").strip() or None,
+ "valid_lifetime": to_int(rec.get("valid-lft", "")),
+ "expire": rec.get("expire", "").strip() or None,
+ "subnet_id": to_int(rec.get("subnet-id", "")),
+ "fqdn_fwd": to_bool(rec.get("fqdn-fwd", "")),
+ "fqdn_rev": to_bool(rec.get("fqdn-rev", "")),
+ "name": rec.get("hostname", "").strip() or None,
+ "dhcp_state": rec.get("state", "").strip() or None,
+ "user_context": rec.get("user-context", "").strip() or None, # spesso JSON serializzato
+ "pool_id": to_int(rec.get("pool-id", "")),
+ }
+ leases.append(item)
+ index += 1
+ else:
+ for raw in reader:
+ rec = { _norm(k): (v if v is not None else "") for k, v in raw.items() }
+
+ item = {
+ "id": f"d-{index}",
+ "ipv4": rec.get("address", "").strip() or None,
+ "mac": rec.get("hwaddr", "").strip().lower() or None,
+ "name": rec.get("hostname", "").strip() or None,
+ "dhcp_state": rec.get("state", "").strip() or None,
+ }
+ leases.append(item)
+ index += 1
+
+ return leases
+
+# -----------------------------
+# SELECT SINGLE LEASE
+# -----------------------------
+def get_lease(lease_id: int) -> Optional[Dict[str, Any]]:
+ path = Path(settings.DHCP4_LEASES_FILE)
+ if not path.exists():
+ raise FileNotFoundError(f"File not found: {path}")
+
+ with path.open("r", encoding="utf-8", newline="") as f:
+ reader = csv.DictReader(f)
+ if not reader.fieldnames:
+ return None
+
+ for index, raw in enumerate(reader, start=1):
+ if index == lease_id:
+ rec = {_norm(k): (v if v is not None else "") for k, v in raw.items()}
+
+ return {
+ "id": index,
+ "ipv4": rec.get("address", "").strip() or None,
+ "mac": rec.get("hwaddr", "").strip().lower() or None,
+ "client_id": rec.get("client-id", "").strip() or None,
+ "valid_lifetime": to_int(rec.get("valid-lft", "")),
+ "expire": rec.get("expire", "").strip() or None,
+ "subnet_id": to_int(rec.get("subnet-id", "")),
+ "fqdn_fwd": to_bool(rec.get("fqdn-fwd", "")),
+ "fqdn_rev": to_bool(rec.get("fqdn-rev", "")),
+ "name": rec.get("hostname", "").strip() or None,
+ "dhcp_state": rec.get("state", "").strip() or None,
+ "user_context": rec.get("user-context", "").strip() or None,
+ "pool_id": to_int(rec.get("pool-id", "")),
+ }
+
+ return None
+
+# -----------------------------
+# DELETE LEASE
+# -----------------------------
+def delete_lease(lease_id: int):
+
+ path = Path(settings.DHCP4_LEASES_FILE)
+ if not path.exists():
+ raise FileNotFoundError(f"File not found: {path}")
+
+ with path.open("r") as f:
+ lines = f.readlines()
+
+ # file empty or only header
+ if len(lines) < 2:
+ raise ValueError(f"Lease file is empty: {path}")
+
+ header = lines[0]
+ data_lines = lines[1:]
+ index = lease_id - 1 # lease_id is 1-based, index is 0-based
+
+ # Index out of range
+ if index < 0 or index >= len(data_lines):
+ raise ValueError(f"Lease index out of range: {lease_id}")
+
+ # delete the line
+ deleted_line = data_lines.pop(index)
+
+ # Rewrite the file without the deleted line
+ with path.open("w") as f:
+ f.writelines([header] + data_lines)
# import standard modules
from fastapi import APIRouter, Request, Response, HTTPException, status
from fastapi.responses import FileResponse
-import asyncio
-import csv
import json
import os
-import ipaddress
-from pathlib import Path
import time
# Import local modules
from backend.db.hosts import get_hosts
+from backend.db.leases import get_leases, get_lease, delete_lease
# Import Settings & Logging
from backend.settings.settings import settings
# Create Router
router = APIRouter()
+# ---------------------------------------------------------
+# FRONTEND PATHS (absolute paths inside Docker)
+# ---------------------------------------------------------
+# Leases page
+@router.get("/leases")
+def leases(request: Request):
+ return FileResponse(os.path.join(settings.FRONTEND_DIR, "leases.html"))
+
+# Serve leases.js
+@router.get("/js/leases.js")
+def js_leases():
+ return FileResponse(os.path.join(settings.FRONTEND_DIR, "js/leases.js"))
+
# ---------------------------------------------------------
# Reload
# ---------------------------------------------------------
})
def api_dhcp_leases(request: Request):
+ try:
+ leases = get_leases()
+ return leases or []
+
+ except FileNotFoundError as err:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail={
+ "code": "DHCP_LEASES_NOT_FOUND",
+ "status": "failure",
+ "message": str(err),
+ },
+ )
+
+ except HTTPException:
+ raise
+
+ except Exception as err:
+ logger.exception("Error reading DHCP leases: %s", str(err).strip())
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail={
+ "code": "DHCP_LEASES_ERROR",
+ "status": "failure",
+ "message": "Internal error reading DHCP leases",
+ },
+ )
+
+# ---------------------------------------------------------
+# Get Lease
+# ---------------------------------------------------------
+@router.get("/api/dhcp/leases/{lease_id}", status_code=status.HTTP_200_OK, responses={
+ 200: {"description": "Lease found"},
+ 404: {"description": "Lease not found"},
+ 500: {"description": "Internal server error"},
+})
+def api_get_lease(request: Request, lease_id: int):
+
# Inizializzazioni
- items = []
+ start_ns = time.monotonic_ns()
try:
- path = Path(settings.DHCP4_LEASES_FILE)
- if not path.exists():
+ lease = get_lease(lease_id)
+ if not lease: # None or empty dict
+ took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
- "code": "DHCP_LEASES_NOT_FOUND",
+ "code": "DHCP_LEASE_NOT_FOUND",
"status": "failure",
- "message": "File not found: " + str(path),
+ "message": "Lease not found",
+ "details": {
+ "lease_id": lease_id,
+ "took_ms": took_ms,
+ },
},
)
+ return lease or []
- def _to_int(v: str):
- v = (v or "").strip()
- if not v or v.lower() == "null":
- return None
- try:
- return int(v)
- except ValueError:
- return None
-
- def _to_bool(v: str):
- v = (v or "").strip().lower()
- if v in ("true", "1", "yes", "y"):
- return True
- if v in ("false", "0", "no", "n"):
- return False
- return None
-
- def _norm(col: str) -> str:
- col = (col or "").strip()
- aliases = {
- "client_id": "client-id",
- "valid_lifetime": "valid-lft",
- "subnet_id": "subnet-id",
- "fqdn_fwd": "fqdn-fwd",
- "fqdn_rev": "fqdn-rev",
- "user_context": "user-context",
- "pool_id": "pool-id",
- }
- return aliases.get(col, col)
-
- # Open the file in lettura (non locking): ok per kea memfile
- with path.open("r", encoding="utf-8", newline="") as f:
- reader = csv.DictReader(f)
- if not reader.fieldnames:
- return {
- "total": 0, "items": []
- }
-
- for raw in reader:
- rec = { _norm(k): (v if v is not None else "") for k, v in raw.items() }
-
- item = {
- "address": rec.get("address", "").strip() or None,
- "hwaddr": rec.get("hwaddr", "").strip() or None,
- "client_id": rec.get("client-id", "").strip() or None,
- "valid_lifetime": _to_int(rec.get("valid-lft", "")),
- "expire": _to_int(rec.get("expire", "")), # epoch seconds
- "subnet_id": _to_int(rec.get("subnet-id", "")),
- "fqdn_fwd": _to_bool(rec.get("fqdn-fwd", "")),
- "fqdn_rev": _to_bool(rec.get("fqdn-rev", "")),
- "hostname": rec.get("hostname", "").strip() or None,
- "state": _to_int(rec.get("state", "")),
- "user_context": rec.get("user-context", "").strip() or None, # spesso JSON serializzato
- "pool_id": _to_int(rec.get("pool-id", "")),
- }
- items.append(item)
+ except FileNotFoundError as err:
+ took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail={
+ "code": "DHCP_LEASE_NOT_FOUND",
+ "status": "failure",
+ "message": str(err),
+ "details": {
+ "lease_id": lease_id,
+ "took_ms": took_ms,
+ },
+ },
+ )
+ except HTTPException:
+ raise
+
+ except Exception as err:
+ logger.exception("Error reading DHCP lease: %s", str(err).strip())
+ took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail={
+ "code": "DHCP_LEASE_ERROR",
+ "status": "failure",
+ "message": "Internal error reading DHCP lease",
+ "details": {
+ "lease_id": lease_id,
+ "took_ms": took_ms,
+ },
+ },
+ )
+
+# ---------------------------------------------------------
+# Delete
+# ---------------------------------------------------------
+@router.delete("/api/dhcp/leases/{lease_id}", status_code=status.HTTP_200_OK, responses={
+ 200: {"description": "Lease deleted"},
+ 404: {"description": "Lease not found"},
+ 500: {"description": "Internal server error"},
+})
+def api_delete_lease(request: Request, lease_id: int):
+
+ # Inizializzazioni
+ start_ns = time.monotonic_ns()
+
+ try:
+ delete_lease(lease_id)
+
+ took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
return {
- "total": len(items),
- "items": items
- }
+ "code": "DHCP_LEASE_DELETED",
+ "status": "success",
+ "message": "Lease deleted successfully",
+ "details": {
+ "lease_id": lease_id,
+ "took_ms": took_ms,
+ },
+ }
+
+ except FileNotFoundError as err:
+ took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail={
+ "code": "DHCP_LEASES_NOT_FOUND",
+ "status": "failure",
+ "message": str(err),
+ "details": {
+ "lease_id": lease_id,
+ "took_ms": took_ms,
+ },
+ },
+ )
+
+ except ValueError as err:
+ took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail={
+ "code": "DHCP_LEASES_NOT_FOUND",
+ "status": "failure",
+ "message": str(err),
+ "details": {
+ "lease_id": lease_id,
+ "took_ms": took_ms,
+ },
+ },
+ )
+
+ except HTTPException:
+ raise
except Exception as err:
- logger.exception("Error reading DHCP leases: %s", str(err).strip())
+ logger.exception("Error deleting lease index %s: %s", lease_id, str(err).strip())
+ took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
- "code": "DHCP_LEASES_ERROR",
+ "code": "DHCP_LEASE_DELETE_ERROR",
"status": "failure",
- "message": "Internal error reading DHCP leases",
+ "message": "Internal error deleting lease",
+ "details": {
+ "lease_id": lease_id,
+ "took_ms": took_ms,
+ },
},
)
<div class="navbar-nav ms-auto gap-2">
<a href="/hosts" id="hostsBtn" class="btn btn-primary" aria-current="page">Hostname</a>
<a href="/aliases" id="aliasesBtn" class="btn btn-primary active" aria-current="page">Alias</a>
+ <a href="/leases" id="leasesBtn" class="btn btn-primary" aria-current="page">DHCP Leases</a>
<button id="logoutBtn" class="btn btn-primary">Logout</button>
</div>
</div>
<div class="navbar-nav ms-auto gap-2">
<a href="/hosts" id="hostsBtn" class="btn btn-primary active" aria-current="page">Hostname</a>
<a href="/aliases" id="aliasesBtn" class="btn btn-primary" aria-current="page">Alias</a>
+ <a href="/leases" id="leasesBtn" class="btn btn-primary" aria-current="page">DHCP Leases</a>
<button id="logoutBtn" class="btn btn-primary">Logout</button>
</div>
</div>
<h3>DHCP (Kea)</h3>
<p>Pools, leases, reservations.</p>
<div class="mt-2 d-flex gap-2 flex-wrap">
+ <a href="/leases" class="btn btn-primary btn-sm" title="DHCP Leases (Kea)" aria-label="DHCP Leases">
+ <i class="bi bi-list-ul"></i>
+ <span class="label"> DHCP Leases</span>
+ </a>
<button class="btn btn-primary btn-sm" title="Reload DHCP (Kea)" aria-label="Reload DHCP" data-action="reloadDhcp">
<i class="bi bi-arrow-repeat"></i><span class="label"> Reload DHCP</span>
</button>
--- /dev/null
+// Import common js
+import { isValidIPv4, isValidIPv6, isValidMAC, showToast, sortTable, initSortableTable, resetSorting } from './common.js';
+import { reloadDNS, reloadDHCP } from './services.js';
+
+// -----------------------------
+// State variables
+// -----------------------------
+const sortState = { sortDirection: {}, lastSort: null };
+
+// -----------------------------
+// Load all leases into the table
+// -----------------------------
+async function loadLeases() {
+ let leases = [];
+ const loader = document.getElementById("loader");
+ const container = document.getElementById("devices-container");
+ const dataTable = document.getElementById("dataTable");
+
+ // hide table during loading to avoid flickering and show loader
+ dataTable.classList.add("d-none");
+
+ try {
+ // Show loader
+ loader.style.display = "block";
+
+ // Fetch data
+ const res = await fetch(`/api/dhcp/leases`, {
+ headers: { Accept: 'application/json' },
+ });
+
+ // Check content-type to avoid parsing errors
+ const contentType = res.headers.get("content-type") || "";
+ if (!contentType.includes("application/json")) {
+ const err = new Error(`${res.status}: ${res.statusText}`);
+ err.status = res.status;
+ throw err;
+ }
+
+ // Check JSON
+ let data;
+ try {
+ data = await res.json();
+ leases = Array.isArray(data) ? data : (Array.isArray(data?.data) ? data.data : []);
+
+ } catch {
+ throw new Error('Invalid JSON payload');
+ }
+
+ // Check JSON errors
+ if (!res.ok) {
+ const serverMsg = data?.detail?.message?.trim();
+ const base = `Error loading leases`;
+ const err = new Error(serverMsg ? `${base}: ${serverMsg}` : base);
+ err.status = res.status;
+ throw err;
+ }
+
+ } catch (err) {
+ console.error(err?.message || "Error loading leases");
+ showToast(err?.message || "Error loading leases", false);
+ leases = [];
+ // hide loader and show table
+ loader.style.display = "none";
+ dataTable.classList.remove("d-none");
+ }
+
+ // DOM Reference
+ const tbody = document.querySelector("#dataTable tbody");
+ if (!tbody) {
+ console.warn('Element "#dataTable tbody" not found in DOM.');
+ return;
+ }
+
+ // Svuota la tabella
+ tbody.innerHTML = "";
+
+ // if no leases, show an empty row
+ if (!leases.length) {
+ const trEmpty = document.createElement("tr");
+ const tdEmpty = document.createElement("td");
+ tdEmpty.colSpan = 7;
+ tdEmpty.textContent = "No leases available.";
+ tdEmpty.style.textAlign = "center";
+ trEmpty.appendChild(tdEmpty);
+ tbody.appendChild(trEmpty);
+ return;
+ }
+
+ // fragment per performance
+ const frag = document.createDocumentFragment();
+
+ leases.forEach(l => {
+ const tr = document.createElement("tr");
+
+ // IP Address
+ {
+ const td = document.createElement("td");
+ const raw = (l.ipv4 ?? "").toString().trim();
+ td.textContent = raw;
+ if (raw) td.setAttribute("data-value", raw);
+ tr.appendChild(td);
+ }
+
+ // MAC
+ {
+ const td = document.createElement("td");
+ const raw = (l.mac ?? "").toString().trim();
+ td.textContent = raw;
+ const norm = raw.toLowerCase().replace(/[\s:\-\.]/g, "");
+ if (norm) td.setAttribute("data-value", norm);
+ tr.appendChild(td);
+ }
+
+ // Hostname
+ {
+ const td = document.createElement("td");
+ const val = (l.name ?? "").toString();
+ td.textContent = val;
+ if (val) td.setAttribute("data-value", val.toLowerCase());
+ tr.appendChild(td);
+ }
+
+ // Start lease
+ {
+ const td = document.createElement("td");
+ let val = "";
+
+ if (l.expire && l.valid_lifetime) {
+ const expireDate = new Date(
+ l.expire.endsWith("Z") ? l.expire : l.expire + "Z"
+ );
+ if (!isNaN(expireDate.getTime())) {
+ const startEpoch =
+ Math.floor(expireDate.getTime() / 1000) - Number(l.valid_lifetime);
+
+ const startDate = new Date(startEpoch * 1000);
+ val = startDate.toISOString().replace("T", " ").slice(0, 19);
+ }
+ }
+ td.textContent = val;
+ if (val) td.setAttribute("data-value", val);
+ tr.appendChild(td);
+ }
+
+ // End lease
+ {
+ const td = document.createElement("td");
+ let val = "";
+ if (l.expire) {
+ const expireDate = new Date(
+ l.expire.endsWith("Z") ? l.expire : l.expire + "Z"
+ );
+ if (!isNaN(expireDate.getTime())) {
+ val = expireDate.toISOString().replace("T", " ").slice(0, 19);
+ }
+ }
+ td.textContent = val;
+ if (val) td.setAttribute("data-value", val);
+ tr.appendChild(td);
+ }
+
+ // State Icon
+ {
+ const td = document.createElement("td");
+ td.style.textAlign = "center";
+ td.style.verticalAlign = "middle";
+
+ const val = (l.dhcp_state ?? "").toString();
+ let aria = "";
+ let iconClass = "";
+ switch (val) {
+ case "active":
+ // DHCP active lease
+ aria = "DHCP lease is active";
+ iconClass = "bi bi-check-circle-fill";
+ break;
+
+ case "expired":
+ // DHCP expired lease
+ aria = "DHCP lease is expired";
+ iconClass = "bi bi-clock-history";
+ break;
+
+ case "released":
+ // DHCP released lease
+ aria = "DHCP lease is released";
+ iconClass = "bi bi-box-arrow-in-right";
+ break;
+
+ case "declined":
+ // DHCP declined lease
+ aria = "DHCP lease is declined";
+ iconClass = "bi bi-x-octagon-fill";
+ break;
+ }
+ if (iconClass) {
+ const icon = document.createElement("i");
+ icon.className = iconClass + " icon icon-static";
+ icon.setAttribute("aria-hidden", "true");
+ icon.setAttribute("title", aria);
+ td.appendChild(icon);
+ }
+
+ tr.appendChild(td);
+ }
+
+ // Actions
+ {
+ const td = document.createElement("td");
+ td.className = "actions";
+ td.style.textAlign = "center";
+ td.style.verticalAlign = "middle";
+
+ const id = Number(l.id);
+
+ // Usa elementi reali invece di innerHTML con entity
+ const editSpan = document.createElement("span");
+ editSpan.className = "action-icon";
+ editSpan.setAttribute("role", "button");
+ editSpan.tabIndex = 0;
+ editSpan.title = "Add static lease";
+ editSpan.setAttribute("aria-label", "Add static lease");
+ editSpan.setAttribute("data-bs-toggle", "modal");
+ editSpan.setAttribute("data-bs-target", "#addHostModal");
+ editSpan.setAttribute("data-action", "add");
+ editSpan.setAttribute("data-lease-id", String(id));
+ {
+ const i = document.createElement("i");
+ i.className = "bi bi-plus-circle icon icon-action";
+ i.setAttribute("aria-hidden", "true");
+ editSpan.appendChild(i);
+ }
+
+ const delSpan = document.createElement("span");
+ delSpan.className = "action-icon";
+ delSpan.setAttribute("role", "button");
+ delSpan.tabIndex = 0;
+ delSpan.title = "Delete lease";
+ delSpan.setAttribute("aria-label", "Delete lease");
+ delSpan.setAttribute("data-action", "delete");
+ delSpan.setAttribute("data-lease-id", String(id));
+ {
+ const i = document.createElement("i");
+ i.className = "bi bi-trash-fill icon icon-action";
+ i.setAttribute("aria-hidden", "true");
+ delSpan.appendChild(i);
+ }
+
+ td.appendChild(editSpan);
+ td.appendChild(delSpan);
+ tr.appendChild(td);
+ }
+
+ frag.appendChild(tr);
+ });
+
+ // publish all rows
+ tbody.appendChild(frag);
+
+ // apply last sorting
+ if (typeof lastSort === "object" && lastSort && Array.isArray(sortDirection)) {
+ if (Number.isInteger(lastSort.colIndex)) {
+ sortDirection[lastSort.colIndex] = !lastSort.ascending;
+ sortTable(lastSort.colIndex, sortState);
+ }
+ }
+
+ // hide loader and show table
+ loader.style.display = "none";
+ dataTable.classList.remove("d-none");
+}
+
+// -----------------------------
+// Add Host: load data and pre-fill the form
+// -----------------------------
+async function addHost(id) {
+ // Clear form first
+ clearAddHostForm();
+
+ // Fetch lease
+ const res = await fetch(`/api/dhcp/leases/${id}`, {
+ headers: { Accept: 'application/json' },
+ });
+
+ // Check content-type to avoid parsing errors
+ const contentType = res.headers.get("content-type") || "";
+ if (!contentType.includes("application/json")) {
+ const err = new Error(`Fetch failed for lease ${id}: ${res.statusText}`);
+ err.status = res.status;
+ throw err;
+ }
+
+ // Check JSON
+ let data;
+ try {
+ data = await res.json();
+ } catch {
+ throw new Error(`Fetch failed for lease ${id}: Invalid JSON payload`);
+ }
+
+ // Check JSON errors
+ if (!res.ok) {
+ const serverMsg = data?.detail?.message?.trim();
+ const base = `Fetch failed for lease ${id}`;
+ const err = new Error(serverMsg ? `${base}: ${serverMsg}` : base);
+ err.status = res.status;
+ throw err;
+ }
+
+ // Pre-fill the form fields
+ document.getElementById("hostName").value = data.name ?? "";
+ document.getElementById("hostIPv4").value = data.ipv4 ?? "";
+ document.getElementById("hostIPv6").value = data.ipv6 ?? "";
+ document.getElementById("hostMAC").value = data.mac ?? "";
+ document.getElementById("hostDescription").value = data.description ?? "";
+ document.getElementById("hostSSL").checked = !!data.ssl_enabled;
+ if (data.visibility == 2) {
+ document.getElementById("hostVisibilityAlias").checked = true;
+ } else if (data.visibility == 1){
+ document.getElementById("hostVisibilityGlobal").checked = true;
+ } else {
+ document.getElementById("hostVisibilityLocal").checked = true;
+ }
+}
+
+// -----------------------------
+// Save host (CREATE OR UPDATE)
+// -----------------------------
+async function saveHost(hostData) {
+ // Validate hostname
+ if (!hostData.name.trim()) {
+ showToast("Hostname is required", false);
+ return false;
+ }
+ // Validate IPv4 format
+ if (!isValidIPv4(hostData.ipv4)) {
+ showToast("Invalid IPv4 format", false);
+ return false;
+ }
+ // Validate IPv6 format
+ if (!isValidIPv6(hostData.ipv6)) {
+ showToast("Invalid IPv6 format", false);
+ return false;
+ }
+ // Validate MAC format
+ if (!isValidMAC(hostData.mac)) {
+ showToast("Invalid MAC format", false);
+ return false;
+ }
+
+ // Create new host
+ const res = await fetch(`/api/hosts`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(hostData)
+ });
+
+ // Success without JSON
+ if (res.status === 204) {
+ showToast('Host created successfully', true);
+ return true;
+ }
+
+ // Check content-type to avoid parsing errors
+ const contentType = res.headers.get("content-type") || "";
+ if (!contentType.includes("application/json")) {
+ const err = new Error(`${res.status}: ${res.statusText}`);
+ err.status = res.status;
+ throw err;
+ }
+
+ // Check JSON
+ let data;
+ try {
+ data = await res.json();
+ } catch {
+ throw new Error('Invalid JSON payload');
+ }
+
+ // Check JSON errors
+ if (!res.ok) {
+ const serverMsg = data?.detail?.message?.trim();
+ const base = `Error adding host`;
+ const err = new Error(serverMsg ? `${base}: ${serverMsg}` : base);
+ err.status = res.status;
+ throw err;
+ }
+
+ // Success
+ showToast(data?.message || 'Host created successfully', true);
+ return true
+}
+// -----------------------------
+// Prepare add host form
+// -----------------------------
+function clearAddHostForm() {
+ // reset form fields
+ document.getElementById('addHostForm')?.reset();
+}
+
+// -----------------------------
+// Close popup
+// -----------------------------
+async function closeAddHostModal() {
+ const modalEl = document.getElementById('addHostModal');
+ const modal = bootstrap.Modal.getInstance(modalEl)
+ || bootstrap.Modal.getOrCreateInstance(modalEl);
+ modal.hide();
+}
+
+// -----------------------------
+// Handle Add host form submit
+// -----------------------------
+async function handleAddHostSubmit(e) {
+ // Prevent default form submission
+ e.preventDefault();
+
+ try {
+ // Retrieve form data
+ const data = {
+ name: document.getElementById('hostName').value.trim(),
+ ipv4: document.getElementById('hostIPv4').value.trim(),
+ ipv6: document.getElementById('hostIPv6').value.trim(),
+ mac: document.getElementById('hostMAC').value.trim(),
+ description: document.getElementById('hostDescription').value.trim(),
+ ssl_enabled: document.getElementById('hostSSL').checked ? 1 : 0,
+ visibility: Number(
+ document.querySelector('input[name="hostVisibility"]:checked')?.value ?? 0
+ )
+ };
+
+ const ok = await saveHost(data);
+ if (ok !== false) {
+ // close modal and reload hosts
+ closeAddHostModal();
+ await loadLeases();
+ return true
+ }
+
+ } catch (err) {
+ console.error(err?.message || "Error saving host");
+ showToast(err?.message || "Error saving host", false);
+ }
+
+ return false;
+}
+
+// -----------------------------
+// Handle delete lease action
+// -----------------------------
+async function handleDeleteLease(e, el) {
+ // Prevent default action
+ e.preventDefault();
+
+ // Get lease ID
+ const id = Number(el.dataset.leaseId);
+ if (!Number.isFinite(id)) {
+ console.warn('Delete: lease id not valid for delete:', id);
+ showToast('Lease id not valid for delete', false);
+ return;
+ }
+
+ // Execute delete
+ try {
+ // Fetch data
+ const res = await fetch(`/api/dhcp/leases/${id}`, {
+ method: 'DELETE',
+ headers: { 'Accept': 'application/json' },
+ });
+
+ // Check content-type to avoid parsing errors
+ const contentType = res.headers.get("content-type") || "";
+ if (!contentType.includes("application/json")) {
+ const err = new Error(`${res.status}: ${res.statusText}`);
+ err.status = res.status;
+ throw err;
+ }
+
+ // Check JSON
+ let data;
+ try {
+ data = await res.json();
+ } catch {
+ throw new Error('Invalid JSON payload');
+ }
+
+ // Check JSON errors
+ if (!res.ok) {
+ const serverMsg = data?.detail?.message?.trim();
+ const base = `Error deleting lease`;
+ const err = new Error(serverMsg ? `${base}: ${serverMsg}` : base);
+ err.status = res.status;
+ throw err;
+ }
+
+ // Success
+ showToast(data?.message || 'Lease deleted successfully', true);
+
+ // Reload leases
+ await loadLeases();
+ return true;
+
+ } catch (err) {
+ console.error(err?.message || "Error deleting lease");
+ showToast(err?.message || "Error deleting lease", false);
+ }
+
+ return false;
+}
+
+// -----------------------------
+// filter dhcp leases in the table
+// -----------------------------
+function filterLeases() {
+ const query = document.getElementById("searchInput").value.toLowerCase();
+ const rows = document.querySelectorAll("#dataTable tbody tr");
+
+ rows.forEach(row => {
+ const text = row.textContent.toLowerCase();
+ row.style.display = text.includes(query) ? "" : "none";
+ });
+}
+
+// -----------------------------
+// Clear search on ESC key
+// -----------------------------
+async function clearSearch() {
+ const input = document.getElementById("searchInput");
+ input.value = "";
+ input.blur();
+ await loadLeases();
+}
+
+// -----------------------------
+// Action Handlers
+// -----------------------------
+const actionHandlers = {
+ // Delete lease
+ delete: (e, el) => {
+ handleDeleteLease(e, el);
+ },
+ // Add static lease
+ add: () => {
+ // handled by bootstrap modal show event
+ },
+ // Reload DNS
+ reloadDns: async () => {
+ try {
+ const result = await reloadDNS();
+ const msg = (typeof result === 'object' && result?.message)
+ ? result.message
+ : 'DNS reload successfully';
+ showToast(msg, true);
+ } catch (err) {
+ showToast(err?.message || "Error reloading DNS", false);
+ }
+ },
+ // Reload DHCP
+ reloadDhcp: async () => {
+ try {
+ const result = await reloadDHCP();
+ const msg = (typeof result === 'object' && result?.message)
+ ? result.message
+ : 'DHCP reload successfully';
+ showToast(msg, true);
+ } catch (err) {
+ showToast(err?.message || "Error reloading DHCP", false);
+ }
+ },
+};
+
+// -----------------------------
+// DOMContentLoaded: initialize everything
+// -----------------------------
+document.addEventListener("DOMContentLoaded", async () => {
+
+ // Init UI sort (aria-sort, arrows)
+ initSortableTable();
+
+ // Load data (leases)
+ try {
+ await loadLeases();
+ } catch (err) {
+ console.error(err?.message || "Error loading dhcp leases");
+ showToast(err?.message || "Error loading dhcp leases", false);
+ }
+
+ // search bar
+ const input = document.getElementById("searchInput");
+ if (input) {
+ // clean input on load
+ input.value = "";
+ // live filter for each keystroke
+ input.addEventListener("input", filterLeases);
+ // Escape management when focus is in the input
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") {
+ e.preventDefault(); // evita side-effect (es. chiusure di modali del browser)
+ e.stopPropagation(); // evita che arrivi al listener globale
+ resetSorting(sortState);
+ clearSearch(); // svuota input e ricarica tabella (come definito nella tua funzione)
+ filterLeases(''); // ripristina tabella
+ }
+ });
+ }
+
+ // global ESC key listener to clear search and reset sorting
+ document.addEventListener("keydown", (e) => {
+ // Ignore if focus is in a typing field
+ const tag = (e.target.tagName || "").toLowerCase();
+ const isTypingField =
+ tag === "input" || tag === "textarea" || tag === "select" || e.target.isContentEditable;
+
+ if (e.key === "Escape" && !isTypingField) {
+ // Prevent default form submission
+ e.preventDefault();
+ resetSorting(sortState);
+ clearSearch();
+ filterLeases('');
+ }
+ });
+
+ // Modal show/hidden events to prepare/reset the form
+ const modalEl = document.getElementById('addHostModal');
+ if (modalEl) {
+
+ // store who opened the modal
+ let lastTriggerEl = null;
+
+ // When shown, determine Add or Edit mode
+ modalEl.addEventListener('show.bs.modal', async (ev) => {
+ lastTriggerEl = ev.relatedTarget; // trigger (Add o Edit)
+ const formEl = document.getElementById('addHostForm');
+
+ // Security check
+ if (!formEl) return;
+
+ // check Add or Edit mode
+ const idAttr = lastTriggerEl?.getAttribute?.('data-lease-id');
+ const id = idAttr ? Number(idAttr) : null;
+
+ try {
+ await addHost(id);
+ } catch (err) {
+ showToast(err?.message || "Error loading host for edit", false);
+ // Close modal
+ const closeOnShown = () => {
+ closeAddHostModal(lastTriggerEl);
+ modalEl.removeEventListener('shown.bs.modal', closeOnShown);
+ };
+ modalEl.addEventListener('shown.bs.modal', closeOnShown);
+ }
+ });
+
+ // When hiding, restore focus to the trigger element
+ modalEl.addEventListener('hide.bs.modal', () => {
+ const active = document.activeElement;
+ if (active && modalEl.contains(active)) {
+ if (lastTriggerEl && typeof lastTriggerEl.focus === 'function') {
+ lastTriggerEl.focus({ preventScroll: true });
+ } else {
+ active.blur();
+ }
+ }
+ });
+
+ // When hidden, reset the form
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ // reset form fields
+ clearAddHostForm();
+ // pulizia ref del trigger
+ lastTriggerEl = null;
+ });
+ }
+
+ // Button event delegation (click)
+ document.addEventListener('click', async (e) => {
+ const el = e.target.closest('[data-action]');
+ if (!el) return;
+
+ const action = el.dataset.action;
+ const handler = actionHandlers[action];
+ if (!handler) return;
+
+ // Execute handler
+ try {
+ await handler(e, el);
+ } catch (err) {
+ console.error(err?.message || 'Action error');
+ showToast(err?.message || 'Action error', false);
+ }
+ });
+
+ // Button event delegation (Enter, Space)
+ document.addEventListener('keydown', async (e) => {
+ const isEnter = e.key === 'Enter';
+ const isSpace = e.key === ' ' || e.key === 'Spacebar';
+ if (!isEnter && !isSpace) return;
+
+ const el = e.target.closest('[data-action]');
+ if (!el) return;
+
+ // Space/Enter
+ if (el.tagName === 'BUTTON') return;
+ // Trigger click event
+ el.click();
+ });
+
+ // Submit Form
+ const form = document.getElementById('addHostForm');
+ if (form) {
+ form.addEventListener('submit', handleAddHostSubmit);
+ }
+
+ // Submit Sort
+ const headers = document.querySelectorAll('thead th');
+ headers.forEach((th) => {
+ if (th.dataset.sortable === 'false') return;
+ th.addEventListener('click', () => sortTable(th.cellIndex, sortState));
+ });
+});
--- /dev/null
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>Network Manager</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+
+ <!-- Bootstrap 5.x CSS (CDN) -->
+ <link
+ href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
+ rel="stylesheet"
+ integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
+ crossorigin="anonymous"
+ >
+ <!-- Bootstrap Icons -->
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
+
+ <!-- Boostrap override -->
+ <link rel="stylesheet" href="css/variables.css">
+ <link rel="stylesheet" href="css/layout.css">
+</head>
+
+<body>
+ <!-- Topbar -->
+ <header class="topbar">
+ <div class="topbar-inner">
+ <a href="/home" class="logo text-decoration-none">
+ <svg width="30" height="30" viewBox="0 0 24 24" fill="var(--accent)" aria-hidden="true">
+ <circle cx="12" cy="4" r="2"></circle>
+ <circle cx="4" cy="12" r="2"></circle>
+ <circle cx="20" cy="12" r="2"></circle>
+ <circle cx="12" cy="20" r="2"></circle>
+ <line x1="12" y1="6" x2="12" y2="18" stroke="var(--accent)" stroke-width="2"></line>
+ <line x1="6" y1="12" x2="18" y2="12" stroke="var(--accent)" stroke-width="2"></line>
+ </svg>
+ <span>Network Manager</span>
+ </a>
+
+ <!-- Spacer -->
+ <div class="col d-none d-md-block"></div>
+
+ <nav class="navbar navbar-expand-md px-3">
+ <!-- Bottone hamburger -->
+ <button class="navbar-toggler bg-light" type="button" data-bs-toggle="collapse" data-bs-target="#menuNav">
+ <span class="navbar-toggler-icon"></span>
+ </button>
+ <!-- Menu -->
+ <div class="collapse navbar-collapse" id="menuNav">
+ <div class="navbar-nav ms-auto gap-2">
+ <a href="/hosts" id="hostsBtn" class="btn btn-primary" aria-current="page">Hostname</a>
+ <a href="/aliases" id="aliasesBtn" class="btn btn-primary" aria-current="page">Alias</a>
+ <a href="/leases" id="leasesBtn" class="btn btn-primary active" aria-current="page">DHCP Leases</a>
+ <a href="/devices" id="devicesBtn" class="btn btn-primary" aria-current="page">Devices</a>
+ <button id="logoutBtn" class="btn btn-primary">Logout</button>
+ </div>
+ </div>
+ </nav>
+ </div>
+ </header>
+
+ <!-- Toast -->
+ <div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
+
+ <!-- Toolbar / Section header -->
+ <section class="page-frame">
+ <div class="container-fluid p-0">
+ <div class="row g-2 align-items-center">
+ <!-- Title -->
+ <div class="col-12 col-md-auto">
+ <h2 class="mb-0 d-flex align-items-center gap-2 lh-1">
+ <span class="title-icon">🖧</span>
+ <span class="section-title">DHCP Leases</span>
+ </h2>
+ </div>
+
+ <!-- Spacer -->
+ <div class="col d-none d-md-block"></div>
+
+ <!-- Search -->
+ <div class="col-12 col-md-auto">
+ <div class="search-wrapper">
+ <input
+ type="text"
+ id="searchInput"
+ placeholder="Search..."
+ class="form-control form-control-sm placeholder-italic"
+ aria-label="Search hosts"
+ >
+ </div>
+ </div>
+
+ <!-- Bottoni -->
+ <div class="col-12 col-md-auto d-flex gap-2 flex-wrap">
+ <button class="btn btn-primary" title="Reload DNS (BIND)" aria-label="Reload DNS"
+ data-action="reloadDns">
+ <i class="bi bi-arrow-repeat"></i><span class="label"> Reload DNS</span>
+ </button>
+ <button class="btn btn-primary" title="Reload DHCP (Kea)" aria-label="Reload DHCP"
+ data-action="reloadDhcp">
+ <i class="bi bi-arrow-repeat"></i><span class="label"> Reload DHCP</span>
+ </button>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <!-- Tabella -->
+ <table id="dataTable" class="table table-bordered table-hover align-middle d-none">
+ <thead class="table-light">
+ <tr>
+ <th data-type="ipv4" data-sortable="true">IP Address<span class="sort-arrow" aria-hidden="true"></span></th>
+ <th data-type="mac" data-sortable="true">MAC <span class="sort-arrow" aria-hidden="true"></span></th>
+ <th data-type="string" data-sortable="true">Hostname <span class="sort-arrow" aria-hidden="true"></span></th>
+ <th data-type="string" data-sortable="true">Start <span class="sort-arrow" aria-hidden="true"></span></th>
+ <th data-type="string" data-sortable="true">End <span class="sort-arrow" aria-hidden="true"></span></th>
+ <th data-type="string" data-sortable="true">State <span class="sort-arrow" aria-hidden="true"></span></th>
+ <th data-type="string" data-sortable="false">Actions <span class="sort-arrow" aria-hidden="true"></span></th>
+ </tr>
+ </thead>
+ <tbody></tbody>
+ </table>
+
+ <!-- Loader -->
+ <div id="loader" class="text-center my-3" style="display: none;">
+ <div class="spinner-border text-primary" role="status">
+ <span class="visually-hidden">Loading...</span>
+ </div>
+ </div>
+ <div id="devices-container"></div>
+
+ <!-- AddHost -->
+ <div class="modal fade" id="addHostModal" tabindex="-1" aria-labelledby="addHostTitle" aria-hidden="true">
+ <div class="modal-dialog modal-dialog-centered"><!-- modal-sm|md|lg se vuoi cambiare -->
+ <div class="modal-content addhost-modal">
+ <!-- Header scuro con logo/brand -->
+ <div class="modal-header addhost-header">
+ <div class="d-flex align-items-center gap-2">
+ <!-- Emoji o icona -->
+ <span class="title-icon" aria-hidden="true">🖧</span>
+ <h5 class="modal-title mb-0" id="addHostTitle">Aggiungi Host</h5>
+ </div>
+ <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Chiudi"></button>
+ </div>
+
+ <div class="modal-body">
+ <form id="addHostForm">
+ <div class="mb-2">
+ <label for="hostName" class="form-label">Hostname</label>
+ <input type="text" id="hostName" class="form-control" required>
+ </div>
+
+ <div class="mb-2">
+ <label for="hostIPv4" class="form-label">IPv4</label>
+ <input type="text" id="hostIPv4" class="form-control" inputmode="decimal" placeholder="es. 192.168.1.10">
+ </div>
+
+ <div class="mb-2">
+ <label for="hostIPv6" class="form-label">IPv6</label>
+ <input type="text" id="hostIPv6" class="form-control" placeholder="es. fe80::1">
+ </div>
+
+ <div class="mb-2">
+ <label for="hostMAC" class="form-label">MAC Address</label>
+ <input type="text" id="hostMAC" class="form-control" placeholder="es. AA:BB:CC:DD:EE:FF">
+ </div>
+
+ <div class="mb-2">
+ <label for="hostDescription" class="form-label">Description</label>
+ <input type="text" id="hostDescription" class="form-control">
+ </div>
+
+ <div class="form-check my-2">
+ <input class="form-check-input" type="checkbox" id="hostSSL">
+ <label class="form-check-label" for="hostSSL">SSL?</label>
+ </div>
+
+ <div class="mb-2">
+ <label class="form-label d-block">Visibility</label>
+ <div class="btn-group" role="group">
+ <!-- Local -->
+ <input type="radio" class="btn-check" id="hostVisibilityLocal" name="hostVisibility" value="0" checked>
+ <label class="btn btn-outline-primary" for="hostVisibilityLocal">Local</label>
+ <!-- Global -->
+ <input type="radio" class="btn-check" id="hostVisibilityGlobal" name="hostVisibility" value="1">
+ <label class="btn btn-outline-primary" for="hostVisibilityGlobal">Global</label>
+ <!-- Alias -->
+ <input type="radio" class="btn-check" id="hostVisibilityAlias" name="hostVisibility" value="2">
+ <label class="btn btn-outline-primary" for="hostVisibilityAlias">Alias</label>
+ </div>
+ </div>
+ </form>
+ </div>
+
+ <div class="modal-footer">
+ <button type="submit" form="addHostForm" class="btn btn-primary">
+ <i class="bi bi-check2"></i>
+ </button>
+ <button type="button" class="btn btn-primary" data-bs-dismiss="modal">
+ <i class="bi bi-x"></i>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Scripts -->
+ <script type="module" src="js/leases.js"></script>
+ <script type="module" src="js/session.js"></script>
+
+ <!-- Bootstrap JS -->
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
+ integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
+ crossorigin="anonymous"></script>
+</body>
+</html>