]> git.giorgioravera.it Git - network-manager.git/commitdiff
Added leases page
authorGiorgio Ravera <giorgio.ravera@gmail.com>
Fri, 22 May 2026 15:22:35 +0000 (17:22 +0200)
committerGiorgio Ravera <giorgio.ravera@gmail.com>
Fri, 22 May 2026 15:22:35 +0000 (17:22 +0200)
backend/db/leases.py [new file with mode: 0644]
backend/routes/dhcp.py
frontend/aliases.html
frontend/hosts.html
frontend/index.html
frontend/js/leases.js [new file with mode: 0644]
frontend/leases.html [new file with mode: 0644]

diff --git a/backend/db/leases.py b/backend/db/leases.py
new file mode 100644 (file)
index 0000000..cc3b10b
--- /dev/null
@@ -0,0 +1,151 @@
+# 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)
index 08e561d1da758974987caa3f47880e71d57ad400..feb57544c715874cfd040e06cfe1473a2e940fc5 100644 (file)
@@ -3,16 +3,13 @@
 # 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
@@ -24,6 +21,19 @@ logger = get_logger(__name__)
 # 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
 # ---------------------------------------------------------
@@ -109,90 +119,171 @@ async def api_dhcp_reload(request: Request):
 })
 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,
+                },
             },
         )
index 5142595909ca3460e576489f6677909980c7b88d..c0acb7763c4699a2880621772e4f228ad2f019f2 100644 (file)
@@ -49,6 +49,7 @@
                     <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>
index e827d4e01b9eb6d01cc70eace6d6e96a7032a2ec..658bf0d16731ef4b5b47b43dd88ab38a605e7fbd 100644 (file)
@@ -49,6 +49,7 @@
                     <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>
index 8cea44108c4451c986e4d3ff122968829d6bc295..8692e32a24c5ad7db811f04035650dee26c9a204 100644 (file)
                 <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>
diff --git a/frontend/js/leases.js b/frontend/js/leases.js
new file mode 100644 (file)
index 0000000..52166a2
--- /dev/null
@@ -0,0 +1,721 @@
+// 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));
+    });
+});
diff --git a/frontend/leases.html b/frontend/leases.html
new file mode 100644 (file)
index 0000000..8b228b7
--- /dev/null
@@ -0,0 +1,215 @@
+<!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>