]> git.giorgioravera.it Git - network-manager.git/commitdiff
Added devices list
authorGiorgio Ravera <giorgio.ravera@gmail.com>
Fri, 22 May 2026 16:19:22 +0000 (18:19 +0200)
committerGiorgio Ravera <giorgio.ravera@gmail.com>
Fri, 22 May 2026 16:19:22 +0000 (18:19 +0200)
13 files changed:
Dockerfile
README.md
backend/app.py
backend/bootstrap.py
backend/db/hosts.py
backend/routes/devices.py [new file with mode: 0644]
backend/settings/default.py
backend/settings/settings.py
frontend/aliases.html
frontend/devices.html [new file with mode: 0644]
frontend/hosts.html
frontend/index.html
frontend/js/devices.js [new file with mode: 0644]

index c31cacb977f8694a69c23306ccd7006b3f152382..6533edb9078c1dd52d18fdfa5ee921dbd633e995 100644 (file)
@@ -24,7 +24,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
     LANG=C.UTF-8
 
 # librerie runtime
-RUN apk add --no-cache libffi openssl sqlite-libs
+RUN apk add --no-cache libffi openssl sqlite-libs iputils
 
 WORKDIR /app
 
index 118188efbf67110a8732502e4817e7c78848b915..9c6fd286a86e76a528d7eea64afde394f37a5cac 100644 (file)
--- a/README.md
+++ b/README.md
@@ -151,6 +151,7 @@ secrets:
 | `DHCP4_LEASES_FILE` | /dhcp/lib/dhcp4.leases | KEA-DHCP4 leases file |
 | `DHCP6_HOST_FILE` | /dhcp/etc/hosts-ipv6.json | KEA-DHCP6 Hosts file |
 | `DHCP6_LEASES_FILE` | /dhcp/lib/dhcp6.leases | KEA-DHCP6 leases file |
+| `PING_WORKERS` | 25 | Number of threads used for pinging |
 
 ---
 
index b184faad574fac4a71048cdf218f49404d466dc5..c169fce44c02e231d686ba8e9662bbb526f31618 100644 (file)
@@ -11,6 +11,7 @@ import os
 
 # Import Routers
 from backend.routes.about import router as about_router
+from backend.routes.devices import router as devices_router
 from backend.routes.backup import router as backup_router
 from backend.routes.certificates import router as certificates_router
 from backend.routes.health import router as health_router
@@ -205,6 +206,7 @@ def create_app() -> FastAPI:
     # Routers
     app.include_router(about_router)
     app.include_router(backup_router)
+    app.include_router(devices_router)
     app.include_router(certificates_router)
     app.include_router(health_router)
     app.include_router(login_router)
index 567740c5a475fb1f41997bf3fd2867944c488ac6..379b8e16c51d4a56532304435b90ae355439ed3a 100644 (file)
@@ -52,6 +52,10 @@ def print_welcome(logger):
         "DHCP: ipv4 host file=%s | ipv4 leases file=%s | ipv6 host file=%s | ipv6 leases file=%s",
         settings.DHCP4_HOST_FILE, settings.DHCP4_LEASES_FILE, settings.DHCP6_HOST_FILE, settings.DHCP6_LEASES_FILE
     )
+    logger.info(
+        "App features: ping_workers=%d",
+        settings.PING_WORKERS
+    )
 
 # ------------------------------------------------------------------------------
 # Shutdown log
index a9ff3aebd870e63930e3929d93890fe54e563223..bb07eabac3bd6452b78205e82f6e9926ae54189d 100644 (file)
@@ -87,10 +87,19 @@ def ipv4_sort_key(h: Dict[str, Any]):
 # -----------------------------
 # SELECT ALL HOSTS
 # -----------------------------
-def get_hosts() -> List[Dict[str, Any]]:
+def get_hosts(filter_devices: bool = False) -> List[Dict[str, Any]]:
     conn = get_db()
-    cur = conn.execute("SELECT * FROM hosts")
-    rows = [dict(r) for r in cur.fetchall()]
+    if (filter_devices != True):
+        cur = conn.execute("SELECT * FROM hosts")
+    else:
+        cur = conn.execute("SELECT id, ipv4, mac, name, description FROM hosts WHERE ipv4 IS NOT NULL AND mac IS NOT NULL")
+
+    rows = []
+    for r in cur.fetchall():
+        item = dict(r)
+        if (filter_devices == True):
+            item["id"] = f"s-{item['id']}"
+        rows.append(item)
     rows.sort(key=ipv4_sort_key)
     return rows
 
diff --git a/backend/routes/devices.py b/backend/routes/devices.py
new file mode 100644 (file)
index 0000000..d9fd93b
--- /dev/null
@@ -0,0 +1,78 @@
+# backend/routes/hosts.py
+
+# import standard modules
+from concurrent.futures import ThreadPoolExecutor
+from fastapi import APIRouter, Request, Response, HTTPException, status
+from fastapi.responses import FileResponse
+import ipaddress
+import time
+import os
+
+# Import local modules
+from backend.db.hosts import get_hosts
+from backend.db.leases import get_leases
+
+# Import Settings & Logging
+from backend.settings.settings import settings
+from backend.log.log import get_logger
+
+from backend.utils import is_host_active
+
+# Logger initialization
+logger = get_logger(__name__)
+
+# Create Router
+router = APIRouter()
+
+# ---------------------------------------------------------
+# FRONTEND PATHS (absolute paths inside Docker)
+# ---------------------------------------------------------
+# Devices page
+@router.get("/devices")
+def devices(request: Request):
+    return FileResponse(os.path.join(settings.FRONTEND_DIR, "devices.html"))
+
+# Serve devices.js
+@router.get("/js/devices.js")
+def js_devices():
+    return FileResponse(os.path.join(settings.FRONTEND_DIR, "js/devices.js"))
+
+# ---------------------------------------------------------
+# Get Devices
+# ---------------------------------------------------------
+@router.get("/api/devices", status_code=status.HTTP_200_OK, responses={
+    200: {"description": "Devices found"},
+    500: {"description": "Internal server error"},
+})
+def api_get_devices(request: Request):
+
+    try:
+        hosts = get_hosts(filter_devices=True)
+        with ThreadPoolExecutor(max_workers=settings.PING_WORKERS) as executor:
+            futures = [executor.submit(is_host_active, host["ipv4"]) for host in hosts]
+            for i, future in enumerate(futures):
+                hosts[i]["dhcp_state"] = "static"
+                hosts[i]["active"] = future.result()
+
+        leases = get_leases(filter_devices=True)
+        with ThreadPoolExecutor(max_workers=settings.PING_WORKERS) as executor:
+            futures = [executor.submit(is_host_active, lease["ipv4"]) for lease in leases]
+            for i, future in enumerate(futures):
+                leases[i]["description"] = None
+                leases[i]["active"] = future.result()
+
+        return hosts+leases or []
+
+    except HTTPException:
+        raise
+
+    except Exception as err:
+        logger.exception("Error getting list devices %s", str(err).strip())
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail={
+                "code": "DEVICES_GET_ERROR",
+                "status": "failure",
+                "message": "Internal error getting devices",
+            },
+        )
index 08f5cdeebe657dffbd84c54c6be12d39eea01fbd..d163aca16408c6f269133f47df1e723109df2b49 100644 (file)
@@ -59,3 +59,8 @@ DHCP4_HOST_FILE="/dhcp/etc/hosts-ipv4.json"
 DHCP4_LEASES_FILE="/dhcp/lib/dhcp4.leases"
 DHCP6_HOST_FILE="/dhcp/etc/hosts-ipv6.json"
 DHCP6_LEASES_FILE="/dhcp/lib/dhcp6.leases"
+
+# ---------------------------------------------------------
+# APP Features
+# ---------------------------------------------------------
+PING_WORKERS = 25
\ No newline at end of file
index aacb5daa183e7c42c3379d79ff99b1375cf3befb..e3a6e8ac4ba6cdc82bf8e312a56b01b1519d1f0f 100644 (file)
@@ -95,6 +95,9 @@ class Settings(BaseModel):
     DHCP6_HOST_FILE: str = Field(default_factory=lambda: os.getenv("DHCP6_HOST_FILE", default.DHCP6_HOST_FILE))
     DHCP6_LEASES_FILE: str = Field(default_factory=lambda: os.getenv("DHCP6_LEASES_FILE", default.DHCP6_LEASES_FILE))
 
+    # APP Features
+    PING_WORKERS: int = Field(default_factory=lambda: int(os.getenv("PING_WORKERS", default.PING_WORKERS)))
+
     def model_post_init(self, __context) -> None:
         if self.DEVEL:
             ts = datetime.datetime.now().strftime("%Y%m%d-%H%M")
index c0acb7763c4699a2880621772e4f228ad2f019f2..07924f60269ff879af706f812ea1bec4c1e8f899 100644 (file)
@@ -50,6 +50,7 @@
                         <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>
+                        <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>
diff --git a/frontend/devices.html b/frontend/devices.html
new file mode 100644 (file)
index 0000000..54ab9fe
--- /dev/null
@@ -0,0 +1,219 @@
+<!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"        aria-current="page">DHCP Leases</a>
+                        <a href="/devices" id="devicesBtn" class="btn btn-primary active" 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">Devices</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="Add Host" aria-label="Add Host"
+                            data-bs-toggle="modal" data-bs-target="#addHostModal">
+                        <i class="bi bi-plus-lg"></i><span class="label"> Add Host</span>
+                    </button>
+                    <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 Address<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">Description<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="true">Active     <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/devices.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>
index 658bf0d16731ef4b5b47b43dd88ab38a605e7fbd..b5b562af3fb625a2c1acc538f12eba01e1192a1d 100644 (file)
@@ -50,6 +50,7 @@
                         <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>
+                        <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>
index 8692e32a24c5ad7db811f04035650dee26c9a204..c8a2485640b8eed7901f5f3547a1c2f3461074d4 100644 (file)
                 <p>Inventario alias DNS.</p>
             </a>
 
+            <!-- Devices Status -->
+            <a href="/devices" class="tile text-decoration-none">
+                <div class="tile-icon">
+                    <i class="bi bi-card-list"></i>
+                </div>
+                <h3>Devices</h3>
+                <p>Device status (Host + DHCP).</p>
+            </a>
+
             <!-- Certificati -->
             <a href="/api/certificates" class="tile text-decoration-none">
                 <div class="tile-icon"><i class="bi bi-shield-lock"></i></div>
diff --git a/frontend/js/devices.js b/frontend/js/devices.js
new file mode 100644 (file)
index 0000000..4b2966b
--- /dev/null
@@ -0,0 +1,863 @@
+// Import common js
+import { isValidIPv4, isValidIPv6, isValidMAC, showToast, sortTable, initSortableTable, resetSorting } from './common.js';
+import { reloadDNS, reloadDHCP } from './services.js';
+
+// -----------------------------
+// State variables
+// -----------------------------
+let editingHostId = null;
+const sortState = { sortDirection: {}, lastSort: null };
+
+// -----------------------------
+// Load all devices into the table
+// -----------------------------
+async function loadDevices() {
+    let devices = [];
+    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/devices`, {
+            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();
+            devices = 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 devices`;
+            const err = new Error(serverMsg ? `${base}: ${serverMsg}` : base);
+            err.status = res.status;
+            throw err;
+        }
+
+    } catch (err) {
+        console.error(err?.message || "Error loading devices");
+        showToast(err?.message || "Error loading devices", false);
+        devices = [];
+        // 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 devices, show an empty row
+    if (!devices.length) {
+        const trEmpty = document.createElement("tr");
+        const tdEmpty = document.createElement("td");
+        tdEmpty.colSpan = 7;
+        tdEmpty.textContent = "No devices available.";
+        tdEmpty.style.textAlign = "center";
+        trEmpty.appendChild(tdEmpty);
+        tbody.appendChild(trEmpty);
+        return;
+    }
+
+    // fragment per performance
+    const frag = document.createDocumentFragment();
+
+    devices.forEach(d => {
+
+        //const mixedId = d.id;
+        //const id = mixedId.slice(2);
+        const id = d.id;
+        let type = 0;
+
+        // Static or Dynamic?
+        if (id.startsWith("s-")) {
+            // static â†’ delete su DB
+            type = 1;
+        } else if (id.startsWith("d-")) {
+            type = 2;
+        } else {
+            console.error("loadDevices: unknown device type:", id);
+            showToast("loadDevices: unknown device type:", false);
+        }
+
+        const tr = document.createElement("tr");
+
+        // IP Address
+        {
+            const td = document.createElement("td");
+            const raw = (d.ipv4 ?? "").toString().trim();
+            td.textContent = raw;
+            if (raw) td.setAttribute("data-value", raw);
+            tr.appendChild(td);
+        }
+
+        // MAC
+        {
+            const td = document.createElement("td");
+            const raw = (d.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 = (d.name ?? "").toString();
+            td.textContent = val;
+            if (val) td.setAttribute("data-value", val.toLowerCase());
+            tr.appendChild(td);
+        }
+
+        // Description
+        {
+            const td = document.createElement("td");
+            const val = (d.description ?? "").toString();
+            td.textContent = val;
+            if (val) td.setAttribute("data-value", val.toLowerCase());
+            tr.appendChild(td);
+        }
+
+        // State Icon
+        {
+            const td = document.createElement("td");
+            td.style.textAlign = "center";
+            td.style.verticalAlign = "middle";
+
+            const val = (d.dhcp_state ?? "").toString();
+            let aria = "";
+            let iconClass = "";
+            switch (val) {
+                case "static":
+                    // Static device
+                    aria = "Device is static";
+                    iconClass = "bi bi-gear-fill";
+                    break;
+
+                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);
+        }
+
+        // Active
+        {
+            const td = document.createElement("td");
+            td.style.textAlign = "center";
+            td.style.verticalAlign = "middle";
+
+            const active = !!d.active;
+            td.setAttribute("data-value", active ? "true" : "false");
+            td.setAttribute("aria-label", active ? "device active" : "device not active");
+            const icon = document.createElement("i");
+            if (active) {
+                icon.className = "bi bi-circle-fill text-success icon icon-static";
+                icon.setAttribute("aria-hidden", "true");
+                icon.setAttribute("title", "Device is active");
+            } else {
+                icon.className = "bi bi-circle-fill text-danger icon icon-static";
+                icon.setAttribute("aria-hidden", "true");
+                icon.setAttribute("title", "Device is not active");
+            }
+            td.appendChild(icon);
+            tr.appendChild(td);
+        }
+
+        // Actions
+        {
+            const td = document.createElement("td");
+            td.className = "actions";
+            td.style.textAlign = "center";
+            td.style.verticalAlign = "middle";
+
+            // Edit Button
+            const editSpan = document.createElement("span");
+            editSpan.className = "action-icon";
+            editSpan.setAttribute("role", "button");
+            editSpan.tabIndex = 0;
+            editSpan.title = "Edit host";
+            editSpan.setAttribute("aria-label", "Edit host");
+            editSpan.setAttribute("data-bs-toggle", "modal");
+            editSpan.setAttribute("data-bs-target", "#addHostModal");
+            editSpan.setAttribute("data-action", "edit");
+            editSpan.setAttribute("data-device-id", String(id));
+            {
+                const i = document.createElement("i");
+                i.className = "bi bi-pencil-fill icon icon-action";
+                i.setAttribute("aria-hidden", "true");
+                editSpan.appendChild(i);
+            }
+
+            // Add Button
+            const addSpan = document.createElement("span");
+            addSpan.className = "action-icon";
+            addSpan.setAttribute("role", "button");
+            addSpan.tabIndex = 0;
+            addSpan.title = "Add static lease";
+            addSpan.setAttribute("aria-label", "Add static lease");
+            addSpan.setAttribute("data-bs-toggle", "modal");
+            addSpan.setAttribute("data-bs-target", "#addHostModal");
+            addSpan.setAttribute("data-action", "add");
+            addSpan.setAttribute("data-device-id", String(id));
+            {
+                const i = document.createElement("i");
+                i.className = "bi bi-plus-circle icon icon-action";
+                i.setAttribute("aria-hidden", "true");
+                addSpan.appendChild(i);
+            }
+
+            // Delete Button
+            const delSpan = document.createElement("span");
+            delSpan.className = "action-icon";
+            delSpan.setAttribute("role", "button");
+            delSpan.tabIndex = 0;
+            delSpan.title = "Delete device";
+            delSpan.setAttribute("aria-label", "Delete device");
+            delSpan.setAttribute("data-action", "delete");
+            delSpan.setAttribute("data-device-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);
+            }
+
+            if(type == 1) {
+              td.appendChild(editSpan);
+            } else if (type == 2) {
+              td.appendChild(addSpan);
+            } else {
+            }
+            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");
+}
+
+// -----------------------------
+// Edit Host: load data and pre-fill the form
+// -----------------------------
+async function editHost(id) {
+
+    let fetchUrl = "";
+    let host = false;
+
+    // Clear form first
+    clearAddHostForm();
+
+    if (id !== null) {
+        // Static or Dynamic?
+        if (id.startsWith("s-")) {
+            // static
+            fetchUrl = `/api/hosts/${id.slice(2)}`;
+            host = true;
+        } else if (id.startsWith("d-")) {
+            // dynamic
+            fetchUrl = `/api/dhcp/leases/${id.slice(2)}`;
+            host = false;
+        } else {
+            throw new Error("Invalid Device ID format for edit");
+        }
+        id = Number(id.slice(2));
+    } else {
+        throw new Error("Invalid Device ID for edit");
+    }
+
+    // Fetch host
+    const res = await fetch(fetchUrl, {
+        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 host ${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 host ${id}: Invalid JSON payload`);
+    }
+
+    // Check JSON errors
+    if (!res.ok) {
+        const serverMsg = data?.detail?.message?.trim();
+        const base = `Fetch failed for host ${id}`;
+        const err = new Error(serverMsg ? `${base}: ${serverMsg}` : base);
+        err.status = res.status;
+        throw err;
+    }
+
+    if(host) {
+        // Store the ID of the host being edited
+        editingHostId = id;
+    }
+
+    // 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;
+    }
+
+    if (editingHostId !== null) {
+        // Update existing host
+        const res = await fetch(`/api/hosts/${editingHostId}`, {
+            method: 'PUT',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify(hostData)
+        });
+
+        // Success without JSON
+        if (res.status === 204) {
+            showToast('Host updated 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 updating host`;
+            const err = new Error(serverMsg ? `${base}: ${serverMsg}` : base);
+            err.status = res.status;
+            throw err;
+        }
+
+        // Success
+        showToast(data?.message || 'Host updated successfully', true);
+        return true;
+
+    } else {
+        // 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 edit mode
+    editingHostId = null;
+    // 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 loadDevices();
+            return true
+        }
+
+    } catch (err) {
+        console.error(err?.message || "Error saving host");
+        showToast(err?.message || "Error saving host", false);
+    }
+
+    return false;
+}
+
+// -----------------------------
+// Handle delete device action
+// -----------------------------
+async function handleDeleteDevice(e, el) {
+    // Prevent default action
+    e.preventDefault();
+
+    // Get device ID
+    const id = el.dataset.deviceId;
+
+    if (!id) {
+        console.warn('Delete: device id not valid for delete:', id);
+        showToast('Device id not valid for delete', false);
+        return;
+    }
+
+    let deleteUrl = "";
+
+    // Static or Dynamic?
+    if (id.startsWith("s-")) {
+        // static â†’ delete su DB
+        deleteUrl = `/api/hosts/${id.slice(2)}`
+    } else if (id.startsWith("d-")) {
+        // dynamic â†’ delete su DHCP server
+        deleteUrl = `/api/dhcp/leases/${id.slice(2)}`
+    } else {
+        console.error("Delete: unknown device type:", id);
+        showToast("Delete: unknown device type:", false);
+        return;
+    }
+
+    // Execute delete
+    try {
+        // Fetch data
+        const res = await fetch(deleteUrl, {
+            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 device`;
+            const err = new Error(serverMsg ? `${base}: ${serverMsg}` : base);
+            err.status = res.status;
+            throw err;
+        }
+
+        // Success
+        showToast(data?.message || 'Device deleted successfully', true);
+
+        // Reload devices
+        await loadDevices();
+        return true;
+
+    } catch (err) {
+        console.error(err?.message || "Error deleting device");
+        showToast(err?.message || "Error deleting device", false);
+    }
+
+    return false;
+}
+
+// -----------------------------
+// filter devices in the table
+// -----------------------------
+function filterDevices() {
+    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 loadDevices();
+}
+
+// -----------------------------
+// Action Handlers
+// -----------------------------
+const actionHandlers = {
+    // Delete device
+    delete: (e, el) => {
+        handleDeleteDevice(e, el);
+    },
+    // Edit host
+    edit: () => {
+        // 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 (devices)
+    try {
+        await loadDevices();
+    } catch (err) {
+        console.error(err?.message || "Error loading devices");
+        showToast(err?.message || "Error loading devices", false);
+    }
+
+    // search bar
+    const input = document.getElementById("searchInput");
+    if (input) {
+        // clean input on load
+        input.value = "";
+        // live filter for each keystroke
+        input.addEventListener("input", filterDevices);
+        // 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)
+                filterDevices('');        // 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();
+            filterDevices('');
+        }
+    });
+
+    // 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-device-id');
+            const id = idAttr ? idAttr : null;
+
+            if (id !== null) {
+                // Edit Mode
+                try {
+                    await editHost(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);
+                }
+            } else {
+                // Add Mode
+                clearAddHostForm();
+                // Set focus to the first input field when modal is shown
+                const focusOnShown = () => {
+                    document.getElementById('hostName')?.focus({ preventScroll: true });
+                    modalEl.removeEventListener('shown.bs.modal', focusOnShown);
+                };
+                modalEl.addEventListener('shown.bs.modal', focusOnShown);
+            }
+        });
+
+        // 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));
+    });
+});