--- /dev/null
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>Network Manager</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+
+ <!-- Bootstrap 5.x CSS (CDN) -->
+ <link
+ href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
+ rel="stylesheet"
+ integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
+ crossorigin="anonymous"
+ >
+ <!-- Bootstrap Icons -->
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
+
+ <!-- Boostrap override -->
+ <link rel="stylesheet" href="css/variables.css">
+ <link rel="stylesheet" href="css/layout.css">
+</head>
+
+<body>
+ <!-- Topbar -->
+ <header class="topbar">
+ <div class="topbar-inner">
+ <a href="/home" class="logo text-decoration-none">
+ <svg width="30" height="30" viewBox="0 0 24 24" fill="var(--accent)" aria-hidden="true">
+ <circle cx="12" cy="4" r="2"></circle>
+ <circle cx="4" cy="12" r="2"></circle>
+ <circle cx="20" cy="12" r="2"></circle>
+ <circle cx="12" cy="20" r="2"></circle>
+ <line x1="12" y1="6" x2="12" y2="18" stroke="var(--accent)" stroke-width="2"></line>
+ <line x1="6" y1="12" x2="18" y2="12" stroke="var(--accent)" stroke-width="2"></line>
+ </svg>
+ <span>Network Manager</span>
+ </a>
+
+ <!-- Spacer -->
+ <div class="col d-none d-md-block"></div>
+
+ <nav class="navbar navbar-expand-md px-3">
+ <!-- Bottone hamburger -->
+ <button class="navbar-toggler bg-light" type="button" data-bs-toggle="collapse" data-bs-target="#menuNav">
+ <span class="navbar-toggler-icon"></span>
+ </button>
+ <!-- Menu -->
+ <div class="collapse navbar-collapse" id="menuNav">
+ <div class="navbar-nav ms-auto gap-2">
+ <a href="/hosts" id="hostsBtn" class="btn btn-primary" aria-current="page">Hostname</a>
+ <a href="/aliases" id="aliasesBtn" class="btn btn-primary" aria-current="page">Alias</a>
+ <a href="/leases" id="leasesBtn" class="btn btn-primary" 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>
--- /dev/null
+// 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));
+ });
+});