]> git.giorgioravera.it Git - network-manager.git/commitdiff
several improvements in hots (sort, validate, layout)
authorGiorgio Ravera <giorgio.ravera@gmail.com>
Sun, 11 Jan 2026 00:57:38 +0000 (01:57 +0100)
committerGiorgio Ravera <giorgio.ravera@gmail.com>
Sun, 11 Jan 2026 00:57:38 +0000 (01:57 +0100)
frontend/css/hosts.css
frontend/hosts.html
frontend/js/hosts.js

index 47404679ea6dee4c6a2129124ff12184d6308310..9435d17489b3df9a53f7990be101ef866b51c73d 100644 (file)
@@ -30,3 +30,17 @@ td.actions {
     border-radius: 6px;
     font-size: 14px;
 }
+
+/* ================================
+    Sort arrows (specific to hosts)
+   ================================ */  
+.sort-arrow {
+  display: inline-block;
+  width: 1em;
+  text-align: center;
+  margin-left: .25rem;
+  color: var(--text-dark);
+}
+
+.sort-arrow.asc::after { content: "▲"; }
+.sort-arrow.desc::after { content: "▼"; }
index 41ee14071ba2b74dbac267f67d7018490f0d617b..c8350a6a7677d81ff153db66868def983b424d62 100644 (file)
 <table id="hosts-table">
     <thead>
         <tr>
-            <th onclick="sortTable(0)">Name <span class="sort-arrow"></span></th>
-            <th onclick="sortTable(1)">IPv4 <span class="sort-arrow"></span></th>
-            <th onclick="sortTable(2)">IPv6 <span class="sort-arrow"></span></th>
-            <th onclick="sortTable(3)">MAC <span class="sort-arrow"></span></th>
-            <th onclick="sortTable(4)">Note <span class="sort-arrow"></span></th>
-            <th onclick="sortTable(5)">SSL <span class="sort-arrow"></span></th>
+            <th data-type="string" onclick="sortTable(0)">Name <span class="sort-arrow" aria-hidden="true"></span></th>
+            <th data-type="ipv4" onclick="sortTable(1)">IPv4 <span class="sort-arrow" aria-hidden="true"></span></th>
+            <th data-type="ipv6" onclick="sortTable(2)">IPv6 <span class="sort-arrow" aria-hidden="true"></span></th>
+            <th data-type="mac" onclick="sortTable(3)">MAC <span class="sort-arrow" aria-hidden="true"></span></th>
+            <th data-type="string" onclick="sortTable(4)">Note <span class="sort-arrow" aria-hidden="true"></span></th>
+            <th data-type="string" onclick="sortTable(5)">SSL <span class="sort-arrow" aria-hidden="true"></span></th>
             <th>Actions</th>
         </tr>
     </thead>
index 9c2e47e79976ec0b97d5c0aa7b3cf488a8e1f137..4c14165426f4a8d0609d642111264b8bfa250b3d 100644 (file)
@@ -1,16 +1,47 @@
 let editingHostId = null;
 let sortDirection = {};
+let lastSort = null; // { colIndex: number, ascending: boolean }
+const stringCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
 
 // -----------------------------
-// Validate the IP address format
+// Validate the IPv4 address format
 // -----------------------------
-function isValidIP(ip) {
+function isValidIPv4(ip) {
     if (!ip || !ip.trim()) return true; // empty is allowed
-
     const ipv4 = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
-    const ipv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::1)$/;
+    return ipv4.test(ip);
+}
+
+// -----------------------------
+// Validate the IPv6 address format
+// -----------------------------
+function isValidIPv6(ip) {
+    if (!ip || !ip.trim()) return true; // empty is allowed
+    // Parser robusto (gestisce '::')
+    let s = ip.toLowerCase().trim();
+    const dbl = s.indexOf("::");
+    let parts = [];
+    if (dbl >= 0) {
+        const left = s.slice(0, dbl).split(":").filter(Boolean);
+        const right = s.slice(dbl + 2).split(":").filter(Boolean);
+        const missing = 8 - (left.length + right.length);
+        if (missing < 0) return false;
+        parts = [...left, ...Array(missing).fill("0"), ...right];
+    } else {
+        parts = s.split(":");
+        if (parts.length !== 8) return false;
+    }
+    return parts.every(p => /^[0-9a-f]{1,4}$/.test(p));
+}
 
-    return ipv4.test(ip) || ipv6.test(ip);
+// -----------------------------
+// Validate the MAC address format
+// -----------------------------
+function isValidMAC(mac) {
+  const s = (mac ?? "").trim().toLowerCase().replace(/[\s:\-\.]/g, "");
+  // vuoto consentito
+  if (s === "") return true;
+  return /^[0-9a-f]{12}$/.test(s);
 }
 
 // -----------------------------
@@ -18,6 +49,10 @@ function isValidIP(ip) {
 // -----------------------------
 async function loadHosts() {
     const res = await fetch("/api/hosts");
+    if (!res.ok) {
+        showToast("Errore nel caricamento degli host", false);
+        return;
+    }
     const hosts = await res.json();
 
     const tbody = document.querySelector("#hosts-table tbody");
@@ -25,32 +60,83 @@ async function loadHosts() {
 
     hosts.forEach(h => {
         const tr = document.createElement("tr");
-        tr.innerHTML = `
-            <td>${h.name}</td>
-            <td>${h.ipv4 || ""}</td>
-            <td>${h.ipv6 || ""}</td>
-            <td>${h.mac || ""}</td>
-            <td>${h.note || ""}</td>
-            <td>${h.ssl_enabled ? "&#10004;" : ""}</td>
-            <td class="actions">
-                <span class="edit-btn" onclick="editHost(${h.id})">
-                    <svg width="18" height="18" viewBox="0 0 24 24" fill="#007BFF">
-                        <path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 
-                        7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 
-                        0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
-                    </svg>
-                </span>
-
-                <span class="delete-btn" onclick="deleteHost(${h.id})">
-                    <svg width="18" height="18" viewBox="0 0 24 24" fill="#0099FF">
-                        <path d="M3 6h18v2H3V6zm2 3h14l-1.5 
-                        12.5h-11L5 9zm5-6h4l1 1h5v2H4V4h5l1-1z"/>
-                    </svg>
-                </span>
-            </td>
+
+        // Name
+        const tdName = document.createElement("td");
+        const nameVal = (h.name ?? "").toString();
+        tdName.textContent = nameVal;
+        if (nameVal) tdName.setAttribute("data-value", nameVal.toLowerCase());
+        tr.appendChild(tdName);
+
+        // IPv4
+        const tdIPv4 = document.createElement("td");
+        const ipv4Raw = (h.ipv4 ?? "").trim();
+        tdIPv4.textContent = ipv4Raw;
+        if (ipv4Raw) tdIPv4.setAttribute("data-value", ipv4Raw);
+        tr.appendChild(tdIPv4);
+
+        // IPv6
+        const tdIPv6 = document.createElement("td");
+        const ipv6Raw = (h.ipv6 ?? "").trim();
+        tdIPv6.textContent = ipv6Raw;
+        if (ipv6Raw) tdIPv6.setAttribute("data-value", ipv6Raw.toLowerCase());
+        tr.appendChild(tdIPv6);
+
+        // MAC
+        const tdMAC = document.createElement("td");
+        const macRaw = (h.mac ?? "").trim();
+        tdMAC.textContent = macRaw;
+        const macNorm = macRaw.toLowerCase().replace(/[\s:\-\.]/g, "");
+        if (macNorm) tdMAC.setAttribute("data-value", macNorm);
+        tr.appendChild(tdMAC);
+
+        // Note
+        const tdNote = document.createElement("td");
+        const noteVal = (h.note ?? "").toString();
+        tdNote.textContent = noteVal;
+        if (noteVal) tdNote.setAttribute("data-value", noteVal.toLowerCase());
+        tr.appendChild(tdNote);
+
+        // SSL (icon)
+        const tdSSL = document.createElement("td");
+        const sslEnabled = !!h.ssl_enabled; // 1/true -> true
+        if (sslEnabled) {
+            tdSSL.innerHTML = "&#10004;";
+            tdSSL.setAttribute("data-value", "true");
+            tdSSL.setAttribute("aria-label", "SSL attivo");
+        } else {
+            tdSSL.setAttribute("data-value", "false");
+            tdSSL.setAttribute("aria-label", "SSL non attivo");
+        }
+        tr.appendChild(tdSSL);
+
+        // Actions
+        const tdActions = document.createElement("td");
+        tdActions.className = "actions";
+        tdActions.innerHTML = `
+            <span class="edit-btn" onclick="editHost(${Number(h.id)})">
+                <svg width="18" height="18" viewBox="0 0 24 24" fill="#007BFF" aria-hidden="true">
+                    <path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 
+                    7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 
+                    0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
+                </svg>
+            </span>
+            <span class="delete-btn" onclick="deleteHost(${Number(h.id)})">
+                <svg width="18" height="18" viewBox="0 0 24 24" fill="#0099FF" aria-hidden="true">
+                    <path d="M3 6h18v2H3V6zm2 3h14l-1.5 
+                    12.5h-11L5 9zm5-6h4l1 1h5v2H4V4h5l1-1z"/>
+                </svg>
+            </span>
         `;
+        tr.appendChild(tdActions);
+
         tbody.appendChild(tr);
     });
+
+    if (lastSort) {
+        sortDirection[lastSort.colIndex] = !lastSort.ascending;
+        sortTable(lastSort.colIndex);
+    }
 }
 
 // -----------------------------
@@ -58,6 +144,11 @@ async function loadHosts() {
 // -----------------------------
 async function editHost(id) {
     const res = await fetch(`/api/hosts/${id}`);
+    if (!res.ok) {
+        console.error(`Errore nel recupero host ${id}:`, res.status);
+        showToast("Errore nel recupero host", false);
+        return;
+    }
     const host = await res.json();
 
     // Store the ID of the host being edited
@@ -69,7 +160,7 @@ async function editHost(id) {
     document.getElementById("hostIPv6").value = host.ipv6 || "";
     document.getElementById("hostMAC").value = host.mac || "";
     document.getElementById("hostNote").value = host.note || "";
-    document.getElementById("hostSSL").checked = host.ssl_enabled === 1;
+    document.getElementById("hostSSL").checked = !!host.ssl_enabled;
 
     document.getElementById("addHostModal").style.display = "flex";
 }
@@ -108,15 +199,21 @@ async function saveHost() {
         showToast("Name is required", false);
         return; // stop here, do NOT send the request
     }
-    // Validate IP format
-    if (!isValidIP(document.getElementById("hostIPv4").value)) {
+    // Validate IPv4 format
+    if (!isValidIPv4(document.getElementById("hostIPv4").value)) {
         showToast("Invalid IPv4 format", false);
         return;
     }
-    if (!isValidIP(document.getElementById("hostIPv6").value)) {
+    // Validate IPv6 format
+    if (!isValidIPv6(document.getElementById("hostIPv6").value)) {
         showToast("Invalid IPv6 format", false);
         return;
     }
+    // Validate MAC format
+    if (!isValidMAC(document.getElementById("hostMAC").value)) {
+        showToast("Invalid MAC format", false);
+        return;
+    }
 
     const payload = {
         name: document.getElementById("hostName").value,
@@ -130,26 +227,26 @@ async function saveHost() {
     try {
         if (editingHostId !== null) {
             // UPDATE EXISTING HOST
-            await fetch(`/api/hosts/${editingHostId}`, {
+            const res = await fetch(`/api/hosts/${editingHostId}`, {
                 method: "PUT",
                 headers: { "Content-Type": "application/json" },
                 body: JSON.stringify(payload)
             });
-
+            if (!res.ok) throw new Error(`Update failed: ${res.status}`);
             showToast("Host updated successfully");
         } else {
             // CREATE NEW HOST
-            await fetch("/api/hosts", {
+            const res = await fetch("/api/hosts", {
                 method: "POST",
                 headers: { "Content-Type": "application/json" },
                 body: JSON.stringify(payload)
             });
-
+            if (!res.ok) throw new Error(`Create failed: ${res.status}`);
             showToast("Host added successfully");
         }
 
         closeAddHostModal();
-        loadHosts();
+        await loadHosts();
 
     } catch (err) {
         console.error(err);
@@ -163,11 +260,9 @@ async function saveHost() {
 async function deleteHost(id) {
     try {
         const res = await fetch(`/api/hosts/${id}`, { method: "DELETE" });
-
         if (!res.ok) {
             throw new Error("Delete failed");
         }
-
         showToast("Host removed successfully");
 
     } catch (err) {
@@ -175,7 +270,7 @@ async function deleteHost(id) {
         showToast("Error while removing host", false);
     }
 
-    loadHosts();
+    await loadHosts();
 }
 
 // -----------------------------
@@ -210,11 +305,175 @@ function filterHosts() {
 // -----------------------------
 // Clear search on ESC key
 // -----------------------------
-function clearSearch() {
+async function clearSearch() {
     const input = document.getElementById("searchInput");
     input.value = "";
     input.blur();
-    loadHosts();
+    await loadHosts();
+}
+
+/**
+ * Parser per tipi di dato:
+ * - number: riconosce float, anche con virgole migliaia
+ * - date: tenta Date.parse su formati comuni
+ * - ipv4: ordina per valore numerico dei 4 ottetti
+ * - ipv6: espande '::' e ordina come BigInt 128-bit
+ * - mac: normalizza e ordina come BigInt 48-bit
+ * - version: ordina semver-like "1.2.10"
+ * - string: predefinito (locale-aware, case-insensitive, con numerico)
+ */
+function parseByType(value, type) {
+    const v = (value ?? "").trim();
+
+    if (v === "") return { type: "empty", value: null };
+
+    switch (type) {
+        case "number": {
+            // Rimuove separatori di migliaia (spazio, apostrofo, punto prima di gruppi da 3)
+            // e converte la virgola decimale in punto.
+            const norm = v.replace(/[\s'’\.](?=\d{3}\b)/g, "").replace(",", ".");
+            const n = Number(norm);
+            return isNaN(n) ? { type: "string", value: v.toLowerCase() } : { type: "number", value: n };
+        }
+
+        case "date": {
+            let time = Date.parse(v);
+            if (isNaN(time)) {
+                // prova formato DD/MM/YYYY [HH:mm]
+                const m = v.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2}))?$/);
+                if (m) {
+                    const [_, d, mo, y, hh = "0", mm = "0"] = m;
+                    time = Date.parse(`${y}-${mo.padStart(2,"0")}-${d.padStart(2,"0")}T${hh.padStart(2,"0")}:${mm.padStart(2,"0")}:00`);
+                }
+            }
+            return isNaN(time) ? { type: "string", value: v.toLowerCase() } : { type: "date", value: time };
+        }
+
+        case "ipv4": {
+            const m = v.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
+            if (m) {
+                const a = +m[1], b = +m[2], c = +m[3], d = +m[4];
+                if ([a,b,c,d].every(n => Number.isInteger(n) && n >= 0 && n <= 255)) {
+                    const num = ((a << 24) | (b << 16) | (c << 8) | d) >>> 0; // 32-bit unsigned
+                    return { type: "ipv4", value: num };
+                }
+            }
+            return { type: "string", value: v.toLowerCase() };
+        }
+
+        case "ipv6": {
+            // Espansione '::' e parsing 8 gruppi hex da 16 bit ? BigInt 128-bit
+            let s = v.toLowerCase().trim();
+            const dbl = s.indexOf("::");
+            let parts = [];
+            if (dbl >= 0) {
+                const left = s.slice(0, dbl).split(":").filter(Boolean);
+                const right = s.slice(dbl + 2).split(":").filter(Boolean);
+                const missing = 8 - (left.length + right.length);
+                if (missing < 0) return { type: "string", value: v.toLowerCase() };
+                parts = [...left, ...Array(missing).fill("0"), ...right];
+            } else {
+                parts = s.split(":");
+                if (parts.length !== 8) return { type: "string", value: v.toLowerCase() };
+            }
+            if (!parts.every(p => /^[0-9a-f]{1,4}$/.test(p))) {
+                return { type: "string", value: v.toLowerCase() };
+            }
+            let big = 0n;
+            for (const p of parts) {
+                big = (big << 16n) + BigInt(parseInt(p, 16));
+            }
+            return { type: "ipv6", value: big };
+        }
+
+        case "mac": {
+            // Accetta :, -, ., spazi o formato compatto; ordina come BigInt 48-bit
+            let s = v.toLowerCase().trim().replace(/[\s:\-\.]/g, "");
+            if (!/^[0-9a-f]{12}$/.test(s)) {
+                return { type: "string", value: v.toLowerCase() };
+            }
+            const mac = BigInt("0x" + s);
+            return { type: "mac", value: mac };
+        }
+
+        case "version": {
+            const segs = v.split(".").map(s => (/^\d+$/.test(s) ? Number(s) : s.toLowerCase()));
+            return { type: "version", value: segs };
+        }
+
+        default:
+        case "string":
+            return { type: "string", value: v.toLowerCase() };
+    }
+}
+
+/**
+ * Comparatore generico in base al tipo
+ */
+function comparator(aParsed, bParsed) {
+    // Celle vuote in fondo in asc
+    if (aParsed.type === "empty" && bParsed.type === "empty") return 0;
+    if (aParsed.type === "empty") return 1;
+    if (bParsed.type === "empty") return -1;
+
+    // Se i tipi differiscono (capita se fallback a string), imponi una gerarchia:
+    // tipi “numerici” prima delle stringhe
+    if (aParsed.type !== bParsed.type) {
+        const rank = { number: 0, date: 0, ipv4: 0, ipv6: 0, mac: 0, version: 0, string: 1 };
+        return (rank[aParsed.type] ?? 1) - (rank[bParsed.type] ?? 1);
+    }
+
+    switch (aParsed.type) {
+        case "number":
+        case "date":
+        case "ipv4":
+            return aParsed.value - bParsed.value;
+
+        case "ipv6":
+        case "mac": {
+            // Confronto BigInt
+            if (aParsed.value === bParsed.value) return 0;
+            return aParsed.value < bParsed.value ? -1 : 1;
+        }
+
+        case "version": {
+            const a = aParsed.value, b = bParsed.value;
+            const len = Math.max(a.length, b.length);
+            for (let i = 0; i < len; i++) {
+                const av = a[i] ?? 0;
+                const bv = b[i] ?? 0;
+                if (typeof av === "number" && typeof bv === "number") {
+                    if (av !== bv) return av - bv;
+                } else {
+                    const as = String(av), bs = String(bv);
+                    const cmp = stringCollator.compare(as, bs);
+                    if (cmp !== 0) return cmp;
+                }
+            }
+            return 0;
+        }
+
+        case "string":
+        default:
+            return stringCollator.compare(aParsed.value, bParsed.value);
+    }
+}
+
+/**
+ * Aggiorna UI delle frecce e ARIA in thead
+ */
+function updateSortUI(table, colIndex, ascending) {
+    const ths = table.querySelectorAll("thead th");
+    ths.forEach((th, i) => {
+        const arrow = th.querySelector(".sort-arrow");
+        th.setAttribute("aria-sort", i === colIndex ? (ascending ? "ascending" : "descending") : "none");
+        th.classList.toggle("is-sorted", i === colIndex);
+        if (arrow) {
+            arrow.classList.remove("asc", "desc");
+            if (i === colIndex) arrow.classList.add(ascending ? "asc" : "desc");
+        }
+        // Se usi pulsanti <button>, puoi aggiornare aria-pressed/aria-label qui.
+    });
 }
 
 // -----------------------------
@@ -222,60 +481,119 @@ function clearSearch() {
 // -----------------------------
 function sortTable(colIndex) {
     const table = document.getElementById("hosts-table");
+    if (!table) return;
+
     const tbody = table.querySelector("tbody");
+    if (!tbody) return;
+
     const rows = Array.from(tbody.querySelectorAll("tr"));
-    const headers = table.querySelectorAll("th .sort-arrow");
+    const ths = table.querySelectorAll("thead th");
+    const type = ths[colIndex]?.dataset?.type || "string";
 
     // Toggle direction
     sortDirection[colIndex] = !sortDirection[colIndex];
-    const direction = sortDirection[colIndex] ? 1 : -1;
-
-    // Reset all arrows
-    headers.forEach(h => h.textContent = "");
+    const ascending = !!sortDirection[colIndex];
+    const direction = ascending ? 1 : -1;
+
+    // Pre-parsing per performance
+    const parsed = rows.map((row, idx) => {
+        const cell = row.children[colIndex];
+        const raw = (cell?.getAttribute("data-value") ?? cell?.innerText ?? "").trim();
+        return { row, idx, parsed: parseByType(raw, type) };
+    });
 
-    // Set arrow for current column
-    headers[colIndex].textContent = direction === 1 ? "▲" : "▼";
+    parsed.sort((a, b) => {
+        const c = comparator(a.parsed, b.parsed);
+        return c !== 0 ? c * direction : (a.idx - b.idx); // tie-breaker
+    });
 
-    rows.sort((a, b) => {
-        const A = a.children[colIndex].innerText.toLowerCase();
-        const B = b.children[colIndex].innerText.toLowerCase();
+    // Re-append in un DocumentFragment (più efficiente)
+    const frag = document.createDocumentFragment();
+    parsed.forEach(p => frag.appendChild(p.row));
+    tbody.appendChild(frag);
 
-        // Numeric sort if both values are numbers
-        const numA = parseFloat(A);
-        const numB = parseFloat(B);
+    updateSortUI(table, colIndex, ascending);
 
-        if (!isNaN(numA) && !isNaN(numB)) {
-            return (numA - numB) * direction;
-        }
-
-        return A.localeCompare(B) * direction;
-    });
+    lastSort = { colIndex, ascending };
+}
 
-    rows.forEach(row => tbody.appendChild(row));
+/**
+ * Opzionale: inizializza aria-sort='none'
+ */
+function initSortableTable() {
+    const table = document.getElementById("hosts-table");
+    if (!table) return;
+    const ths = table.querySelectorAll("thead th");
+    ths.forEach(th => th.setAttribute("aria-sort", "none"));
 }
 
 // -----------------------------
 // Reset sorting arrows and directions
 // -----------------------------
 function resetSorting() {
-    // Svuota tutte le direzioni salvate
+    // azzera lo stato
     sortDirection = {};
 
-    // Rimuove tutte le frecce dalle colonne
-    const arrows = document.querySelectorAll("th .sort-arrow");
-    arrows.forEach(a => a.textContent = "");
+    const table = document.getElementById("hosts-table");
+    if (!table) return;
+
+    // reset ARIA e classi
+    table.querySelectorAll("thead th").forEach(th => {
+        th.setAttribute("aria-sort", "none");
+        th.classList.remove("is-sorted");
+        const arrow = th.querySelector(".sort-arrow");
+        if (arrow) arrow.classList.remove("asc", "desc");
+    });
+
+    lastSort = null;
 }
 
 // -----------------------------
 // INITIAL TABLE LOAD
 // -----------------------------
-loadHosts();
-document.getElementById("searchInput").value = "";
+document.addEventListener("DOMContentLoaded", async () => {
+    // 1) Init UI sort (aria-sort, arrows)
+    initSortableTable();
 
-document.addEventListener("keydown", (e) => {
-    if (e.key === "Escape") {
-        resetSorting();  
-        clearSearch();
+    // 2) Load data (hosts)
+    try {
+        await loadHosts();
+    } catch (err) {
+        console.error("Errore nel caricamento degli host:", err);
+        showToast("Errore nel caricamento degli host", false);
     }
+
+    // 3) search bar
+    const input = document.getElementById("searchInput");
+    if (input) {
+        // clean input on load
+        input.value = "";
+
+        // live filter for each keystroke
+        input.addEventListener("input", filterHosts);
+
+        // 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();
+                clearSearch();          // svuota input e ricarica tabella (come definito nella tua funzione)
+            }
+        });
+  }
+
+    // 4) 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) {
+            e.preventDefault();
+            resetSorting();
+            clearSearch();
+        }
+    });
 });