From ff491618fcbe8c0e80d556a2a0b126a96b5f357f Mon Sep 17 00:00:00 2001 From: Giorgio Ravera Date: Fri, 22 May 2026 17:22:35 +0200 Subject: [PATCH] Added leases page --- backend/db/leases.py | 151 +++++++++ backend/routes/dhcp.py | 233 +++++++++---- frontend/aliases.html | 1 + frontend/hosts.html | 1 + frontend/index.html | 4 + frontend/js/leases.js | 721 +++++++++++++++++++++++++++++++++++++++++ frontend/leases.html | 215 ++++++++++++ 7 files changed, 1255 insertions(+), 71 deletions(-) create mode 100644 backend/db/leases.py create mode 100644 frontend/js/leases.js create mode 100644 frontend/leases.html diff --git a/backend/db/leases.py b/backend/db/leases.py new file mode 100644 index 0000000..cc3b10b --- /dev/null +++ b/backend/db/leases.py @@ -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) diff --git a/backend/routes/dhcp.py b/backend/routes/dhcp.py index 08e561d..feb5754 100644 --- a/backend/routes/dhcp.py +++ b/backend/routes/dhcp.py @@ -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, + }, }, ) diff --git a/frontend/aliases.html b/frontend/aliases.html index 5142595..c0acb77 100644 --- a/frontend/aliases.html +++ b/frontend/aliases.html @@ -49,6 +49,7 @@ diff --git a/frontend/hosts.html b/frontend/hosts.html index e827d4e..658bf0d 100644 --- a/frontend/hosts.html +++ b/frontend/hosts.html @@ -49,6 +49,7 @@ diff --git a/frontend/index.html b/frontend/index.html index 8cea441..8692e32 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -98,6 +98,10 @@

DHCP (Kea)

Pools, leases, reservations.

+ + + DHCP Leases + diff --git a/frontend/js/leases.js b/frontend/js/leases.js new file mode 100644 index 0000000..52166a2 --- /dev/null +++ b/frontend/js/leases.js @@ -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 index 0000000..8b228b7 --- /dev/null +++ b/frontend/leases.html @@ -0,0 +1,215 @@ + + + + + Network Manager + + + + + + + + + + + + + + +
+
+ + + +
+ + +
+
+ + +
+ + +
+
+
+ +
+

+ 🖧 + DHCP Leases +

+
+ + +
+ + +
+
+ +
+
+ + +
+ + +
+
+
+
+ + + + + + + + + + + + + + + +
IP AddressMAC Hostname Start End State Actions
+ + + +
+ + + + + + + + + + + + -- 2.47.3