]> git.giorgioravera.it Git - network-manager.git/commitdiff
Added aliases management
authorGiorgio Ravera <giorgio.ravera@gmail.com>
Wed, 18 Feb 2026 18:28:57 +0000 (19:28 +0100)
committerGiorgio Ravera <giorgio.ravera@gmail.com>
Wed, 18 Feb 2026 18:28:57 +0000 (19:28 +0100)
backend/db/aliases.py [new file with mode: 0644]
backend/db/hosts.py
backend/main.py
backend/routes/aliases.py [new file with mode: 0644]
entrypoint.py
frontend/aliases.html [new file with mode: 0644]
frontend/hosts.html
frontend/js/aliases.js [new file with mode: 0644]
frontend/js/hosts.js

diff --git a/backend/db/aliases.py b/backend/db/aliases.py
new file mode 100644 (file)
index 0000000..0cbaf2a
--- /dev/null
@@ -0,0 +1,169 @@
+# backend/db/aliases.py
+
+# Import standard modules
+import ipaddress
+import logging
+import os
+import re
+import sqlite3
+# Import local modules
+from backend.db.db import get_db, register_init
+# Import Settings
+from settings.settings import settings
+# Import Log
+from log.log import get_logger
+
+# -----------------------------
+# Check Data Input
+# -----------------------------
+def validate_data(data: dict) -> dict:
+    # Check name
+    if "name" not in data:
+        raise ValueError("Missing required field: name")
+    name = str(data["name"]).strip()
+    if not name:
+        raise ValueError("Field 'name' cannot be empty")
+
+    # Check target
+    if "target" not in data:
+        raise ValueError("Missing required field: target")
+    target = str(data["target"]).strip()
+    if not target:
+        raise ValueError("Field 'target' cannot be empty")
+
+    # Check note
+    note = data.get("note")
+
+    # Boolean normalization for DB (0/1)
+    ssl_enabled = int(bool(data.get("ssl_enabled", 0)))
+
+    return {
+        "name": name,
+        "target": target,
+        "note": note,
+        "ssl_enabled": ssl_enabled,
+    }
+
+# -----------------------------
+# SELECT ALL ALIASES
+# -----------------------------
+def get_aliases():
+    conn = get_db()
+    cur = conn.execute("SELECT * FROM aliases ORDER BY target")
+    rows = [dict(r) for r in cur.fetchall()]
+    return rows
+
+# -----------------------------
+# SELECT SINGLE ALIAS
+# -----------------------------
+def get_alias(alias_id: int):
+    conn = get_db()
+    cur = conn.execute("SELECT * FROM aliases WHERE id = ?", (alias_id,))
+    row = cur.fetchone()
+    return dict(row) if row else None
+
+# -----------------------------
+# ADD ALIAS
+# -----------------------------
+def add_alias(data: dict):
+
+    # Validate input
+    cleaned = validate_data(data)
+
+    conn = get_db()
+    try:
+        cur = conn.execute(
+            "INSERT INTO aliases (name, target, note, ssl_enabled) VALUES (?, ?, ?, ?)",
+            (
+                cleaned["name"],
+                cleaned["target"],
+                cleaned["note"],
+                cleaned["ssl_enabled"],
+            )
+        )
+        conn.commit()
+        return cur.lastrowid
+
+    except sqlite3.IntegrityError as e:
+        conn.rollback()
+        return -1
+
+    except Exception as e:
+        conn.rollback()
+        raise
+
+# -----------------------------
+# UPDATE ALIAS
+# -----------------------------
+def update_alias(alias_id: int, data: dict) -> bool:
+
+    # Validate input
+    cleaned = validate_data(data)
+
+    conn = get_db()
+    try:
+        cur = conn.execute(
+            """
+            UPDATE aliases
+            SET name=?, target=?, note=?, ssl_enabled=?
+            WHERE id=?
+            """,
+            (
+                cleaned["name"],
+                cleaned["target"],
+                cleaned["note"],
+                cleaned["ssl_enabled"],
+                alias_id,
+            )
+        )
+        conn.commit()
+        return cur.rowcount > 0
+
+    except Exception:
+        conn.rollback()
+        raise
+
+# -----------------------------
+# DELETE ALIAS
+# -----------------------------
+def delete_alias(alias_id: int) -> bool:
+
+    # Validate input
+    if alias_id is None:
+        raise ValueError("alias_id cannot be None")
+
+    conn = get_db()
+    try:
+        cur = conn.execute(
+            "DELETE FROM aliases WHERE id = ?",
+            (alias_id,)
+        )
+        conn.commit()
+
+        return cur.rowcount > 0
+
+    except Exception:
+        conn.rollback()
+        raise
+
+# -----------------------------
+# Initialize Aliases DB Table
+# -----------------------------
+@register_init
+def init_db_alias_table(cur):
+    logger = get_logger(__name__)
+
+    # ALIASES TABLE
+    cur.execute("""
+        CREATE TABLE aliases (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            name TEXT NOT NULL UNIQUE,
+            target TEXT NOT NULL,
+            note TEXT,
+            ssl_enabled INTEGER NOT NULL DEFAULT 0
+        );
+    """)
+    cur.execute("CREATE INDEX idx_aliases_name ON aliases(name);")
+
+    logger.info("ALIASES DB: Database initialized successfully for %s", settings.DOMAIN)
+    logger.info("ALIASES DB: Public IP: %s", settings.PUBLIC_IP)
index 3287f274f11d01779760c00952689b175f4d5bbd..7b0a639043c59b0eced3abf031aaee2dcd4526cd 100644 (file)
@@ -20,13 +20,14 @@ MAC_RE = re.compile(r"^([0-9A-Fa-f]{2}([:\-])){5}([0-9A-Fa-f]{2})$")
 # Check Data Input
 # -----------------------------
 def validate_data(data: dict) -> dict:
+    # Check name
     if "name" not in data:
         raise ValueError("Missing required field: name")
-
     name = str(data["name"]).strip()
     if not name:
         raise ValueError("Field 'name' cannot be empty")
 
+    # Check IPv4
     ipv4 = data.get("ipv4")
     if ipv4:
         try:
@@ -34,6 +35,7 @@ def validate_data(data: dict) -> dict:
         except ValueError:
             raise ValueError(f"Invalid IPv4 address: {ipv4}")
 
+    # Check IPv6
     ipv6 = data.get("ipv6")
     if ipv6:
         try:
@@ -45,6 +47,7 @@ def validate_data(data: dict) -> dict:
     if mac and not MAC_RE.match(mac):
         raise ValueError(f"Invalid MAC address: {mac}")
 
+    # Check note
     note = data.get("note")
 
     # Normalizzazione boolean per DB (0/1)
@@ -210,19 +213,6 @@ def init_db_hosts_table(cur):
     """)
     cur.execute("CREATE INDEX idx_hosts_name ON hosts(name);")
 
-    # ALIASES TABLE
-    cur.execute("""
-        CREATE TABLE aliases (
-            id INTEGER PRIMARY KEY AUTOINCREMENT,
-            host_id INTEGER NOT NULL,
-            alias TEXT NOT NULL,
-            note TEXT,
-            ssl_enabled INTEGER NOT NULL DEFAULT 0,
-            FOREIGN KEY (host_id) REFERENCES hosts(id)
-        );
-    """)
-    cur.execute("CREATE INDEX idx_aliases_host ON aliases(host_id);")
-
     # TXT TABLE
     cur.execute("""
         CREATE TABLE txt_records (
index 4984ad0d25b3c244bb52e2281a92d3e53ca631c3..1e9ba84ed878277e07edd6a0e0a26e4f089b2c7d 100644 (file)
@@ -14,6 +14,7 @@ from backend.routes.backup import router as backup_router
 from backend.routes.health import router as health_router
 from backend.routes.login import router as login_router
 from backend.routes.hosts import router as hosts_router
+from backend.routes.aliases import router as aliases_router
 from backend.routes.dns import router as dns_router
 from backend.routes.dhcp import router as dhcp_router
 # Import Security
@@ -108,6 +109,7 @@ app.include_router(backup_router)
 app.include_router(health_router)
 app.include_router(login_router)
 app.include_router(hosts_router)
+app.include_router(aliases_router)
 app.include_router(dns_router)
 app.include_router(dhcp_router)
 
diff --git a/backend/routes/aliases.py b/backend/routes/aliases.py
new file mode 100644 (file)
index 0000000..41b3ee8
--- /dev/null
@@ -0,0 +1,266 @@
+# backend/routes/aliases.py
+
+# import standard modules
+from fastapi import APIRouter, Request, Response, HTTPException, status
+from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
+import ipaddress
+import time
+import os
+# Import local modules
+from backend.db.aliases import (
+    get_aliases,
+    get_alias,
+    add_alias,
+    update_alias,
+    delete_alias
+)
+# Import Settings
+from settings.settings import settings
+# Import Logging
+from log.log import setup_logging, get_logger
+
+# Create Router
+router = APIRouter()
+
+# ---------------------------------------------------------
+# FRONTEND PATHS (absolute paths inside Docker)
+# ---------------------------------------------------------
+# Aliass page
+@router.get("/aliases")
+def aliases(request: Request):
+    return FileResponse(os.path.join(settings.FRONTEND_DIR, "aliases.html"))
+
+# Serve aliases.js
+@router.get("/js/aliases.js")
+def css_aliases():
+    return FileResponse(os.path.join(settings.FRONTEND_DIR, "js/aliases.js"))
+
+# ---------------------------------------------------------
+# Get Aliass
+# ---------------------------------------------------------
+@router.get("/api/aliases", status_code=status.HTTP_200_OK, responses={
+    200: {"description": "Aliass found"},
+    500: {"description": "Internal server error"},
+})
+def api_get_aliases(request: Request):
+    try:
+        aliases = get_aliases()
+        return aliases or []
+
+    except Exception as e:
+        logger = get_logger("aliases")
+        logger.exception("Error getting list alias %s", str(e).strip())
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail={
+                "code": "ALIASES_GET_ERROR",
+                "status": "failure",
+                "message": "Internal error getting alias",
+            },
+        )
+
+# ---------------------------------------------------------
+# Get Alias
+# ---------------------------------------------------------
+@router.get("/api/aliases/{alias_id}", status_code=status.HTTP_200_OK, responses={
+    200: {"description": "Alias found"},
+    404: {"description": "Alias not found"},
+    500: {"description": "Internal server error"},
+})
+def api_get_alias(request: Request, alias_id: int):
+
+    try:
+        alias = get_alias(alias_id)
+        if not alias:  # None or empty dict
+            raise HTTPException(
+                status_code=status.HTTP_404_NOT_FOUND,
+                detail={
+                    "code": "ALIAS_NOT_FOUND",
+                    "status": "failure",
+                    "message": "Alias not found",
+                    "alias_id": alias_id,
+                },
+            )
+        return alias
+
+    except Exception as e:
+        logger = get_logger("aliases")
+        logger.exception("Error adding alias %s: %s", alias_id, str(e).strip())
+        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail={
+                "code": "ALIAS_GET_ERROR",
+                "status": "failure",
+                "message": "Internal error getting alias",
+                "took_ms": took_ms,
+            },
+        )
+
+# ---------------------------------------------------------
+# Add Aliass
+# ---------------------------------------------------------
+@router.post("/api/aliases", status_code=status.HTTP_200_OK, responses={
+    200: {"description": "Alias added"},
+    409: {"description": "Alias already present"},
+    500: {"description": "Internal server error"},
+})
+def api_add_alias(request: Request, data: dict):
+
+    # Inizializzazioni
+    start_ns = time.monotonic_ns()
+
+    try:
+        alias_id = add_alias(data)
+        if(alias_id > 0):
+            took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+            return JSONResponse(
+                status_code=status.HTTP_200_OK,
+                content={
+                    "code": "ALIAS_ADDED",
+                    "status": "success",
+                    "message": "Alias added successfully",
+                    "alias_id": alias_id,
+                    "took_ms": took_ms,
+                },
+            )
+
+        # Already present
+        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+        raise HTTPException(
+            status_code=status.HTTP_409_CONFLICT,
+            detail={
+                "code": "ALIAS_ALREADY_PRESENT",
+                "status": "failure",
+                "message": "Alias already present",
+                "took_ms": took_ms,
+            },
+        )
+
+    except HTTPException as httpe:
+        raise httpe
+
+    except Exception as e:
+        logger = get_logger("aliases")
+        logger.exception("Error adding alias: %s", str(e).strip())
+        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail={
+                "code": "ALIAS_ADD_ERROR",
+                "status": "failure",
+                "message": "Internal error adding alias",
+                "took_ms": took_ms,
+            },
+        )
+
+# ---------------------------------------------------------
+# Update Alias
+# ---------------------------------------------------------
+@router.put("/api/aliases/{alias_id}", status_code=status.HTTP_200_OK, responses={
+    200: {"description": "Alias updated"},
+    404: {"description": "Alias not found"},
+    500: {"description": "Internal server error"},
+})
+def api_update_alias(request: Request, data: dict, alias_id: int):
+
+    # Inizializzazioni
+    start_ns = time.monotonic_ns()
+
+    try:
+        updated = update_alias(alias_id, data)
+        if updated:
+            took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+            return JSONResponse(
+                status_code=status.HTTP_200_OK,
+                content={
+                    "code": "ALIAS_UPDATED",
+                    "status": "success",
+                    "message": "Alias updated successfully",
+                    "alias_id": alias_id,
+                    "took_ms": took_ms,
+                },
+            )
+
+        # Not Found
+        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail={
+                "code": "ALIAS_NOT_FOUND",
+                "status": "failure",
+                "message": "Alias not found",
+                "alias_id": alias_id,
+                "took_ms": took_ms,
+            },
+        )
+
+    except Exception as e:
+        logger = get_logger("aliases")
+        logger.exception("Error updating alias %s: %s", alias_id, str(e).strip())
+        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail={
+                "code": "ALIAS_UPDATE_ERROR",
+                "status": "failure",
+                "message": "Internal error updating alias",
+                "alias_id": alias_id,
+                "took_ms": took_ms,
+            },
+        )
+
+# ---------------------------------------------------------
+# Delete
+# ---------------------------------------------------------
+@router.delete("/api/aliases/{alias_id}", status_code=status.HTTP_200_OK, responses={
+    200: {"description": "Alias deleted"},
+    404: {"description": "Alias not found"},
+    500: {"description": "Internal server error"},
+})
+def api_delete_alias(request: Request, alias_id: int):
+
+    # Inizializzazioni
+    start_ns = time.monotonic_ns()
+
+    try:
+        deleted = delete_alias(alias_id)
+        if deleted:
+            took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+            return JSONResponse(
+                status_code=status.HTTP_200_OK,
+                content={
+                    "code": "ALIAS_DELETED",
+                    "status": "success",
+                    "message": "Alias deleted successfully",
+                    "details": {"took_ms": took_ms, "alias_id": alias_id,},
+                },
+            )
+
+        # Not Found
+        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail={
+                "code": "ALIAS_NOT_FOUND",
+                "status": "failure",
+                "message": "Alias not found",
+                "alias_id": alias_id,
+                "took_ms": took_ms,
+            },
+        )
+
+    except Exception as e:
+        logger = get_logger("aliases")
+        logger.exception("Error deleting alias %s: %s", alias_id, str(e).strip())
+        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail={
+                "code": "ALIAS_DELETE_ERROR",
+                "status": "failure",
+                "message": "Internal error deleting alias",
+                "alias_id": alias_id,
+                "took_ms": took_ms,
+            },
+        )
index 5b25579424a6691695af3208f4dc44f87d6ae3dc..29ec408072e3e75376a0b0b708f373496a037035 100755 (executable)
@@ -7,8 +7,9 @@ import sys
 import argparse
 # Import local modules
 from backend.db.db import init_db
-import backend.db.hosts
 import backend.db.users
+import backend.db.hosts
+import backend.db.aliases
 # Import Settings
 from settings.settings import settings
 # Import Log
diff --git a/frontend/aliases.html b/frontend/aliases.html
new file mode 100644 (file)
index 0000000..f7beff8
--- /dev/null
@@ -0,0 +1,167 @@
+<!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">
+            <div class="logo">
+                <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>
+            </div>
+
+            <button id="logoutBtn" class="btn btn-primary">Logout</button>
+        </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>
+                        <!--<i class="bi bi-hdd-network"></i>-->
+                        <span class="section-title">Aliases List</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="Ricerca..."
+                            oninput="filterAliases()"
+                            class="form-control form-control-sm"
+                            aria-label="Search alias"
+                        >
+                    </div>
+                </div>
+
+                <!-- Bottoni -->
+                <div class="col-12 col-md-auto d-flex gap-2 flex-wrap">
+                    <button class="btn btn-primary" title="Add Alias" aria-label="Add Alias"
+                            data-bs-toggle="modal" data-bs-target="#addAliasModal">
+                        <i class="bi bi-plus-lg"></i><span class="label"> Add Alias</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="hosts-table" class="table table-bordered table-hover align-middle">
+        <thead class="table-light">
+            <tr>
+                <th data-type="string" onclick="sortTable(0)">Alias  <span class="sort-arrow" aria-hidden="true"></span></th>
+                <th data-type="string" onclick="sortTable(1)">Target <span class="sort-arrow" aria-hidden="true"></span></th>
+                <th data-type="string" onclick="sortTable(2)">Note   <span class="sort-arrow" aria-hidden="true"></span></th>
+                <th data-type="string" onclick="sortTable(3)">SSL    <span class="sort-arrow" aria-hidden="true"></span></th>
+                <th data-type="string">Actions <span class="sort-arrow" aria-hidden="true"></span></th>
+            </tr>
+        </thead>
+        <tbody></tbody>
+    </table>
+
+    <div class="modal fade" id="addAliasModal" tabindex="-1" aria-labelledby="addAliasTitle" 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="addAliasTitle">Aggiungi Alias</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="addAliasForm" onsubmit="return handleAddAliasSubmit(event)">
+                        <div class="mb-2">
+                            <label for="aliasName" class="form-label">Alias</label>
+                            <input type="text" id="aliasName" class="form-control" required>
+                        </div>
+
+                        <div class="mb-2">
+                            <label for="aliasTarget" class="form-label">Target</label>
+                            <input type="text" id="aliasTarget" class="form-control" required>
+                        </div>
+
+                        <div class="mb-2">
+                            <label for="aliasNote" class="form-label">Note</label>
+                            <input type="text" id="aliasNote" class="form-control">
+                        </div>
+
+                        <div class="form-check my-2">
+                            <input class="form-check-input" type="checkbox" id="aliasSSL">
+                            <label class="form-check-label" for="aliasSSL">SSL?</label>
+                        </div>
+                    </form>
+                </div>
+
+                <div class="modal-footer">
+                    <button type="submit" form="addAliasForm" 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 src="js/aliases.js"></script>
+    <script src="js/session.js"></script>
+
+    <!-- Bootstrap JS -->
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
+            integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
+            crossorigin="anonymous"></script>
+</body>
+</html>
index 148ed061d0115e629c3cabc19f39f183788e2b98..a00f4975da473d7538c55cafabeb18570c3b7ec4 100644 (file)
     <table id="hosts-table" class="table table-bordered table-hover align-middle">
         <thead class="table-light">
             <tr>
-                <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 data-type="string" onclick="sortTable(0)">Hostname <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 data-type="string">Actions <span class="sort-arrow" aria-hidden="true"></span></th>
             </tr>
         </thead>
                 <div class="modal-body">
                     <form id="addHostForm" onsubmit="return handleAddHostSubmit(event)">
                         <div class="mb-2">
-                            <label for="hostName" class="form-label">Nome</label>
+                            <label for="hostName" class="form-label">Hostname</label>
                             <input type="text" id="hostName" class="form-control" required>
                         </div>
 
-                        <!--<div class="row g-2">
-                            <div class="col-12 col-md-6">
-                                <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="col-12 col-md-6">
-                                <label for="hostIPv6" class="form-label">IPv6</label>
-                                <input type="text" id="hostIPv6" class="form-control" placeholder="es. fe80::1">
-                            </div>
-                        </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">
diff --git a/frontend/js/aliases.js b/frontend/js/aliases.js
new file mode 100644 (file)
index 0000000..a6e3a51
--- /dev/null
@@ -0,0 +1,1022 @@
+// -----------------------------
+// Configuration parameters
+// -----------------------------
+let timeoutToast = 3000; // milliseconds
+
+// -----------------------------
+// State variables
+// -----------------------------
+let editingAliasId = null;
+let sortDirection = {};
+let lastSort = null; // { colIndex: number, ascending: boolean }
+const stringCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
+
+// -----------------------------
+// Load all aliases into the table
+// -----------------------------
+async function loadAliases() {
+    let aliases = [];
+    try {
+        // Fetch data
+        const res = await fetch(`/api/aliases`, {
+            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();
+            aliases = 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 aliases`;
+            const err = new Error(serverMsg ? `${base}: ${serverMsg}` : base);
+            err.status = res.status;
+            throw err;
+        }
+
+    } catch (err) {
+        console.error(err?.message || "Error loading aliases");
+        showToast(err?.message || "Error loading aliase", false);
+        aliases = [];
+    }
+
+    // DOM Reference
+    const tbody = document.querySelector("#hosts-table tbody");
+    if (!tbody) {
+        console.warn('Element "#hosts-table tbody" not found in DOM.');
+        return;
+    }
+
+    // Svuota la tabella
+    tbody.innerHTML = "";
+
+    // if no aliases, show an empty row
+    if (!aliases.length) {
+        const trEmpty = document.createElement("tr");
+        const tdEmpty = document.createElement("td");
+        tdEmpty.colSpan = 7;
+        tdEmpty.textContent = "No alias available.";
+        tdEmpty.style.textAlign = "center";
+        trEmpty.appendChild(tdEmpty);
+        tbody.appendChild(trEmpty);
+        return;
+    }
+
+    // fragment per performance
+    const frag = document.createDocumentFragment();
+
+    aliases.forEach(h => {
+        const tr = document.createElement("tr");
+
+        // Name
+        {
+            const td = document.createElement("td");
+            const val = (h.name ?? "").toString();
+            td.textContent = val;
+            if (val) td.setAttribute("data-value", val.toLowerCase());
+            tr.appendChild(td);
+        }
+
+        // Target
+        {
+            const td = document.createElement("td");
+            const val = (h.target ?? "").toString();
+            td.textContent = val;
+            if (val) td.setAttribute("data-value", val.toLowerCase());
+            tr.appendChild(td);
+        }
+
+        // Note
+        {
+            const td = document.createElement("td");
+            const val = (h.note ?? "").toString();
+            td.textContent = val;
+            if (val) td.setAttribute("data-value", val.toLowerCase());
+            tr.appendChild(td);
+        }
+
+        // SSL (icon)
+        {
+            const td = document.createElement("td");
+            const sslEnabled = !!h.ssl_enabled;
+            td.setAttribute("data-value", sslEnabled ? "true" : "false");
+            td.setAttribute("aria-label", sslEnabled ? "SSL attivo" : "SSL non attivo");
+            td.style.textAlign = "center";
+            td.style.verticalAlign = "middle";
+            if (sslEnabled) {
+                const icon = document.createElement("i");
+                icon.className = "bi bi-shield-lock-fill icon icon-static";
+                icon.setAttribute("aria-hidden", "true");
+                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(h.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 = "Edit alias";
+        editSpan.setAttribute("aria-label", "Edit alias");
+        editSpan.setAttribute("data-bs-toggle", "modal");
+        editSpan.setAttribute("data-bs-target", "#addAliasModal");
+        editSpan.setAttribute("data-action", "edit");
+        editSpan.setAttribute("data-alias-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);
+        }
+
+        const delSpan = document.createElement("span");
+        delSpan.className = "action-icon";
+        delSpan.setAttribute("role", "button");
+        delSpan.tabIndex = 0;
+        delSpan.title = "Delete alias";
+        delSpan.setAttribute("aria-label", "Delete alias");
+        delSpan.setAttribute("data-action", "delete");
+        delSpan.setAttribute("data-alias-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);
+        }
+    }
+}
+
+// -----------------------------
+// Edit Alias: load data and pre-fill the form
+// -----------------------------
+async function editAlias(id) {
+    // Clear form first
+    clearAddAliasForm();
+
+    // Fetch alias
+    const res = await fetch(`/api/aliases/${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 alias ${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 alias ${id}: Invalid JSON payload`);
+    }
+
+    // Check JSON errors
+    if (!res.ok) {
+        const serverMsg = data?.detail?.message?.trim();
+        const base = `Fetch failed for alias ${id}`;
+        const err = new Error(serverMsg ? `${base}: ${serverMsg}` : base);
+        err.status = res.status;
+        throw err;
+    }
+
+    // Store the ID of the alias being edited
+    editingAliasId = id;
+
+    // Pre-fill the form fields
+    document.getElementById("aliasName").value = data.name ?? "";
+    document.getElementById("aliasTarget").value = data.target ?? "";
+    document.getElementById("aliasNote").value = data.note ?? "";
+    document.getElementById("aliasSSL").checked = !!data.ssl_enabled;
+}
+
+// -----------------------------
+// Save alias (CREATE OR UPDATE)
+// -----------------------------
+async function saveAlias(aliasData) {
+    // Validate alias
+    if (!aliasData.name.trim()) {
+        showToast("Alias is required", false);
+        return false;
+    }
+    // Validate Target
+    if (!aliasData.target.trim()) {
+        showToast("Target is required", false);
+        return false;
+    }
+
+    if (editingAliasId !== null) {
+        // Update existing alias
+        const res = await fetch(`/api/aliases/${editingAliasId}`, {
+            method: 'PUT',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify(aliasData)
+        });
+
+        // Success without JSON
+        if (res.status === 204) {
+            showToast('Alias 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 alias`;
+            const err = new Error(serverMsg ? `${base}: ${serverMsg}` : base);
+            err.status = res.status;
+            throw err;
+        }
+
+        // Success
+        showToast(data?.message || 'Alias updated successfully', true);
+        return true;
+
+    } else {
+        // Create new alias
+        const res = await fetch(`/api/aliases`, {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify(aliasData)
+        });
+
+        // Success without JSON
+        if (res.status === 204) {
+            showToast('Alias 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 alias`;
+            const err = new Error(serverMsg ? `${base}: ${serverMsg}` : base);
+            err.status = res.status;
+            throw err;
+        }
+
+        // Success
+        showToast(data?.message || 'Alias created successfully', true);
+        return true
+    }
+}
+
+// -----------------------------
+// Prepare add alias form
+// -----------------------------
+function clearAddAliasForm() {
+    // reset edit mode
+    editingAliasId = null;
+    // reset form fields
+    document.getElementById('addAliasForm')?.reset();
+}
+
+// -----------------------------
+// Close popup
+// -----------------------------
+async function closeAddAliasModal() {
+    const modalEl = document.getElementById('addAliasModal');
+    const modal = bootstrap.Modal.getInstance(modalEl)
+               || bootstrap.Modal.getOrCreateInstance(modalEl);
+    modal.hide();
+}
+
+// -----------------------------
+// Handle Add alias form submit
+// -----------------------------
+async function handleAddAliasSubmit(e) {
+    // Prevent default form submission
+    e.preventDefault();
+
+    try {
+        // Retrieve form data
+        const data = {
+            name:  document.getElementById('aliasName').value.trim(),
+            target: document.getElementById('aliasTarget').value.trim(),
+            note:   document.getElementById('aliasNote').value.trim(),
+            ssl_enabled: document.getElementById('aliasSSL').checked ? 1 : 0
+        };
+
+        const ok = await saveAlias(data);
+        if (ok !== false) {
+            // close modal and reload aliases
+            closeAddAliasModal();
+            await loadAliases();
+            return true
+        }
+
+    } catch (err) {
+        console.error(err?.message || "Error saving alias");
+        showToast(err?.message || "Error saving alias", false);
+    }
+
+    return false;
+}
+
+// -----------------------------
+// Handle delete alias action
+// -----------------------------
+async function handleDeleteAlias(e, el) {
+    // Prevent default action
+    e.preventDefault();
+
+    // Get alias ID
+    const id = Number(el.dataset.aliasId);
+    if (!Number.isFinite(id)) {
+        console.warn('Delete: alias id not valid for delete:', id);
+        showToast('Alias id not valid for delete', false);
+        return;
+    }
+
+    // Execute delete
+    try {
+        // Fetch data
+        const res = await fetch(`/api/aliases/${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 alias`;
+            const err = new Error(serverMsg ? `${base}: ${serverMsg}` : base);
+            err.status = res.status;
+            throw err;
+        }
+
+        // Success
+        showToast(data?.message || 'Alias deleted successfully', true);
+
+        // Reload aliases
+        await loadAliases();
+        return true;
+
+    } catch (err) {
+        console.error(err?.message || "Error deleting alias");
+        showToast(err?.message || "Error deleting alias", false);
+    }
+
+    return false;
+}
+
+// -----------------------------
+// Handle reload DNS action
+// -----------------------------
+async function handleReloadDNS() {
+    try {
+        // Fetch data
+        const res = await fetch(`/api/dns/reload`, {
+            method: 'POST',
+            headers: { 'Accept': 'application/json' },
+        });
+
+        // Success without JSON
+        if (res.status === 204) {
+            showToast('DNS reload 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 reloading DNS`;
+            const err = new Error(serverMsg ? `${base}: ${serverMsg}` : base);
+            err.status = res.status;
+            throw err;
+        }
+
+        // Success
+        showToast(data?.message || 'DNS reload successfully', true);
+        return true;
+
+    } catch (err) {
+        console.error(err?.message || "Error reloading DNS");
+        showToast(err?.message || "Error reloading DNS", false);
+    }
+
+    return false;
+}
+
+// -----------------------------
+// Handle reload DHCP action
+// -----------------------------
+async function handleReloadDHCP() {
+    try {
+        // Fetch data
+        const res = await fetch(`/api/dhcp/reload`, {
+            method: 'POST',
+            headers: { 'Accept': 'application/json' },
+        });
+
+        // Success without JSON
+        if (res.status === 204) {
+            showToast('DHCP reload 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('Error reloading DHCP: Invalid JSON payload');
+        }
+
+        // Check JSON errors
+        if (!res.ok) {
+            const serverMsg = data?.detail?.message?.trim();
+            const base = `Error reloadin DHCP`;
+            const err = new Error(serverMsg ? `${base}: ${serverMsg}` : base);
+            err.status = res.status;
+            throw err;
+        }
+
+        // Success
+        showToast(data?.message || 'DHCP reload successfully', true);
+        return true;
+
+    } catch (err) {
+        console.error(err?.message || "Error reloading DHCP");
+        showToast(err?.message || "Error reloading DHCP", false);
+    }
+
+    return false;
+}
+
+// -----------------------------
+// Display a temporary notification message
+// -----------------------------
+function showToast(message, success = true) {
+    const toast = document.getElementById("toast");
+    toast.textContent = message;
+
+    toast.style.background = success ? "#28a745" : "#d9534f"; // green / red
+
+    toast.classList.add("show");
+
+    setTimeout(() => {
+        toast.classList.remove("show");
+    }, timeoutToast);
+}
+
+// -----------------------------
+// filter aliases in the table
+// -----------------------------
+function filterAliases() {
+    const query = document.getElementById("searchInput").value.toLowerCase();
+    const rows = document.querySelectorAll("#hosts-table 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 loadAliases();
+}
+
+/**
+ * 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.
+    });
+}
+
+// -----------------------------
+// Sort the table by column
+// -----------------------------
+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 ths = table.querySelectorAll("thead th");
+    const type = ths[colIndex]?.dataset?.type || "string";
+
+    // Toggle direction
+    sortDirection[colIndex] = !sortDirection[colIndex];
+    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) };
+    });
+
+    parsed.sort((a, b) => {
+        const c = comparator(a.parsed, b.parsed);
+        return c !== 0 ? c * direction : (a.idx - b.idx); // tie-breaker
+    });
+
+    // Re-append in un DocumentFragment (più efficiente)
+    const frag = document.createDocumentFragment();
+    parsed.forEach(p => frag.appendChild(p.row));
+    tbody.appendChild(frag);
+
+    updateSortUI(table, colIndex, ascending);
+
+    lastSort = { colIndex, ascending };
+}
+
+/**
+ * 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() {
+    // azzera lo stato
+    sortDirection = {};
+
+    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;
+}
+
+// -----------------------------
+// RELOAD DNS
+// -----------------------------
+function reloadDNS() {
+    // Implement DNS reload logic here
+    showToast("DNS reloaded successfully");
+}
+
+// -----------------------------
+// RELOAD DHCP
+// -----------------------------
+function reloadDHCP() {
+    // Implement DHCP reload logic here
+    showToast("DHCP reloaded successfully");
+}
+
+// -----------------------------
+// Action Handlers
+// -----------------------------
+const actionHandlers = {
+  async delete(e, el) { handleDeleteAlias(e, el); },
+
+  // edit is handled by bootstrap modal show event
+  edit(e, el) {
+    // no-op o log
+  },
+
+  // Reload DNS
+  async reloadDns()  { handleReloadDNS();  },
+
+  // Reload DHCP
+  async reloadDhcp() { handleReloadDHCP(); },
+};
+
+// -----------------------------
+// DOMContentLoaded: initialize everything
+// -----------------------------
+document.addEventListener("DOMContentLoaded", async () => {
+    // 1) Init UI sort (aria-sort, arrows)
+    initSortableTable();
+
+    // 2) Load data (aliases)
+    try {
+        await loadAliases();
+    } catch (err) {
+        console.error("Error loading aliases:", err);
+        showToast("Error loading aliases:", 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", filterAliases);
+        // 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) {
+            // Prevent default form submission
+            e.preventDefault();
+            resetSorting();
+            clearSearch();
+        }
+    });
+
+    // 5) Modal show/hidden events to prepare/reset the form
+    const modalEl = document.getElementById('addAliasModal');
+    if (modalEl) {
+
+        // store who opened the modal
+        let lastTriggerEl = null;
+
+        // When shown, determine Add or Edit mode
+        modalEl.addEventListener('show.bs.modal', async (ev) => {
+            const lastTriggerEl = ev.relatedTarget; // trigger (Add o Edit)
+            const formEl = document.getElementById('addAliasForm');
+
+            // Security check
+            if (!formEl) return;
+
+            // check Add or Edit mode
+            const idAttr = lastTriggerEl?.getAttribute?.('data-alias-id');
+            const id = idAttr ? Number(idAttr) : null;
+
+            if (Number.isFinite(id)) {
+                // Edit Mode
+                console.log("Modal in EDIT mode for alias ID:", id);
+                try {
+                    await editAlias(id);
+                } catch (err) {
+                    showToast(err?.message || "Error loading alias for edit", false);
+                    // Close modal
+                    const closeOnShown = () => {
+                        closeAddAliasModal(lastTriggerEl);
+                        modalEl.removeEventListener('shown.bs.modal', closeOnShown);
+                    };
+                    modalEl.addEventListener('shown.bs.modal', closeOnShown);
+                }
+            } else {
+                // Add Mode
+                console.log("Modal in CREATE mode");
+                clearAddAliasForm();
+                // Set focus to the first input field when modal is shown
+                const focusOnShown = () => {
+                    document.getElementById('aliasName')?.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(); // fallback: evita warning A11Y
+                }
+            }
+        });
+
+        // When hidden, reset the form
+        modalEl.addEventListener('hidden.bs.modal', () => {
+            // reset form fields
+            clearAddAliasForm();
+            // pulizia ref del trigger
+            lastTriggerEl = null;
+        });
+    }
+
+    // 6) Button event delegation (click and keydown)
+    {
+        // Click event
+        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', false);
+                showToast(err?.message || 'Action error', false);
+            }
+        });
+
+        // Keydown (Enter, Space) for accessibility
+        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;
+
+            // Trigger click event
+            el.click();
+        });
+    }
+});
index 01fe81cbbefe3f955cfad22f82ab37660b9dfd94..836b95c73936471aee9b2b89952181abaaba2184 100644 (file)
@@ -53,7 +53,7 @@ function isValidMAC(mac) {
 }
 
 // -----------------------------
-// LOAD ALL HOSTS INTO THE TABLE
+// Load all hosts into the table
 // -----------------------------
 async function loadHosts() {
     let hosts = [];
@@ -250,7 +250,7 @@ async function loadHosts() {
 }
 
 // -----------------------------
-// Edit HOST: load data and pre-fill the form
+// Edit Host: load data and pre-fill the form
 // -----------------------------
 async function editHost(id) {
     // Clear form first
@@ -270,16 +270,16 @@ async function editHost(id) {
     }
 
     // Check JSON
-    let host;
+    let data;
     try {
-        host = await res.json();
+        data = await res.json();
     } catch {
         throw new Error(`Fetch failed for host ${id}: Invalid JSON payload`);
     }
 
     // Check JSON errors
     if (!res.ok) {
-        const serverMsg = host?.detail?.message?.trim();
+        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;
@@ -290,21 +290,21 @@ async function editHost(id) {
     editingHostId = id;
 
     // Pre-fill the form fields
-    document.getElementById("hostName").value = host.name ?? "";
-    document.getElementById("hostIPv4").value = host.ipv4 ?? "";
-    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;
+    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("hostNote").value = data.note ?? "";
+    document.getElementById("hostSSL").checked = !!data.ssl_enabled;
 }
 
 // -----------------------------
-// SAVE HOST (CREATE OR UPDATE)
+// Save host (CREATE OR UPDATE)
 // -----------------------------
 async function saveHost(hostData) {
-    // Validate required fields
+    // Validate hostname
     if (!hostData.name.trim()) {
-        showToast("Name is required", false);
+        showToast("Hostname is required", false);
         return false;
     }
     // Validate IPv4 format
@@ -324,7 +324,7 @@ async function saveHost(hostData) {
     }
 
     if (editingHostId !== null) {
-        // UPDATE EXISTING HOST
+        // Update existing host
         const res = await fetch(`/api/hosts/${editingHostId}`, {
             method: 'PUT',
             headers: { 'Content-Type': 'application/json' },
@@ -367,7 +367,7 @@ async function saveHost(hostData) {
         return true;
 
     } else {
-        // CREATE NEW HOST
+        // Create new host
         const res = await fetch(`/api/hosts`, {
             method: 'POST',
             headers: { 'Content-Type': 'application/json' },
@@ -412,7 +412,7 @@ async function saveHost(hostData) {
 }
 
 // -----------------------------
-// PREPARE ADD HOST FORM
+// Prepare add host form
 // -----------------------------
 function clearAddHostForm() {
     // reset edit mode
@@ -422,7 +422,7 @@ function clearAddHostForm() {
 }
 
 // -----------------------------
-// CLOSE POPUP
+// Close popup
 // -----------------------------
 async function closeAddHostModal() {
     const modalEl = document.getElementById('addHostModal');
@@ -432,24 +432,24 @@ async function closeAddHostModal() {
 }
 
 // -----------------------------
-// Handle Add Host Form Submit
+// Handle Add host form submit
 // -----------------------------
 async function handleAddHostSubmit(e) {
     // Prevent default form submission
     e.preventDefault();
 
-    // Retrieve form data
-    const hostData = {
-        name:  document.getElementById('hostName').value.trim(),
-        ipv4:  document.getElementById('hostIPv4').value.trim(),
-        ipv6:  document.getElementById('hostIPv6').value.trim(),
-        mac:   document.getElementById('hostMAC').value.trim(),
-        note:  document.getElementById('hostNote').value.trim(),
-        ssl_enabled: document.getElementById('hostSSL').checked ? 1 : 0
-    };
-
     try {
-        const ok = await saveHost(hostData);
+        // 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(),
+            note:  document.getElementById('hostNote').value.trim(),
+            ssl_enabled: document.getElementById('hostSSL').checked ? 1 : 0
+        };
+
+        const ok = await saveHost(data);
         if (ok !== false) {
             // close modal and reload hosts
             closeAddHostModal();