From a0fea08425c9ba306f5f077e5ff4ace14216b9a0 Mon Sep 17 00:00:00 2001 From: Giorgio Ravera Date: Fri, 22 May 2026 18:19:22 +0200 Subject: [PATCH] Added devices list --- Dockerfile | 2 +- README.md | 1 + backend/app.py | 2 + backend/bootstrap.py | 4 + backend/db/hosts.py | 15 +- backend/routes/devices.py | 78 ++++ backend/settings/default.py | 5 + backend/settings/settings.py | 3 + frontend/aliases.html | 1 + frontend/devices.html | 219 +++++++++ frontend/hosts.html | 1 + frontend/index.html | 9 + frontend/js/devices.js | 863 +++++++++++++++++++++++++++++++++++ 13 files changed, 1199 insertions(+), 4 deletions(-) create mode 100644 backend/routes/devices.py create mode 100644 frontend/devices.html create mode 100644 frontend/js/devices.js diff --git a/Dockerfile b/Dockerfile index c31cacb..6533edb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 118188e..9c6fd28 100644 --- 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 | --- diff --git a/backend/app.py b/backend/app.py index b184faa..c169fce 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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) diff --git a/backend/bootstrap.py b/backend/bootstrap.py index 567740c..379b8e1 100644 --- a/backend/bootstrap.py +++ b/backend/bootstrap.py @@ -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 diff --git a/backend/db/hosts.py b/backend/db/hosts.py index a9ff3ae..bb07eab 100644 --- a/backend/db/hosts.py +++ b/backend/db/hosts.py @@ -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 index 0000000..d9fd93b --- /dev/null +++ b/backend/routes/devices.py @@ -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", + }, + ) diff --git a/backend/settings/default.py b/backend/settings/default.py index 08f5cde..d163aca 100644 --- a/backend/settings/default.py +++ b/backend/settings/default.py @@ -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 diff --git a/backend/settings/settings.py b/backend/settings/settings.py index aacb5da..e3a6e8a 100644 --- a/backend/settings/settings.py +++ b/backend/settings/settings.py @@ -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") diff --git a/frontend/aliases.html b/frontend/aliases.html index c0acb77..07924f6 100644 --- a/frontend/aliases.html +++ b/frontend/aliases.html @@ -50,6 +50,7 @@ Hostname Alias DHCP Leases + Devices diff --git a/frontend/devices.html b/frontend/devices.html new file mode 100644 index 0000000..54ab9fe --- /dev/null +++ b/frontend/devices.html @@ -0,0 +1,219 @@ + + + + + Network Manager + + + + + + + + + + + + + + +
+
+ + + +
+ + +
+
+ + +
+ + +
+
+
+ +
+

+ 🖧 + Devices +

+
+ + +
+ + +
+
+ +
+
+ + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + +
IP Address MAC AddressHostname DescriptionState Active Actions
+ + + +
+ + + + + + + + + + + + diff --git a/frontend/hosts.html b/frontend/hosts.html index 658bf0d..b5b562a 100644 --- a/frontend/hosts.html +++ b/frontend/hosts.html @@ -50,6 +50,7 @@ Hostname Alias DHCP Leases + Devices diff --git a/frontend/index.html b/frontend/index.html index 8692e32..c8a2485 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -122,6 +122,15 @@

Inventario alias DNS.

+ + +
+ +
+

Devices

+

Device status (Host + DHCP).

+
+
diff --git a/frontend/js/devices.js b/frontend/js/devices.js new file mode 100644 index 0000000..4b2966b --- /dev/null +++ b/frontend/js/devices.js @@ -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)); + }); +}); -- 2.47.3