]> git.giorgioravera.it Git - network-manager.git/commitdiff
Added settings
authorGiorgio Ravera <giorgio.ravera@gmail.com>
Wed, 3 Jun 2026 12:56:17 +0000 (14:56 +0200)
committerGiorgio Ravera <giorgio.ravera@gmail.com>
Wed, 3 Jun 2026 12:56:17 +0000 (14:56 +0200)
24 files changed:
backend/app.py
backend/backup.py
backend/bootstrap.py
backend/db/leases.py
backend/db/settings.py
backend/routes/about.py
backend/routes/backup.py
backend/routes/dhcp.py
backend/routes/dns.py
backend/routes/settings.py [new file with mode: 0644]
backend/routes/system.py [new file with mode: 0644]
backend/utils.py
frontend/aliases.html
frontend/css/layout.css
frontend/devices.html
frontend/hosts.html
frontend/index.html
frontend/js/common.js
frontend/js/index.js
frontend/js/services.js
frontend/js/settings.js [new file with mode: 0644]
frontend/leases.html
frontend/modals.html
frontend/settings.html [new file with mode: 0644]

index 6f85f26b20c86e7f0af0b802797de6493eb95e77..5df07a2c7254aa774cdbf9d0fadbff002f377712 100644 (file)
@@ -9,7 +9,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
 from typing import Callable
 
 # Import Routers
-from backend.routes.about import router as about_router
+from backend.routes.system import router as system_router
 from backend.routes.devices import router as devices_router
 from backend.routes.backup import router as backup_router
 from backend.routes.certificates import router as certificates_router
@@ -19,6 +19,7 @@ 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
+from backend.routes.settings import router as settings_router
 
 # Import Security
 from backend.security import is_logged_in, apply_session
@@ -212,7 +213,7 @@ def create_app() -> FastAPI:
     )
 
     # Routers
-    app.include_router(about_router)
+    app.include_router(system_router)
     app.include_router(backup_router)
     app.include_router(devices_router)
     app.include_router(certificates_router)
@@ -222,6 +223,7 @@ def create_app() -> FastAPI:
     app.include_router(aliases_router)
     app.include_router(dns_router)
     app.include_router(dhcp_router)
+    app.include_router(settings_router)
 
     # CORS
     cors_origins = [
index 63eea9c3eed2544ee7a581fe5dabd3b8d753ace2..9c8ac87310f42c37acc52f73d999eb79c09551c5 100644 (file)
@@ -14,8 +14,9 @@ import zipfile
 from backend.db.hosts import get_hosts, add_host, reset_hosts_db
 from backend.db.aliases import get_aliases, add_alias, reset_aliases_db
 
-# Import Settings
+# Import Settings & Config
 from backend.settings.settings import settings
+from backend.db.settings import get_config
 # Import Logging
 from backend.log.log import get_logger
 
@@ -94,8 +95,8 @@ def create_backup_archive(
 
     try:
         # --- Paths ---
-        base_zip_dir = Path(zip_dir or settings.BACKUP_PATH)
-        base_files_dir = Path(files_dir or settings.BACKUP_PATH)
+        base_zip_dir = Path(zip_dir or get_config("BACKUP_PATH"))
+        base_files_dir = Path(files_dir or get_config("BACKUP_PATH"))
         base_zip_dir.mkdir(parents=True, exist_ok=True)
 
         # zip name
@@ -159,7 +160,7 @@ def unzip_backup_archive(
 
     try:
         # --- Resolve paths ---
-        base_zip_dir = zip_dir or settings.BACKUP_PATH
+        base_zip_dir = zip_dir or get_config("BACKUP_PATH")
 
         if not zip_path:
             if not zip_name:
@@ -167,7 +168,7 @@ def unzip_backup_archive(
 
             zip_path = Path(base_zip_dir) / zip_name
 
-        base_extract_dir = extract_dir or settings.BACKUP_PATH
+        base_extract_dir = extract_dir or get_config("BACKUP_PATH")
         Path(base_extract_dir).mkdir(parents=True, exist_ok=True)
 
         # --- Unzip ---
@@ -202,7 +203,7 @@ def store_hosts(
 
     # Initialization
     start_ns = time.monotonic_ns()
-    filepath = Path(filepath or settings.BACKUP_PATH)
+    filepath = Path(filepath or get_config("BACKUP_PATH"))
     filename = filename or settings.BACKUP_HOSTS_FILE
     file = filepath / filename
     filepath.mkdir(parents=True, exist_ok=True)
@@ -260,7 +261,7 @@ def restore_hosts(
 
     # Initialization
     start_ns = time.monotonic_ns()
-    filepath = Path(filepath or settings.BACKUP_PATH)
+    filepath = Path(filepath or get_config("BACKUP_PATH"))
     filename = filename or settings.BACKUP_HOSTS_FILE
     file = filepath / filename
     count_restored = 0
@@ -319,7 +320,7 @@ def store_aliases(
 
     # Initialization
     start_ns = time.monotonic_ns()
-    filepath = Path(filepath or settings.BACKUP_PATH)
+    filepath = Path(filepath or get_config("BACKUP_PATH"))
     filepath.mkdir(parents=True, exist_ok=True)
     filename = filename or settings.BACKUP_ALIASES_FILE
     file = filepath / filename
@@ -378,7 +379,7 @@ def restore_aliases(
 
     # Initialization
     start_ns = time.monotonic_ns()
-    filepath = Path(filepath or settings.BACKUP_PATH)
+    filepath = Path(filepath or get_config("BACKUP_PATH"))
     filename = filename or settings.BACKUP_ALIASES_FILE
     file = filepath / filename
     count_restored = 0
@@ -438,7 +439,7 @@ def store_metadata(
 
     # Initialization
     start_ns = time.monotonic_ns()
-    filepath = Path(filepath or settings.BACKUP_PATH)
+    filepath = Path(filepath or get_config("BACKUP_PATH"))
     filepath.mkdir(parents=True, exist_ok=True)
     filename = filename or settings.BACKUP_METADATA_FILE
     file = filepath / filename
@@ -504,7 +505,7 @@ def check_metadata(
 
     # Initialization
     start_ns = time.monotonic_ns()
-    filepath = Path(filepath or settings.BACKUP_PATH)
+    filepath = Path(filepath or get_config("BACKUP_PATH"))
     filename = filename or settings.BACKUP_METADATA_FILE
     file = filepath / filename
 
@@ -573,7 +574,7 @@ def check_metadata(
 def backup_create() -> Dict[str, Any]:
 
     # Ensure backup directory exists
-    base_dir = Path(settings.BACKUP_PATH)
+    base_dir = Path(get_config("BACKUP_PATH"))
     base_dir.mkdir(parents=True, exist_ok=True)
 
     # Timestamp used for backup file naming
@@ -610,7 +611,7 @@ def backup_create() -> Dict[str, Any]:
 def backup_list() -> List[Dict[str, Any]]:
 
     # Initialization
-    backup_dir = Path(settings.BACKUP_PATH)
+    backup_dir = Path(get_config("BACKUP_PATH"))
     backups = []
 
     if backup_dir.is_dir():
@@ -639,7 +640,7 @@ def backup_restore(backup_id: str, cleanup: bool = True) -> Dict[str, Any]:
     }
 
     # Check if backup file exists
-    backup_dir = Path(settings.BACKUP_PATH)
+    backup_dir = Path(get_config("BACKUP_PATH"))
     backup_file = backup_dir / backup_id
     extract_dir = backup_dir / Path(backup_id).stem
 
@@ -690,7 +691,7 @@ def backup_delete(backup_id: str) -> Dict[str, Any]:
     start_ns = time.monotonic_ns()
     errors: List[str] = []
 
-    backup_dir = Path(settings.BACKUP_PATH)
+    backup_dir = Path(get_config("BACKUP_PATH"))
     backup_file = backup_dir / backup_id
 
     try:
index 288852055955c4e818699fe78df5c4c1ea978c1d..a09868070dc8b5e8619cb344f51f9b0830cbb311 100644 (file)
@@ -46,15 +46,15 @@ def print_welcome(logger):
     )
     logger.info(
         "DNS: host file=%s | alias file=%s | reverse file=%s",
-        settings.DNS_HOST_FILE, settings.DNS_ALIAS_FILE, settings.DNS_REVERSE_FILE
+        get_config("DNS_HOST_FILE"), get_config("DNS_ALIAS_FILE"), get_config("DNS_REVERSE_FILE")
     )
     logger.info(
         "DHCP: ipv4 host file=%s | ipv4 leases file=%s | ipv6 host file=%s | ipv6 leases file=%s",
-        settings.DHCP4_HOST_FILE, settings.DHCP4_LEASES_FILE, settings.DHCP6_HOST_FILE, settings.DHCP6_LEASES_FILE
+        get_config("DHCP4_HOST_FILE"), get_config("DHCP4_LEASES_FILE"), get_config("DHCP6_HOST_FILE"), get_config("DHCP6_LEASES_FILE")
     )
     logger.info(
         "Backup: path=%s",
-        str(settings.BACKUP_PATH)
+        get_config("BACKUP_PATH")
     )
     logger.info(
         "App features: ping_workers=%d",
index b4bc3f384ca14f4f4ad419d5baa78d835c9d1a92..30d929c15652ce25829f6a4e16f163376769d118 100644 (file)
@@ -8,8 +8,9 @@ from typing import Any, Dict, List, Optional
 # Import local modules
 from backend.utils import to_bool, to_int
 
-# Import Settings
+# Import Settings & Config
 from backend.settings.settings import settings
+from backend.db.settings import get_config
 # Import Logging
 from backend.log.log import get_logger
 
@@ -39,7 +40,7 @@ def get_leases(filter_devices: bool = False) -> List[Dict[str, Any]]:
     leases = []
     index = 1  # 1-based id for frontend
 
-    path = settings.DHCP4_LEASES_FILE
+    path = Path(get_config("DHCP4_LEASES_FILE"))
     if not path.exists():
         raise FileNotFoundError(f"File not found: {path}")
 
@@ -87,7 +88,7 @@ def get_leases(filter_devices: bool = False) -> List[Dict[str, Any]]:
 # SELECT SINGLE LEASE
 # -----------------------------
 def get_lease(lease_id: int) -> Optional[Dict[str, Any]]:
-    path = settings.DHCP4_LEASES_FILE
+    path = Path(get_config("DHCP4_LEASES_FILE"))
     if not path.exists():
         raise FileNotFoundError(f"File not found: {path}")
 
@@ -123,7 +124,7 @@ def get_lease(lease_id: int) -> Optional[Dict[str, Any]]:
 # -----------------------------
 def delete_lease(lease_id: int):
 
-    path = settings.DHCP4_LEASES_FILE
+    path = Path(get_config("DHCP4_LEASES_FILE"))
     if not path.exists():
         raise FileNotFoundError(f"File not found: {path}")
 
index 636c93997abb1f06f2e685cce7435324676cd49d..44dc1d6e47bf0f1bc707ee86d820ded85d1a6468 100644 (file)
@@ -1,5 +1,8 @@
 # backend/db/settings.py
 
+# Import standard modules
+from typing import Any, Dict, List
+
 # Import local modules
 from backend.db.db import get_db, register_init
 from backend.utils import to_bool
@@ -17,30 +20,122 @@ logger = get_logger(__name__)
 # ---------------------------------------------------------
 def _to_bool(v):
     result = to_bool(v)
-    return result if result is not None else False
+    if result is None:
+        raise ValueError(f"Invalid boolean value: {v}")
+    return result
 
 # ---------------------------------------------------------
 # Type mapping for config keys
 # ---------------------------------------------------------
-CONFIG_TYPES = {
-    "LOG_LEVEL": str,
-    "LOG_TO_FILE": _to_bool,
-    "EXTERNAL_NAME": str,
-    "LOGIN_MAX_ATTEMPTS": int,
-    "LOGIN_WINDOW_SECONDS": int,
-    "PING_WORKERS": int,
+TYPE_CASTERS = {
+    "string": str,
+    "integer": int,
+    "number": float,
+    "boolean": _to_bool,
 }
 
 # ---------------------------------------------------------
 # Default Values
 # ---------------------------------------------------------
 CONFIG_DEFAULTS = {
-    "LOG_LEVEL": settings.LOG_LEVEL,
-    "LOG_TO_FILE": settings.LOG_TO_FILE,
-    "EXTERNAL_NAME": settings.EXTERNAL_NAME,
-    "LOGIN_MAX_ATTEMPTS": settings.LOGIN_MAX_ATTEMPTS,
-    "LOGIN_WINDOW_SECONDS": settings.LOGIN_WINDOW_SECONDS,
-    "PING_WORKERS": settings.PING_WORKERS,
+    "LOG_LEVEL": {
+        "value": settings.LOG_LEVEL,
+        "description": "Logging verbosity level",
+        "group_name": "logging",
+        "type": "string",
+        "allowed": ["debug", "info", "warning", "error", "critical"],
+    },
+    "LOG_TO_FILE": {
+        "value": settings.LOG_TO_FILE,
+        "description": "Enable file logging",
+        "group_name": "logging",
+        "allowed": [True, False],
+        "type": "boolean",
+    },
+    "DOMAIN": {
+        "value": settings.DOMAIN,
+        "description": "Domain name for DNS configuration",
+        "group_name": "network",
+        "type": "string",
+    },
+    "EXTERNAL_NAME": {
+        "value": settings.EXTERNAL_NAME,
+        "description": "External hostname or IP for API access",
+        "group_name": "network",
+        "type": "string",
+    },
+    "DNS_HOST_FILE": {
+        "value": settings.DNS_HOST_FILE,
+        "description": "Path to DNS host file",
+        "group_name": "network - dns",
+        "type": "string",
+    },
+    "DNS_ALIAS_FILE": {
+        "value": settings.DNS_ALIAS_FILE,
+        "description": "Path to DNS alias file",
+        "group_name": "network - dns",
+        "type": "string",
+    },
+    "DNS_REVERSE_FILE": {
+        "value": settings.DNS_REVERSE_FILE,
+        "description": "Path to DNS reverse file",
+        "group_name": "network - dns",
+        "type": "string",
+    },
+    "DHCP4_HOST_FILE": {
+        "value": settings.DHCP4_HOST_FILE,
+        "description": "Path to DHCPv4 host file",
+        "group_name": "network - dhcp",
+        "type": "string",
+    },
+    "DHCP4_LEASES_FILE": {
+        "value": settings.DHCP4_LEASES_FILE,
+        "description": "Path to DHCPv4 leases file",
+        "group_name": "network - dhcp",
+        "type": "string",
+    },
+    "DHCP6_HOST_FILE": {
+        "value": settings.DHCP6_HOST_FILE,
+        "description": "Path to DHCPv6 host file",
+        "group_name": "network - dhcp",
+        "type": "string",
+    },
+    "DHCP6_LEASES_FILE": {
+        "value": settings.DHCP6_LEASES_FILE,
+        "description": "Path to DHCPv6 leases file",
+        "group_name": "network - dhcp",
+        "type": "string",
+    },
+    "BACKUP_PATH": {
+        "value": settings.BACKUP_PATH,
+        "description": "Directory path for storing backups",
+        "group_name": "backup",
+        "type": "string",
+    },
+    "LOGIN_MAX_ATTEMPTS": {
+        "value": settings.LOGIN_MAX_ATTEMPTS,
+        "description": "Maximum failed login attempts",
+        "group_name": "security",
+        "type": "integer",
+        "min": 1,
+        "max": 10
+    },
+    "LOGIN_WINDOW_SECONDS": {
+        "value": settings.LOGIN_WINDOW_SECONDS,
+        "description": "Time window for counting failed login attempts (seconds)",
+        "group_name": "security",
+        "type": "integer",
+        "min": 60,
+        "max": 3600,
+    },
+    "PING_WORKERS": {
+        "value": settings.PING_WORKERS,
+        "description": "Number of ping worker threads",
+        "group_name": "system",
+        "type": "integer",
+        "min": 1,
+        "max": 100,
+    },
 }
 
 # ---------------------------------------------------------
@@ -48,6 +143,69 @@ CONFIG_DEFAULTS = {
 # ---------------------------------------------------------
 _config_cache = {}
 
+# ---------------------------------------------------------
+# Internal: error response helper
+# ---------------------------------------------------------
+def _error(msg, code="CONFIG_UPDATE_ERROR", json_format=False):
+    if json_format:
+        return {
+            "code": code,
+            "status": "failure",
+            "message": msg,
+        }
+    return False
+
+# ---------------------------------------------------------
+# Internal: expand config data with metadata and type casting
+# ---------------------------------------------------------
+def _expand_config_data(key, data: dict) -> dict:
+
+    cfg_default = CONFIG_DEFAULTS.get(key, {})
+    type_name = cfg_default.get("type", "string")
+    caster = TYPE_CASTERS.get(type_name, str)
+
+    # -------------------------
+    # Type casting
+    # -------------------------
+    try:
+        data["value"] = caster(data["value"])
+    except Exception as e:
+        logger.warning(f"Config cast failed for {key}: {data['value']} ({e})")
+
+    # -------------------------
+    # Default value
+    # -------------------------
+    default = cfg_default.get("value")
+    data["default_value"] = default
+
+    # -------------------------
+    # is_default flag
+    # -------------------------
+    try:
+        casted_default = caster(default)
+    except Exception:
+        casted_default = default
+    data["is_default"] = (data.get("value") == casted_default)
+
+    # -------------------------
+    # Optional metadata
+    # -------------------------
+    if cfg_default.get("min") is not None:
+        data["min"] = cfg_default["min"]
+
+    if cfg_default.get("max") is not None:
+        data["max"] = cfg_default["max"]
+
+    if cfg_default.get("allowed") is not None:
+        data["allowed"] = cfg_default["allowed"]
+
+    # -------------------------
+    # Type info (optional but useful)
+    # -------------------------
+    data["type"] = type_name
+
+    return data
+
 # ---------------------------------------------------------
 # Clear cache
 # ---------------------------------------------------------
@@ -58,33 +216,59 @@ def clear_cache(key=None):
     else:
         _config_cache.clear()
 
-# ---------------------------------------------------------
-# Return a specific config value (with cache + type casting)
-# ---------------------------------------------------------
-def set_config(key, value):
-    if key not in CONFIG_TYPES:
-        logger.warning("Config key not typed: %s", key)
-
-    # salva sempre come stringa
-    str_value = str(value)
-
+# -----------------------------
+# Return the list of configs
+# -----------------------------
+def get_configs() -> List[Dict[str, Any]]:
     conn = get_db()
-    conn.execute(
-        "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)",
-        (key, str_value),
+    query = (
+        "SELECT * FROM config"
     )
-    conn.commit()
+    cur = conn.execute(query)
+
+    rows = []
+    for r in cur.fetchall():
+        item = dict(r)
 
-    # invalida cache
-    clear_cache(key)
+        # Expand with metadata and type casting
+        item = _expand_config_data(item["key"], item)
+
+        rows.append(item)
+    return rows
 
 # ---------------------------------------------------------
 # Return a specific config value (with cache + type casting)
 # ---------------------------------------------------------
-def get_config(key):
-
-    if key not in CONFIG_TYPES:
+def get_config(key, json_format: bool = False):
+    # Check if key is valid
+    if key not in CONFIG_DEFAULTS:
         logger.warning("Invalid config key: %s", key)
+        return None
+
+    # ---- JSON format (no cache, no type casting) ----
+    if json_format:
+        conn = get_db()
+        cur = conn.execute("SELECT * FROM config WHERE key = ?", (key,))
+        row = cur.fetchone()
+
+        if not row:
+            cfg_default = CONFIG_DEFAULTS.get(key, {})
+            value = cfg_default.get("value", None)
+            logger.warning("Config key not found in database: %s (using default: %s)", key, value)
+            data = {
+                "key": key,
+                "value": value,
+                "description": cfg_default.get("description", None),
+                "group_name": cfg_default.get("group_name", None),
+                "from_default": True
+            }
+        else:
+            data = dict(row)
+
+        # Expand with metadata and type casting
+        data = _expand_config_data(key, data)
+
+        return data
 
     # ---- Cache hit ----
     if key in _config_cache:
@@ -104,7 +288,9 @@ def get_config(key):
     raw_value = row["value"]
 
     # ---- Type casting ----
-    caster = CONFIG_TYPES.get(key, str)
+    meta = CONFIG_DEFAULTS.get(key, {})
+    type_name = meta.get("type", "string")
+    caster = TYPE_CASTERS.get(type_name, str)
     try:
         value = caster(raw_value)
     except Exception:
@@ -112,6 +298,7 @@ def get_config(key):
 
     # ---- Save in cache ----
     _config_cache[key] = value
+
     return value
 
 # ---------------------------------------------------------
@@ -121,6 +308,89 @@ def get_config_or(key, default):
     value = get_config(key)
     return value if value is not None else default
 
+# ---------------------------------------------------------
+# Update a specific config value (with validation, cache invalidation, and optional JSON input)
+# ---------------------------------------------------------
+def update_config(key, value=None, reset_to_default=False, json_format=False):
+
+    # Initialization
+    meta = CONFIG_DEFAULTS.get(key, {})
+
+    conn = get_db()
+
+    # JSON input
+    if json_format and not reset_to_default:
+        if not isinstance(value, dict) or "value" not in value:
+            return _error("Invalid input format, expected dict with 'value' key", json_format=json_format)
+        value = value["value"]
+
+    # Reset
+    if reset_to_default:
+        if key not in CONFIG_DEFAULTS:
+            return _error(f"No default value for key: {key}", "CONFIG_NOT_FOUND", json_format=json_format)
+        value = CONFIG_DEFAULTS[key]["value"]
+
+    # Check Value
+    if value is None:
+        return _error(f"Value is None for key: {key}", json_format=json_format)
+
+    # Type Cast
+    type_name = meta.get("type", "string")
+    caster = TYPE_CASTERS.get(type_name, str)
+    try:
+        value = caster(value)
+    except Exception:
+        return _error(f"Invalid type for key {key}: {value}", json_format=json_format)
+
+    # Data Validation
+    if "allowed" in meta and value not in meta["allowed"]:
+        return _error(f"Value not allowed for {key}: {value}", json_format=json_format)
+    if "min" in meta and value < meta["min"]:
+        return _error(f"Value below minimum for {key}: {value}", json_format=json_format)
+    if "max" in meta and value > meta["max"]:
+        return _error(f"Value above maximum for {key}: {value}", json_format=json_format)
+
+    try:
+        cur = conn.cursor()
+
+        # Check existence
+        cur.execute("SELECT value FROM config WHERE key = ?", (key,))
+        row = cur.fetchone()
+        if not row:
+            return _error(f"Config key not found: {key}", "CONFIG_NOT_FOUND", json_format=json_format)
+
+        current_value = row["value"]
+        str_value = str(value)
+
+        # Skip if unchanged
+        if current_value == str_value:
+            if json_format:
+                return {"code": "CONFIG_UNCHANGED", "status": "success"}
+            else:
+                return True
+
+        # Update
+        cur.execute("""
+            UPDATE config
+            SET value = ?, last_updated=strftime('%Y-%m-%dT%H:%M:%SZ','now')
+            WHERE key = ?
+        """, (str_value, key))
+
+        conn.commit()
+
+        # cache invalidation
+        clear_cache(key)
+
+        if json_format:
+            return {"code": "CONFIG_UPDATED", "status": "success"}
+        else:
+            return True
+
+    except Exception as err:
+        conn.rollback()
+        logger.error(f"CONFIG DB: Error updating config - {err}")
+        raise
+
 # ---------------------------------------------------------
 # Create Config DB Tables
 # ---------------------------------------------------------
@@ -131,7 +401,10 @@ def init_db_config_table(cur):
     cur.execute("""
         CREATE TABLE IF NOT EXISTS config (
             key TEXT PRIMARY KEY,
-            value TEXT
+            value TEXT,
+            description TEXT,
+            group_name TEXT,
+            last_updated TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
         );
     """)
 
@@ -143,4 +416,9 @@ def init_db_config_defaults(cur):
 
     # Add configuration parameters
     for key, value in CONFIG_DEFAULTS.items():
-        cur.execute("INSERT OR IGNORE INTO config VALUES (?, ?)", (key, str(value)))
+        cur.execute("""
+            INSERT OR IGNORE INTO config (key, value, description, group_name, last_updated)
+            VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ','now'))
+            """,
+            (key, str(value["value"]), value["description"], value["group_name"])
+        )
index 2f5fd02d9649fc9976cc307cd338d1512c084df2..95ffe3153a376fc2f15eb524befea7a1d393f4d6 100644 (file)
@@ -1,17 +1,22 @@
-# backend/about.py
+# backend/system.py
 
 # Import standard modules
 from fastapi import APIRouter
 from datetime import datetime, timezone
+import os
+import signal
+import threading
+import time
 
-# Import Settings
+# Import Settings & Config
 from backend.settings.settings import settings
+from backend.db.settings import get_config
 
 # Create Router
 router = APIRouter()
 
 # ---------------------------------------------------------
-# API ENDPOINTS
+# Get Information
 # ---------------------------------------------------------
 @router.get("/about")
 def about():
@@ -20,6 +25,19 @@ def about():
             "name": settings.APP_NAME,
             "version": settings.APP_VERSION,
         },
-        "domain": settings.DOMAIN,
+        "domain": get_config("DOMAIN"),
         "server_time": datetime.now(timezone.utc).isoformat(),
     }
+
+# ---------------------------------------------------------
+# Restart Application
+# ---------------------------------------------------------
+@router.post("/api/restart")
+def restart():
+    def do_restart():
+        time.sleep(0.5)
+        os.kill(os.getpid(), signal.SIGTERM)
+
+    threading.Thread(target=do_restart, daemon=True).start()
+
+    return {"message": "Application restarting..."}
index 0fcf2b7ffa9820894c9b649c63a0c1ec093066e6..4bf2326930921f109418ecfab5bfcde8dfa8f705 100644 (file)
@@ -13,8 +13,9 @@ import zipfile
 # Import local modules
 from backend.backup import backup_create, backup_list, backup_restore, backup_delete
 
-# Import Settings
+# Import Settings & Config
 from backend.settings.settings import settings
+from backend.db.settings import get_config
 # Import Logging
 from backend.log.log import get_logger
 
@@ -235,7 +236,7 @@ async def api_backup_delete(payload: BackupDeleteRequest):
 # ---------------------------------------------------------
 @router.get("/api/backup/download/{backup_id}")
 def download_backup(backup_id: str):
-    backup_dir = Path(settings.BACKUP_PATH)
+    backup_dir = Path(get_config("BACKUP_PATH"))
 
     zip_path = backup_dir / f"{backup_id}"
 
@@ -276,7 +277,7 @@ def upload_backup(file: UploadFile = File(...)):
                 },
             )
 
-    backup_dir = Path(settings.BACKUP_PATH)
+    backup_dir = Path(get_config("BACKUP_PATH"))
     backup_dir.mkdir(parents=True, exist_ok=True)
 
     # safe filename
index 9cdf71a5b1eca7caea228e5c81a01d46e63bcb82..55e8229ad50189b208e71dd8f65ffe014625fb4f 100644 (file)
@@ -4,14 +4,16 @@
 from fastapi import APIRouter, Request, Response, HTTPException, status
 from fastapi.responses import FileResponse
 import json
+from pathlib import Path
 import time
 
 # Import local modules
 from backend.db.hosts import get_hosts
 from backend.db.leases import get_leases, get_lease, delete_lease
 
-# Import Settings
+# Import Settings & Config
 from backend.settings.settings import settings
+from backend.db.settings import get_config
 # Import Logging
 from backend.log.log import get_logger
 
@@ -68,7 +70,8 @@ async def api_dhcp_reload(request: Request):
             })
 
         # Save DHCP4 Configuration
-        path = settings.DHCP4_HOST_FILE
+        path = Path(get_config("DHCP4_HOST_FILE"))
+        path.parent.mkdir(parents=True, exist_ok=True)
         data = {"reservations": kea4_hosts}
         full = json.dumps(data, indent=4, ensure_ascii=False)
         fragment = full.strip()[1:-1].strip() + "\n"
@@ -76,7 +79,8 @@ async def api_dhcp_reload(request: Request):
             f.write(fragment)
 
         # Save DHCP6 Configuration
-        path = settings.DHCP6_HOST_FILE
+        path = Path(get_config("DHCP6_HOST_FILE"))
+        path.parent.mkdir(parents=True, exist_ok=True)
         data = {"reservations": kea6_hosts}
         full = json.dumps(data, indent=4, ensure_ascii=False)
         fragment = full.strip()[1:-1].strip() + "\n"
index 2e697fb681254e6c3c4c82dd5064492181e9bea3..b39173c523e1f97a7e2843620640f78bcab69e45 100644 (file)
@@ -6,6 +6,7 @@ from fastapi.responses import FileResponse
 import asyncio
 import json
 import ipaddress
+from pathlib import Path
 import time
 
 # Import local modules
@@ -41,7 +42,8 @@ async def api_dns_reload(request: Request):
         hosts = get_hosts()
 
         # Save DNS Hosts Configuration
-        path = settings.DNS_HOST_FILE
+        path = Path(get_config("DNS_HOST_FILE"))
+        path.parent.mkdir(parents=True, exist_ok=True)
         with open(path, "w", encoding="utf-8") as f:
             for h in hosts:
                 name   = h.get("name").ljust(20)
@@ -51,7 +53,8 @@ async def api_dns_reload(request: Request):
                 f.write(line)
 
         # Save DNS Reverse Configuration
-        path = settings.DNS_REVERSE_FILE
+        path = Path(get_config("DNS_REVERSE_FILE"))
+        path.parent.mkdir(parents=True, exist_ok=True)
         with open(path, "w", encoding="utf-8") as f:
             for h in hosts:
                 ip = h.get("ipv4")
@@ -60,7 +63,7 @@ async def api_dns_reload(request: Request):
                     rev    = f"{parts[-1]}.{parts[-2]}"
                     ip     = rev.ljust(20)
                     rtype  = "PTR".ljust(8)
-                    target = h.get("name")+ "." + settings.DOMAIN
+                    target = h.get("name")+ "." + get_config("DOMAIN")
                     line = f"{ip} IN {rtype} {target}\n"
                     f.write(line)
 
@@ -68,7 +71,8 @@ async def api_dns_reload(request: Request):
         aliases = get_aliases()
 
         # Save DNS Aliases Configuration
-        path = settings.DNS_ALIAS_FILE
+        path = Path(get_config("DNS_ALIAS_FILE"))
+        path.parent.mkdir(parents=True, exist_ok=True)
         with open(path, "w", encoding="utf-8") as f:
             for a in aliases:
                 name   = a.get("name").ljust(20)
@@ -81,9 +85,10 @@ async def api_dns_reload(request: Request):
         ext_cname = get_config("EXTERNAL_NAME")
 
         # Save DNS Host and Aliases for the EXT DNS
-        path = settings.DNS_HOST_FILE.with_name(
-            settings.DNS_HOST_FILE.name + "_ext"
+        path = Path(get_config("DNS_HOST_FILE")).with_name(
+               Path(get_config("DNS_HOST_FILE")).name + "_ext"
         )
+        path.parent.mkdir(parents=True, exist_ok=True)
         with open(path, "w", encoding="utf-8") as f:
             for h in hosts:
                 name   = h.get("name").ljust(20)
@@ -104,7 +109,7 @@ async def api_dns_reload(request: Request):
                 vis = a.get('visibility')
                 if (vis == 1):
                     rtype  = "CNAME".ljust(8)
-                    target = a.get("target") + "." + settings.DOMAIN + "."
+                    target = a.get("target") + "." + get_config("DOMAIN") + "."
                     line = f"{name} IN {rtype} {target}\n"
                     f.write(line)
                 if (vis == 2):
diff --git a/backend/routes/settings.py b/backend/routes/settings.py
new file mode 100644 (file)
index 0000000..2802698
--- /dev/null
@@ -0,0 +1,275 @@
+# backend/routes/settings.py
+
+# import standard modules
+from fastapi import APIRouter, Request, Response, HTTPException, status
+from fastapi.responses import FileResponse
+import ipaddress
+import time
+
+# Import local modules
+from backend.db.settings import (
+    get_configs,
+    get_config,
+    update_config,
+)
+
+# Import Settings
+from backend.settings.settings import settings
+# Import Logging
+from backend.log.log import get_logger
+
+# Logger initialization
+logger = get_logger(__name__)
+
+# Create Router
+router = APIRouter()
+
+# ---------------------------------------------------------
+# FRONTEND PATHS (absolute paths inside Docker)
+# ---------------------------------------------------------
+# Settings page
+@router.get("/settings")
+def settings_page(request: Request):
+    return FileResponse(settings.FRONTEND_PATH / "settings.html")
+
+# Serve settings.js
+@router.get("/js/settings.js")
+def settings_js():
+    return FileResponse(settings.FRONTEND_PATH / "js/settings.js")
+
+# ---------------------------------------------------------
+# Get Settings
+# ---------------------------------------------------------
+@router.get("/api/settings", status_code=status.HTTP_200_OK, responses={
+    200: {"description": "Settings found"},
+    500: {"description": "Internal server error"},
+})
+def api_get_configs(request: Request):
+
+    try:
+        configs = get_configs()
+        return configs or []
+
+    except HTTPException:
+        raise
+
+    except Exception as err:
+        logger.exception("Error getting list of the configuration parameters %s", str(err).strip())
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail={
+                "code": "CONFIGS_GET_ERROR",
+                "status": "failure",
+                "message": "Internal error getting list of the configuration parameters",
+            },
+        )
+
+# ---------------------------------------------------------
+# Get a configuration parameter
+# ---------------------------------------------------------
+@router.get("/api/settings/{config_key}", status_code=status.HTTP_200_OK, responses={
+    200: {"description": "Configuration parameter found"},
+    404: {"description": "Configuration parameter not found"},
+    500: {"description": "Internal server error"},
+})
+def api_get_setting(request: Request, config_key: str):
+
+    # Inizializzazioni
+    start_ns = time.monotonic_ns()
+
+    try:
+        config = get_config(config_key, json_format=True)
+        if not config:  # None or empty dict
+            took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+            raise HTTPException(
+                status_code=status.HTTP_404_NOT_FOUND,
+                detail={
+                    "code": "CONFIG_NOT_FOUND",
+                    "status": "failure",
+                    "message": "Configuration parameter not found",
+                    "details": {
+                        "config_key": config_key,
+                        "took_ms": took_ms,
+                    },
+                },
+            )
+        return config
+
+    except HTTPException:
+        raise
+
+    except Exception as err:
+        logger.exception("Error getting configuration parameter %s: %s", config_key, str(err).strip())
+        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail={
+                "code": "CONFIG_GET_ERROR",
+                "status": "failure",
+                "message": "Internal error getting configuration parameter",
+                "details": {
+                    "config_key": config_key,
+                    "took_ms": took_ms,
+                },
+            },
+        )
+
+# ---------------------------------------------------------
+# Update config
+# ---------------------------------------------------------
+@router.put("/api/settings/{config_key}", status_code=status.HTTP_200_OK, responses={
+    200: {"description": "Configuration parameter updated"},
+    400: {"description": "Invalid request"},
+    404: {"description": "Configuration parameter not found"},
+    500: {"description": "Internal server error"},
+})
+def api_update_setting(request: Request, data: dict, config_key: str):
+
+    # Inizializzazioni
+    start_ns = time.monotonic_ns()
+
+    try:
+        result = update_config(config_key, data, json_format=True)
+        if result["status"] == "success":
+            took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+            return {
+                    "code": "CONFIG_UPDATED",
+                    "status": "success",
+                    "message": "Configuration parameter updated successfully",
+                    "details": {
+                        "config_key": config_key,
+                        "took_ms": took_ms,
+                    },
+                }
+
+        else:
+            # Not Found
+            if result["code"] == "CONFIG_NOT_FOUND":
+                took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+                raise HTTPException(
+                    status_code=status.HTTP_404_NOT_FOUND,
+                    detail={
+                        "code": "CONFIG_NOT_FOUND",
+                        "status": "failure",
+                        "message": (result.get("message") if result else None) or f"Config key not found: {key}",
+                        "details": {
+                            "config_key": config_key,
+                            "took_ms": took_ms,
+                        },
+                    },
+                )
+
+            # Other failure
+            else:
+                took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail={
+                        "code": "CONFIG_RESET_ERROR",
+                        "status": "failure",
+                        "message": (result.get("message") if result else None) or "Internal error updating configuration parameter",
+                        "details": {
+                            "config_key": config_key,
+                            "took_ms": took_ms,
+                        },
+                    },
+                )
+
+    except HTTPException:
+        raise
+
+    except Exception as err:
+        logger.exception("Error updating configuration parameter %s: %s", config_key, str(err).strip())
+        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail={
+                "code": "CONFIG_UPDATE_ERROR",
+                "status": "failure",
+                "message": "Internal error updating configuration parameter",
+                "details": {
+                    "config_key": config_key,
+                    "took_ms": took_ms,
+                },
+            },
+        )
+
+# ---------------------------------------------------------
+# Reset config to default
+# ---------------------------------------------------------
+@router.post("/api/settings/{config_key}/reset", status_code=status.HTTP_200_OK, responses={
+    200: {"description": "Configuration parameter reset to default"},
+    400: {"description": "Invalid request"},
+    404: {"description": "Configuration parameter not found"},
+    500: {"description": "Internal server error"},
+})
+def api_reset_config(request: Request, config_key: str):
+
+    # Inizializzazioni
+    start_ns = time.monotonic_ns()
+
+    try:
+        result = update_config(config_key, reset_to_default=True, json_format=True)
+        if result["status"] == "success":
+            took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+            return {
+                    "code": "CONFIG_RESET",
+                    "status": "success",
+                    "message": "Configuration parameter reset to default successfully",
+                    "details": {
+                        "config_key": config_key,
+                        "took_ms": took_ms,
+                    },
+                }
+
+        else:
+            # Not Found
+            if result["code"] == "CONFIG_NOT_FOUND":
+                took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+                raise HTTPException(
+                    status_code=status.HTTP_404_NOT_FOUND,
+                    detail={
+                        "code": "CONFIG_NOT_FOUND",
+                        "status": "failure",
+                        "message": (result.get("message") if result else None) or f"Config key not found: {key}",
+                        "details": {
+                            "config_key": config_key,
+                            "took_ms": took_ms,
+                        },
+                    },
+                )
+
+            # Other failure
+            else:
+                took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail={
+                        "code": "CONFIG_RESET_ERROR",
+                        "status": "failure",
+                        "message": (result.get("message") if result else None) or "Internal error resetting configuration parameter",
+                        "details": {
+                            "config_key": config_key,
+                            "took_ms": took_ms,
+                        },
+                    },
+                )
+
+    except HTTPException:
+        raise
+
+    except Exception as err:
+        logger.exception("Error updating configuration parameter %s: %s", config_key, str(err).strip())
+        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail={
+                "code": "CONFIG_RESET_ERROR",
+                "status": "failure",
+                "message": "Internal error resetting configuration parameter",
+                "details": {
+                    "config_key": config_key,
+                    "took_ms": took_ms,
+                },
+            },
+        )
diff --git a/backend/routes/system.py b/backend/routes/system.py
new file mode 100644 (file)
index 0000000..ad20b74
--- /dev/null
@@ -0,0 +1,43 @@
+# backend/about.py
+
+# Import standard modules
+from fastapi import APIRouter
+from datetime import datetime, timezone
+import os
+import signal
+import threading
+import time
+
+# Import Settings & Config
+from backend.settings.settings import settings
+from backend.db.settings import get_config
+
+# Create Router
+router = APIRouter()
+
+# ---------------------------------------------------------
+# Get Information
+# ---------------------------------------------------------
+@router.get("/about")
+def about():
+    return {
+        "app": {
+            "name": settings.APP_NAME,
+            "version": settings.APP_VERSION,
+        },
+        "domain": get_config("DOMAIN"),
+        "server_time": datetime.now(timezone.utc).isoformat(),
+    }
+
+# ---------------------------------------------------------
+# Restart Application
+# ---------------------------------------------------------
+@router.post("/api/restart")
+def restart():
+    def do_restart():
+        time.sleep(0.5)
+        os.kill(os.getpid(), signal.SIGTERM)
+
+    threading.Thread(target=do_restart, daemon=True).start()
+
+    return {"message": "Application restarting..."}
index ae8f706713a9354778cb49fd1d49583f72704d26..fb93cb238b9b3879c0ed41aad77fac1654a49297 100644 (file)
@@ -25,11 +25,19 @@ def to_int(v: str, default: int | None = None) -> int | None:
 # convert string to bool (returns None if conversion fails)
 # -----------------------------
 def to_bool(v: str, default: bool | None = None) -> bool | None:
-    v = (v or "").strip().lower()
-    if v in ("true", "1", "yes", "y"):
-        return True
-    if v in ("false", "0", "no", "n"):
-        return False
+    # bool
+    if isinstance(v, bool):
+        return v
+    # int
+    if isinstance(v, int):
+        return bool(v)
+    # strings
+    if isinstance(v, str):
+        v = v.strip().lower()
+        if v in ("true", "1", "yes", "y", "on"):
+            return True
+        if v in ("false", "0", "no", "n", "off"):
+            return False
     return default
 
 # -----------------------------
index 0e77aea04a39f3d7fe04e42f18c966786276081c..efd4a5da8254229974c05c70dbe7df8e9b1a3853 100644 (file)
                 <!-- Menu -->
                 <div class="collapse navbar-collapse" id="menuNav">
                     <div class="navbar-nav ms-auto gap-2">
-                        <a href="/hosts"   id="hostsBtn"   class="btn btn-primary"        aria-current="page">Hostname</a>
-                        <a href="/aliases" id="aliasesBtn" class="btn btn-primary active" aria-current="page">Alias</a>
-                        <a href="/leases"  id="leasesBtn"  class="btn btn-primary"        aria-current="page">DHCP Leases</a>
-                        <a href="/devices" id="devicesBtn" class="btn btn-primary"        aria-current="page">Devices</a>
+                        <a href="/hosts"   id="hostsBtn"     class="btn btn-primary"        aria-current="page">Hostname</a>
+                        <a href="/aliases" id="aliasesBtn"   class="btn btn-primary active" aria-current="page">Alias</a>
+                        <a href="/leases"  id="leasesBtn"    class="btn btn-primary"        aria-current="page">DHCP Leases</a>
+                        <a href="/devices" id="devicesBtn"   class="btn btn-primary"        aria-current="page">Devices</a>
+                        <a href="/settings" id="settingsBtn" class="btn btn-primary"        aria-current="page">Settings</a>
                         <button id="logoutBtn" class="btn btn-primary">Logout</button>
                     </div>
                 </div>
index 2579bb8e9c86c2c1bfedcc64e62a49cd827fb652..2e990f09272422cf2dd31429819a4b89e080a761 100644 (file)
@@ -270,6 +270,7 @@ body {
 .form-check-input {
     font-size: 0.80rem;          /* più piccina */
     margin-bottom: 2px;          /* Bootstrap usa 8px; lo riduciamo */
+    cursor: pointer;
 }
 
 /* ================================
@@ -343,7 +344,7 @@ select.form-select:focus,
 .table thead th {
     background-color: #eee;
     font-weight: 700;
-    cursor: pointer;
+    cursor: default;
     user-select: none;
     position: relative;
 }
@@ -358,6 +359,21 @@ select.form-select:focus,
     background: var(--accent);
 }
 
+.table thead th[data-sortable="true"] {
+    cursor: pointer;
+}
+
+.table-group-header td {
+    background-color: #f5f5f5;
+    font-weight: 600;
+    border-top: 2px solid var(--accent);
+    border-bottom: 1px solid var(--border-light);
+    text-transform: uppercase;
+    font-size: 0.85rem;
+    padding: 0.6rem 1rem;
+    cursor: pointer;
+}
+
 /* Sort arrows */
 .sort-arrow {
     display: inline-block;
@@ -415,11 +431,6 @@ td.actions {
     margin-right: 8px;
 }
 
-/* removed due to background with icons
-.actions span:hover {
-    background-color: #e0f0ff;
-}*/
-
 /* ================================
    Toast
    ================================ */
index fc245257244c4cfc94538c3af021a2c2892c59c1..115337769067c923c56e5f88e9bf49ba7d574e97 100644 (file)
                 <!-- Menu -->
                 <div class="collapse navbar-collapse" id="menuNav">
                     <div class="navbar-nav ms-auto gap-2">
-                        <a href="/hosts"   id="hostsBtn"   class="btn btn-primary"        aria-current="page">Hostname</a>
-                        <a href="/aliases" id="aliasesBtn" class="btn btn-primary"        aria-current="page">Alias</a>
-                        <a href="/leases"  id="leasesBtn"  class="btn btn-primary"        aria-current="page">DHCP Leases</a>
-                        <a href="/devices" id="devicesBtn" class="btn btn-primary active" aria-current="page">Devices</a>
+                        <a href="/hosts"   id="hostsBtn"     class="btn btn-primary"        aria-current="page">Hostname</a>
+                        <a href="/aliases" id="aliasesBtn"   class="btn btn-primary"        aria-current="page">Alias</a>
+                        <a href="/leases"  id="leasesBtn"    class="btn btn-primary"        aria-current="page">DHCP Leases</a>
+                        <a href="/devices" id="devicesBtn"   class="btn btn-primary active" aria-current="page">Devices</a>
+                        <a href="/settings" id="settingsBtn" class="btn btn-primary"        aria-current="page">Settings</a>
                         <button id="logoutBtn" class="btn btn-primary">Logout</button>
                     </div>
                 </div>
index d995379071652156891c40c4deedec8c86f9e680..5707b4340d45b4bdeea2ccca5733e6eda3123a9e 100644 (file)
                 <!-- Menu -->
                 <div class="collapse navbar-collapse" id="menuNav">
                     <div class="navbar-nav ms-auto gap-2">
-                        <a href="/hosts"   id="hostsBtn"   class="btn btn-primary active" aria-current="page">Hostname</a>
-                        <a href="/aliases" id="aliasesBtn" class="btn btn-primary"        aria-current="page">Alias</a>
-                        <a href="/leases"  id="leasesBtn"  class="btn btn-primary"        aria-current="page">DHCP Leases</a>
-                        <a href="/devices" id="devicesBtn" class="btn btn-primary"        aria-current="page">Devices</a>
+                        <a href="/hosts"   id="hostsBtn"     class="btn btn-primary active" aria-current="page">Hostname</a>
+                        <a href="/aliases" id="aliasesBtn"   class="btn btn-primary"        aria-current="page">Alias</a>
+                        <a href="/leases"  id="leasesBtn"    class="btn btn-primary"        aria-current="page">DHCP Leases</a>
+                        <a href="/devices" id="devicesBtn"   class="btn btn-primary"        aria-current="page">Devices</a>
+                        <a href="/settings" id="settingsBtn" class="btn btn-primary"        aria-current="page">Settings</a>
                         <button id="logoutBtn" class="btn btn-primary">Logout</button>
                     </div>
                 </div>
index 226eddcacee4c03020c9a4be69254e77268761c7..6c71f9921355b3d8d54ac37c930f32bcc9f2cd6c 100644 (file)
             </a>
 
             <!-- Settings -->
-            <a href="settings/index.html" class="tile text-decoration-none">
+            <a href="/settings" class="tile text-decoration-none">
                 <div class="tile-icon"><i class="bi bi-gear"></i></div>
                 <h3>Impostazioni</h3>
                 <p>Configurazione sistema e variabili.</p>
index 63b4758c03696510a0370bf2a37863ce1c214c2a..a2a4b83936d55677b08ce15c0b9b32a0fff8ff6f 100644 (file)
@@ -351,3 +351,42 @@ export function clearSearch() {
         input.blur();
     }
 }
+
+/**
+ * Show a confirmation modal with a custom message and return a Promise that resolves to true/false
+ * based on user action.
+ * @param {string} message - The message to display in the modal body.
+ * @returns {Promise<boolean>} - Resolves to true if user confirms, false if cancels.
+ */
+export function showConfirmModal(message = "Are you sure?") {
+    return new Promise((resolve) => {
+        const modalEl = document.getElementById('confirmModal');
+        const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
+
+        const body = modalEl.querySelector("#confirmModalBody");
+        const okBtn = modalEl.querySelector("#confirmModalOk");
+
+        body.textContent = message;
+
+        const cleanUp = () => {
+            okBtn.removeEventListener("click", onConfirm);
+            modalEl.removeEventListener("hidden.bs.modal", onCancel);
+        };
+
+        const onConfirm = () => {
+            cleanUp();
+            resolve(true);
+            modal.hide();
+        };
+
+        const onCancel = () => {
+            cleanUp();
+            resolve(false);
+        };
+
+        okBtn.addEventListener("click", onConfirm);
+        modalEl.addEventListener("hidden.bs.modal", onCancel, { once: true });
+
+        modal.show();
+    });
+}
index 40cdd888df2ec34801b273477689c6b8c3296c3d..16c3a74a86a651dd146622b3da37524b0224645a 100644 (file)
@@ -1,8 +1,8 @@
 // -------------------------------------------------------
 // IMPORT
 // -------------------------------------------------------
-import { loadModals, showToast } from './common.js';
-import { serviceCheckAbount, serviceCheckHealth , serviceReloadDNS, serviceReloadDHCP, serviceBackupCreate, serviceBackupList, serviceBackupRestore, serviceDeleteBackup, serviceDownloadBackup, serviceUploadBackup } from './services.js';
+import { loadModals, showToast, showConfirmModal } from './common.js';
+import { serviceIsAlive, serviceCheckHealth , serviceReloadDNS, serviceReloadDHCP, serviceBackupCreate, serviceBackupList, serviceBackupRestore, serviceDeleteBackup, serviceDownloadBackup, serviceUploadBackup } from './services.js';
 
 // -------------------------------------------------------
 // BACKUP MODAL OPEN/CLOSE
@@ -38,6 +38,26 @@ function closeBackupModal() {
     if (modal) modal.style.display = 'none';
 }
 
+// -------------------------------------------------------
+// Manage Backup List Rendering (usa fetchData() con apiMap.backups)
+// -------------------------------------------------------
+export async function serviceCheckAbout() {
+    const pill = document.getElementById('api-pill');
+    if (!pill) return;
+
+    const ok = await serviceIsAlive();
+
+    if (ok) {
+        pill.textContent = 'API OK';
+        pill.classList.remove('btn-outline-primary');
+        pill.classList.add('btn-primary');
+    } else {
+        pill.textContent = 'API OFFLINE';
+    }
+
+    return ok;
+}
+
 // -------------------------------------------------------
 // Manage Backup List Rendering (usa fetchData() con apiMap.backups)
 // -------------------------------------------------------
@@ -335,8 +355,8 @@ const actionHandlers = {
         const id = el.dataset.id;
         if (!id) return;
 
-        const confirmDelete = confirm(`Delete backup "${id}" ?`);
-        if (!confirmDelete) return;
+        const confirmed = await showConfirmModal(`Delete backup "${id}" ?`);
+        if (!confirmed) return;
 
         try {
             const result = await serviceDeleteBackup(id);
@@ -465,7 +485,7 @@ const actionHandlers = {
     },
     // Check API status
     apiCheck: async () => {
-        const result = await serviceCheckAbount();
+        const result = await serviceCheckAbout();
         if(result) {
             showToast('API status updated succesfully', true);
         } else {
@@ -544,7 +564,7 @@ function initBackupModal() {
 // Periodic API Check
 // -------------------------------------------------------
 async function periodicTest() {
-    await serviceCheckAbount();
+    await serviceCheckAbout();
     setTimeout(periodicTest, 10000);
 }
 
index efa949dc44f551d766ef21c71c37ab285d3ac863..cd0531c5aeb9d6ff0c20e41f097a5c446c075f4a 100644 (file)
@@ -4,23 +4,11 @@ import { apiRequest, apiGet, apiPost, apiDownload, apiUpload } from './api.js';
 // -------------------------------------------------------
 // Check Abount
 // -------------------------------------------------------
-export async function serviceCheckAbount() {
-    const pill = document.getElementById('api-pill');
-    if (!pill) return;
-
+export async function serviceIsAlive() {
     try {
-        const r = await fetch('/about');
-        if (r.ok) {
-            pill.textContent = 'API OK';
-            pill.classList.remove('btn-outline-primary');
-            pill.classList.add('btn-primary');
-            return true;
-        } else {
-            pill.textContent = `API ${r.status}`;
-            return false;
-        }
+        const r = await fetch('/about', { cache: "no-store" });
+        return r.ok;
     } catch {
-        pill.textContent = 'API OFFLINE';
         return false;
     }
 }
@@ -333,3 +321,64 @@ export async function serviceUploadBackup(file) {
 
     return false;
 }
+
+// -----------------------------
+// Get the list of configuration parameters
+// -----------------------------
+export async function serviceGetConfigs() {
+    return await apiGet("/api/settings", "Error loading configuration parameters");
+}
+
+// -----------------------------
+// Get a single configuration parameter
+// -----------------------------
+export async function serviceGetConfig(key) {
+    return await apiRequest(
+        `/api/settings/${key}`,
+        { method: "GET" },
+        `Error loading configuration ${key}`
+    );
+}
+
+// -----------------------------
+// Update a configuration parameter
+// -----------------------------
+export async function serviceUpdateConfig(key, configData) {
+    const data = await apiRequest(
+        `/api/settings/${key}`,
+        {
+            method: "PUT",
+            headers: { "Content-Type": "application/json" },
+            body: JSON.stringify(configData)
+        },
+        "Error updating configuration parameter"
+    );
+
+    return data?.message ? { message: data.message } : true;
+}
+
+// -----------------------------
+// Reset a configuration parameter to its default value
+// -----------------------------
+export async function serviceResetConfig(key) {
+    const data = await apiPost(
+        `/api/settings/${key}/reset`,
+        null,
+        "Error restoring default value"
+    );
+
+    return data?.message ? { message: data.message } : true;
+}
+
+// -----------------------------
+// Reset a configuration parameter to its default value
+// -----------------------------
+export async function serviceRestartApp(key) {
+    const data = await apiPost(
+        "/api/restart",
+        null,
+        "Error restarting application"
+    );
+
+    return data?.message ? { message: data.message } : true;
+}
diff --git a/frontend/js/settings.js b/frontend/js/settings.js
new file mode 100644 (file)
index 0000000..c3cd443
--- /dev/null
@@ -0,0 +1,901 @@
+// Import common js
+import { loadModals, showToast, clearSearch, showConfirmModal } from './common.js';
+// Import services
+import { serviceGetConfigs, serviceGetConfig, serviceUpdateConfig, serviceResetConfig, serviceRestartApp, serviceIsAlive } from './services.js';
+
+// -----------------------------
+// State variables
+// -----------------------------
+let allConfigs = [];
+let viewConfigs = [];
+let editingConfigKey = null;
+
+// -----------------------------
+// Get config value from form based on visible input
+// -----------------------------
+function getConfigValueFromForm() {
+
+    const boolGroup   = document.getElementById("configValueBoolGroup");
+    const rangeGroup  = document.getElementById("configValueRangeGroup");
+    const selectGroup = document.getElementById("configValueSelectGroup");
+    const textInput   = document.getElementById("configValue");
+
+    const boolInput   = document.getElementById("configValueBool");
+    const rangeInput  = document.getElementById("configValueRange");
+    const selectInput = document.getElementById("configValueSelect");
+
+    // Boolean
+    if (!boolGroup.classList.contains("d-none")) {
+        return boolInput.checked;
+    }
+
+    // Range int
+    if (!rangeGroup.classList.contains("d-none")) {
+        return Number(rangeInput.value);
+    }
+
+    // Range group with allowed values (select)
+    if (!selectGroup.classList.contains("d-none")) {
+        const select = selectInput;
+        const value = select.value;
+        const type = select.dataset.type || "string";
+
+        if (type === "integer") return Number(value);
+        if (type === "boolean") return value === "true";
+
+        return value;
+    }
+
+    // Text (default)
+    return typeof textInput.value === "string"
+        ? textInput.value.trim()
+        : textInput.value;
+}
+
+// -----------------------------
+// Fetch configs from API
+// -----------------------------
+async function fetchConfigs () {
+    const loader = document.getElementById("loader");
+    const dataTable = document.getElementById("dataTable");
+
+    // hide table during loading to avoid flickering and show loader
+    dataTable.classList.add("d-none");
+
+    try {
+        // Show loader
+        loader.style.display = "block";
+
+        // Fetch configs
+        allConfigs = await serviceGetConfigs();
+        viewConfigs = [...allConfigs];
+
+    } catch (err) {
+        console.error(err?.message || "Error loading configs");
+        showToast(err?.message || "Error loading configs", false);
+        allConfigs = [];
+        viewConfigs = [];
+        // hide loader and show table
+        loader.style.display = "none";
+        dataTable.classList.remove("d-none");
+    }
+}
+
+// -----------------------------
+// Toggle group rows visibility
+// -----------------------------
+function toggleGroup(groupKey) {
+
+    const rows = document.querySelectorAll(
+        `tr[data-parent-group="${groupKey}"]`
+    );
+
+    const groupRow = document.querySelector(
+        `tr[data-group="${groupKey}"]`
+    );
+
+    const icon = groupRow?.querySelector("i");
+
+    const isHidden = rows.length > 0 && rows[0].classList.contains("d-none");
+
+    // toggle rows
+    rows.forEach(row => {
+        if (isHidden) {
+            row.classList.remove("d-none");
+        } else {
+            row.classList.add("d-none");
+        }
+    });
+
+    // toggle icon
+    if (icon) {
+        if (isHidden) {
+            icon.classList.remove("bi-folder");
+            icon.classList.add("bi-folder2-open");
+        } else {
+            icon.classList.remove("bi-folder2-open");
+            icon.classList.add("bi-folder");
+        }
+    }
+}
+
+// -----------------------------
+// Expand groups
+// -----------------------------
+function expandAllGroups() {
+    document.querySelectorAll('tr[data-parent-group]')
+        .forEach(row => row.classList.remove("d-none"));
+
+    document.querySelectorAll('tr[data-group] i')
+        .forEach(icon => {
+            icon.classList.remove("bi-folder");
+            icon.classList.add("bi-folder2-open");
+        });
+}
+
+// -----------------------------
+// Collapse groups
+// -----------------------------
+function collapseAllGroups() {
+    document.querySelectorAll('tr[data-parent-group]')
+        .forEach(row => row.classList.add("d-none"));
+
+    document.querySelectorAll('tr[data-group] i')
+        .forEach(icon => {
+            icon.classList.remove("bi-folder2-open");
+            icon.classList.add("bi-folder");
+        });
+}
+
+// -----------------------------
+// Overlay
+// -----------------------------
+function showRestartOverlay() {
+    document.getElementById("restartOverlay")
+        ?.classList.remove("d-none");
+}
+
+// -----------------------------
+// Polling to check if the system restarted properly
+// -----------------------------
+function startReconnectPolling() {
+
+    // overlay (opzionale ma consigliato)
+    showRestartOverlay();
+
+    const interval = setInterval(async () => {
+
+        try {
+            // prova endpoint leggero
+            const isUp = await serviceIsAlive();
+
+            if (isUp) {
+
+                clearInterval(interval);
+
+                showToast("Application is back online", true);
+
+                setTimeout(() => location.reload(), 500);
+
+                const btn = document.getElementById("restartBtn");
+                btn?.removeAttribute("disabled");
+            }
+        } catch (err) {
+            console.log("Waiting for server...");
+        }
+
+    }, 2000); // check every 2 seconds
+}
+
+// -----------------------------
+// Restart application
+// -----------------------------
+async function restartApp() {
+
+    const confirmed = await showConfirmModal("Restart the application?");
+    if (!confirmed) return;
+
+    const btn = document.getElementById("restartBtn");
+    btn?.setAttribute("disabled", "true");
+
+    try {
+        await serviceRestartApp();
+        showToast("Application is restarting...", true);
+
+        // wait restart
+        startReconnectPolling();
+
+    } catch (err) {
+        console.error(err?.message || "Error restarting application");
+        showToast(err?.message || "Error restarting application", false);
+        btn?.removeAttribute("disabled");
+    }
+}
+
+// -----------------------------
+// Update table with current configs
+// -----------------------------
+function updateTable () {
+    const loader = document.getElementById("loader");
+    const dataTable = document.getElementById("dataTable");
+    const searchInput = document.getElementById("searchInput");
+    const term = searchInput?.value?.trim().toLowerCase();
+    const hasSearch = !!term;
+    const expandBtn = document.getElementById("expandAllBtn");
+    const collapseBtn = document.getElementById("collapseAllBtn");
+
+    expandBtn?.toggleAttribute("disabled", hasSearch);
+    collapseBtn?.toggleAttribute("disabled", hasSearch);
+    if (hasSearch) {
+        expandBtn?.setAttribute("title", "Disabled during search");
+        collapseBtn?.setAttribute("title", "Disabled during search");
+    } else {
+        expandBtn?.setAttribute("title", "Expand all groups");
+        collapseBtn?.setAttribute("title", "Collapse all groups");
+    }
+
+    // DOM Reference
+    const tbody = document.querySelector("#dataTable tbody");
+    if (!tbody) {
+        console.warn('Element "#dataTable tbody" not found in DOM.');
+        return;
+    }
+
+    // Svuota la tabella
+    tbody.innerHTML = "";
+
+    // if no configs, show an empty row
+    if (!viewConfigs.length) {
+        const trEmpty = document.createElement("tr");
+        const tdEmpty = document.createElement("td");
+        tdEmpty.colSpan = 7;
+        tdEmpty.textContent = "No configs available.";
+        tdEmpty.style.textAlign = "center";
+        trEmpty.appendChild(tdEmpty);
+        tbody.appendChild(trEmpty);
+        // hide loader and show table
+        loader.style.display = "none";
+        dataTable.classList.remove("d-none");
+        return;
+    }
+
+    // fragment per performance
+    const frag = document.createDocumentFragment();
+
+    // Group configs by group_name
+    const grouped = {};
+    viewConfigs.forEach(c => {
+        const group = (c.group_name || "other").toLowerCase();
+        if (!grouped[group]) grouped[group] = [];
+        grouped[group].push(c);
+    });
+
+    Object.keys(grouped)
+        .sort()
+        .forEach(group => {
+
+        // GROUP HEADER ROW
+        const trGroup = document.createElement("tr");
+        const tdGroup = document.createElement("td");
+        const groupKey = group;
+
+        let rows = grouped[group];
+
+        if (term) {
+            rows = rows.filter(c => {
+                return (
+                    (c.key ?? "").toString().toLowerCase().includes(term) ||
+                    (c.value ?? "").toString().toLowerCase().includes(term) ||
+                    (c.description ?? "").toString().toLowerCase().includes(term)
+                );
+            });
+        }
+
+        // skip group if no result
+        if (term && rows.length === 0) return;
+
+        // GROUP HEADER
+        tdGroup.colSpan = 7;
+        trGroup.className = "table-group-header";
+        trGroup.setAttribute("data-group", groupKey);
+        tdGroup.innerHTML = term
+            ? '<i class="bi bi-folder2-open"></i>'
+            : '<i class="bi bi-folder"></i>';
+        tdGroup.appendChild(
+            document.createTextNode(
+                " " + group.charAt(0).toUpperCase() + group.slice(1)
+            )
+        );
+        trGroup.appendChild(tdGroup);
+        frag.appendChild(trGroup);
+
+        // ROWS del gruppo
+        rows.forEach(c => {
+
+            const tr = document.createElement("tr");
+            tr.setAttribute("data-parent-group", groupKey);
+
+            // show only filtered keys
+            if (!term) {
+                tr.classList.add("d-none");
+            }
+
+            // Key
+            {
+                const td = document.createElement("td");
+                const val = (c.key ?? "").toString();
+                td.textContent = val;
+                if (val) td.setAttribute("data-value", val.toLowerCase());
+                tr.appendChild(td);
+            }
+
+            // Value
+            {
+                const td = document.createElement("td");
+                const raw = (c.value ?? "").toString().trim();
+                td.textContent = raw;
+                if (raw) td.setAttribute("data-value", raw);
+                tr.appendChild(td);
+            }
+
+            // Description
+            {
+                const td = document.createElement("td");
+                const raw = (c.description ?? "").toString().trim();
+                td.textContent = raw;
+                if (raw) td.setAttribute("data-value", raw);
+                tr.appendChild(td);
+            }
+
+            // Actions
+            {
+                const td = document.createElement("td");
+                td.className = "actions";
+                td.style.textAlign = "center";
+                td.style.verticalAlign = "middle";
+
+                // Edit Button
+                const editSpan = document.createElement("span");
+                editSpan.className = "action-icon";
+                editSpan.setAttribute("role", "button");
+                editSpan.tabIndex = 0;
+                editSpan.title = "Edit config";
+                editSpan.setAttribute("aria-label", "Edit config");
+                editSpan.setAttribute("data-bs-toggle", "modal");
+                editSpan.setAttribute("data-bs-target", "#editConfigModal");
+                editSpan.setAttribute("data-action", "edit");
+                editSpan.setAttribute("data-config-key", String(c.key));
+                {
+                    const i = document.createElement("i");
+                    i.className = "bi bi-pencil-fill icon icon-action";
+                    i.setAttribute("aria-hidden", "true");
+                    editSpan.appendChild(i);
+                }
+
+                // Reset Button
+                const resetSpan = document.createElement("span");
+                resetSpan.className = "action-icon";
+                resetSpan.setAttribute("role", "button");
+                resetSpan.tabIndex = 0;
+                resetSpan.title = "Reset to default value";
+                resetSpan.setAttribute("aria-label", "Reset to default value");
+                resetSpan.setAttribute("data-action", "reset");
+                resetSpan.setAttribute("data-config-key", String(c.key));
+                {
+                    const i = document.createElement("i");
+                    i.className = "bi bi-arrow-counterclockwise icon icon-action";
+                    i.setAttribute("aria-hidden", "true");
+                    resetSpan.appendChild(i);
+                }
+
+                td.appendChild(editSpan);
+                td.appendChild(resetSpan);
+                tr.appendChild(td);
+            }
+
+            frag.appendChild(tr);
+        });
+    });
+
+    // publish all rows
+    tbody.appendChild(frag);
+
+    // hide loader and show table
+    loader.style.display = "none";
+    dataTable.classList.remove("d-none");
+}
+
+// -----------------------------
+// Edit Config: load data and pre-fill the form
+// -----------------------------
+async function editConfig(key) {
+    // Clear form first
+    clearEditConfigForm();
+
+    try {
+        const data = await serviceGetConfig(key);
+
+        // Store the ID of the config being edited
+        editingConfigKey = key;
+
+        // Static fields
+        document.getElementById("configKey").value = data.key ?? "";
+        document.getElementById("configDescription").value = data.description ?? "";
+
+        // Value elements (Text)
+        const valueInput = document.getElementById("configValue");
+        const valueGroup = valueInput.closest(".mb-2");
+
+        // Value elements (Booleans)
+        const boolGroup = document.getElementById("configValueBoolGroup");
+        const boolInput = document.getElementById("configValueBool");
+
+        // Group for select (allowed values)
+        const selectGroup = document.getElementById("configValueSelectGroup");
+        const selectInput = document.getElementById("configValueSelect");
+
+        // Value elements (range)
+        const rangeGroup = document.getElementById("configValueRangeGroup");
+        const rangeInput = document.getElementById("configValueRange");
+        const rangeDisplay = document.getElementById("configValueRangeDisplay");
+
+        // Value field
+        if (data.type === "boolean") {
+            // Boolean
+            const boolValue = data.value === true;
+            boolInput.checked = boolValue;
+
+            // Update value required
+            boolInput.required = false;
+            rangeInput.required = false;
+            selectInput.required = false;
+            valueInput.required = false;
+
+            // Update View on slider change
+            boolGroup.classList.remove("d-none");
+            rangeGroup.classList.add("d-none");
+            selectGroup.classList.add("d-none");
+            valueGroup.classList.add("d-none");
+
+        } else if (data.allowed !== undefined) {
+
+            // Range: Select (allowed values)
+
+            // svuota select
+            selectInput.innerHTML = "";
+
+            selectInput.dataset.type = data.type;
+
+            // popola opzioni
+            data.allowed.forEach(v => {
+                const opt = document.createElement("option");
+                opt.value = v;
+                opt.textContent = v;
+
+                if (String(v) === String(data.value)) {
+                    opt.selected = true;
+                }
+
+                selectInput.appendChild(opt);
+            });
+
+            // Update value required
+            boolInput.required = false;
+            rangeInput.required = false;
+            selectInput.required = true;
+            valueInput.required = false;
+
+            // Update View on slider change
+            boolGroup.classList.add("d-none");
+            rangeGroup.classList.add("d-none");
+            selectGroup.classList.remove("d-none");
+            valueGroup.classList.add("d-none");
+
+        } else if (data.min !== undefined && data.max !== undefined) {
+
+            // Range: Slider
+            rangeInput.min = data.min;
+            rangeInput.max = data.max;
+            rangeInput.value = data.value ?? data.min;
+
+            rangeDisplay.textContent = String(rangeInput.value);
+
+            // aggiorna display quando sposti slider
+            rangeInput.oninput = () => {
+                rangeDisplay.textContent = String(rangeInput.value);
+            };
+
+            // Update value required
+            boolInput.required = false;
+            rangeInput.required = true;
+            selectInput.required = false;
+            valueInput.required = false;
+
+            // Update View on slider change
+            boolGroup.classList.add("d-none");
+            rangeGroup.classList.remove("d-none");
+            selectGroup.classList.add("d-none");
+            valueGroup.classList.add("d-none");
+
+        } else {
+            // Text
+            valueInput.value = data.value ?? "";
+
+            // Update value required
+            boolInput.required = false;
+            rangeInput.required = false;
+            selectInput.required = false;
+            valueInput.required = true;
+
+            // Update View on slider change
+            boolGroup.classList.add("d-none");
+            rangeGroup.classList.add("d-none");
+            selectGroup.classList.add("d-none");
+            valueGroup.classList.remove("d-none");
+        }
+
+    } catch (err) {
+        console.error(err?.message || "Error loading config");
+        showToast(err?.message || "Error loading config", false);
+    }
+}
+
+// -----------------------------
+// Save config
+// -----------------------------
+async function saveConfig(configData) {
+    // Validate Value
+    switch (typeof configData.value) {
+        case "string":
+            if (!configData.value.trim()) {
+                showToast("Configuration value is required", false);
+                return false;
+            }
+            break;
+
+        case "boolean":
+            break;
+
+        case "number":
+            if (isNaN(configData.value)) {
+                showToast("Invalid numeric value", false);
+                return false;
+            }
+            break;
+
+        default:
+            showToast("Invalid configuration value", false);
+            return false;
+    }
+
+    try {
+        let result;
+
+        // Update
+        result = await serviceUpdateConfig(editingConfigKey, configData);
+        const msg = (typeof result === 'object' && result?.message)
+            ? result.message
+            : 'Config updated successfully';
+
+        showToast(msg, true);
+
+        return true;
+
+    } catch (err) {
+        console.error(err?.message || "Error updating config");
+        showToast(err?.message || "Error updating config", false);
+    }
+
+    return false;
+
+}
+
+// -----------------------------
+// Prepare add config form
+// -----------------------------
+function clearEditConfigForm() {
+    // reset edit mode
+    editingConfigKey = null;
+    // reset form fields
+    document.getElementById('editConfigForm')?.reset();
+}
+
+// -----------------------------
+// Close popup
+// -----------------------------
+async function closeEditConfigModal() {
+    const modalEl = document.getElementById('editConfigModal');
+    const modal = bootstrap.Modal.getInstance(modalEl)
+               || bootstrap.Modal.getOrCreateInstance(modalEl);
+    modal.hide();
+}
+
+// -----------------------------
+// Handle Edit config form submit
+// -----------------------------
+async function handleEditConfigSubmit(e) {
+    // Prevent default form submission
+    e.preventDefault();
+
+    try {
+        // Retrieve form data
+        const data = {
+            key:    document.getElementById('configKey').value.trim(),
+            value:  getConfigValueFromForm(),
+        };
+
+        const ok = await saveConfig(data);
+        if (ok !== false) {
+            // close modal and reload configs
+            closeEditConfigModal();
+            await fetchConfigs();
+            updateTable();
+            return true
+        }
+
+    } catch (err) {
+        console.error(err?.message || "Error saving config");
+        showToast(err?.message || "Error saving config", false);
+    }
+
+    return false;
+}
+
+// -----------------------------
+// Handle reset config action
+// -----------------------------
+async function handleResetConfig(e, el) {
+    // Prevent default action
+    e.preventDefault();
+
+    // Get config ID
+    const key = el.dataset.configKey;
+    if (typeof key !== "string" || key.length === 0) {
+        showToast('Configuration key not valid for reset', false);
+        return;
+    }
+
+    // Confirm requested
+    const confirmed = await showConfirmModal("Reset this configuration?");
+    if (!confirmed) return;
+
+    try {
+        const result = await serviceResetConfig(key);
+
+        const msg = (typeof result === 'object' && result?.message)
+            ? result.message
+            : 'Config reset to default successfully';
+
+        showToast(msg, true);
+
+        // Reload configs
+        await fetchConfigs();
+        updateTable();
+
+        return true;
+
+    } catch (err) {
+        console.error(err?.message || "Error resetting config to default");
+        showToast(err?.message || "Error resetting config to default", false);
+    }
+
+    return false;
+}
+
+// -----------------------------
+// Action Handlers
+// -----------------------------
+const actionHandlers = {
+    // Reset config
+    reset: (e, el) => {
+        handleResetConfig(e, el);
+    },
+    // Edit config
+    edit: () => {
+        // handled by bootstrap modal show event
+    },
+}
+
+// -----------------------------
+// DOMContentLoaded: bootstrap app
+// -----------------------------
+document.addEventListener("DOMContentLoaded", async () => {
+    await initApp();
+});
+
+// -----------------------------
+// APP INIT
+// -----------------------------
+async function initApp() {
+
+    // Load modals (Bootstrap 5 requires JS initialization for dynamic content)
+    try {
+        await loadModals();
+    } catch (err) {
+        console.error(err?.message || "Error loading modals");
+        showToast(err?.message || "Error loading modals", false);
+    }
+
+    // Load data (configs)
+    try {
+        await fetchConfigs();
+        updateTable();
+    } catch (err) {
+        console.error(err?.message || "Error loading configs");
+        showToast(err?.message || "Error loading configs", false);
+    }
+
+    initUI();
+    initEvents();
+}
+
+// -----------------------------
+// UI INIT
+// -----------------------------
+function initUI() {
+    initSearch();
+    initModalLifecycle();
+}
+
+// -----------------------------
+// SEARCH
+// -----------------------------
+function initSearch() {
+    // search bar
+    const input = document.getElementById("searchInput");
+    if (!input) return;
+
+    // clean input on load
+    input.value = "";
+    // live filter for each keystroke
+    input.addEventListener("input", (e) => {
+        const term = e.target.value.trim().toLowerCase();
+        updateTable();
+    });
+}
+
+// -----------------------------
+// MODAL LIFECYCLE (ADD / EDIT)
+// -----------------------------
+function initModalLifecycle() {
+
+    // Modal show/hidden events to prepare/reset the form
+    const modalEl = document.getElementById('editConfigModal');
+    if (!modalEl) return;
+
+    // store who opened the modal
+    let lastTriggerEl = null;
+
+    // When shown, determine Add or Edit mode
+    modalEl.addEventListener('show.bs.modal', async (ev) => {
+        lastTriggerEl = ev.relatedTarget; // trigger (Add o Edit)
+
+        // check Add or Edit mode based on presence of data-config-id in the trigger element
+        const key = lastTriggerEl?.dataset?.configKey;
+
+        if (typeof key === "string" && key.length > 0) {
+            // EDIT MODE
+            try {
+                await editConfig(key);
+            } catch (err) {
+                showToast(err?.message || "Error loading config", false);
+                // Close modal
+                modalEl.addEventListener('shown.bs.modal', () => {
+                    closeEditConfigModal(lastTriggerEl);
+                }, { once: true });
+            }
+        } else {
+            console.warn("Invalid Configuration Key for edit");
+            closeEditConfigModal();
+            return;
+        }
+    });
+
+    // When hiding, restore focus to the trigger element
+    modalEl.addEventListener('hide.bs.modal', () => {
+        const active = document.activeElement;
+        if (active && modalEl.contains(active)) {
+            lastTriggerEl?.focus?.({ preventScroll: true }) || active.blur();
+        }
+    });
+
+    // When hidden, reset the form
+    modalEl.addEventListener('hidden.bs.modal', () => {
+        // reset form fields
+        clearEditConfigForm();
+        // pulizia ref del trigger
+        lastTriggerEl = null;
+    });
+}
+
+// -----------------------------
+// GLOBAL EVENTS INIT
+// -----------------------------
+function initEvents() {
+    document.addEventListener('click', handleActionClick);
+    document.addEventListener('keydown', handleKeyboard);
+    document.addEventListener('submit', handleForms);
+    document.addEventListener("click", (e) => {
+        const groupRow = e.target.closest('tr[data-group]');
+        if (!groupRow || !groupRow.dataset.group) return;
+
+        const groupKey = groupRow.dataset.group;
+        toggleGroup(groupKey);
+    });
+    document.getElementById("expandAllBtn")
+        ?.addEventListener("click", expandAllGroups);
+    document.getElementById("collapseAllBtn")
+        ?.addEventListener("click", collapseAllGroups);
+    document.getElementById("restartBtn")
+        ?.addEventListener("click", restartApp);
+}
+
+// -----------------------------
+// CLICK (DATA-ACTION)
+// -----------------------------
+async function handleActionClick(e) {
+    const el = e.target.closest('[data-action]');
+    if (!el) return;
+
+    const action = el.dataset.action;
+    const handler = actionHandlers[action];
+    if (!handler) return;
+
+    // Execute handler
+    try {
+        await handler(e, el);
+    } catch (err) {
+        console.error(err?.message || 'Action error');
+        showToast(err?.message || 'Action error', false);
+    }
+}
+
+// -----------------------------
+// KEYBOARD (ESC + accessibility)
+// -----------------------------
+function handleKeyboard(e) {
+
+    // Button event delegation (Escape, Enter, Space)
+    const isEscape = e.key === 'Escape';
+    const isEnter = e.key === 'Enter';
+    const isSpace = e.key === ' ';
+
+    if (!isEscape && !isEnter && !isSpace) return;
+
+    // ESC delegation: clear search
+    if (isEscape) {
+        // 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;
+        const isSearchInput = e.target.id === "searchInput";
+
+        // ESC should clear search only if not focused on a typing field, or if focused on the search input (to allow quick clearing of search)
+        if (!isTypingField || isSearchInput) {
+            // Prevent default form submission
+            e.preventDefault();             // evita side-effect (es. chiusure di modali del browser)
+            clearSearch();                  // svuota input e ricarica tabella (come definito nella tua funzione)
+            viewConfigs = [...allConfigs];
+            updateTable();                  // aggiorna tabella
+        }
+        return;
+    }
+
+    // Enter / Space delegation
+    const el = e.target.closest('[data-action]');
+    if (!el) return;
+
+    // Space/Enter
+    if (el.tagName === 'BUTTON') return;
+    // Trigger click event
+    el.click();
+}
+
+// -----------------------------
+// FORM SUBMIT (delegation)
+// -----------------------------
+function handleForms(e) {
+    if (e.target.id === 'editConfigForm') {
+        handleEditConfigSubmit(e);
+    }
+}
index b6df99613fb4110b32cd12beb5acf5bc5c0da014..c29f98099def07e11fc475a51f5a684ab9de3023 100644 (file)
                 <!-- Menu -->
                 <div class="collapse navbar-collapse" id="menuNav">
                     <div class="navbar-nav ms-auto gap-2">
-                        <a href="/hosts"   id="hostsBtn"   class="btn btn-primary"        aria-current="page">Hostname</a>
-                        <a href="/aliases" id="aliasesBtn" class="btn btn-primary"        aria-current="page">Alias</a>
-                        <a href="/leases"  id="leasesBtn"  class="btn btn-primary active" aria-current="page">DHCP Leases</a>
-                        <a href="/devices" id="devicesBtn" class="btn btn-primary"        aria-current="page">Devices</a>
+                        <a href="/hosts"   id="hostsBtn"     class="btn btn-primary"        aria-current="page">Hostname</a>
+                        <a href="/aliases" id="aliasesBtn"   class="btn btn-primary"        aria-current="page">Alias</a>
+                        <a href="/leases"  id="leasesBtn"    class="btn btn-primary active" aria-current="page">DHCP Leases</a>
+                        <a href="/devices" id="devicesBtn"   class="btn btn-primary"        aria-current="page">Devices</a>
+                        <a href="/settings" id="settingsBtn" class="btn btn-primary"        aria-current="page">Settings</a>
                         <button id="logoutBtn" class="btn btn-primary">Logout</button>
                     </div>
                 </div>
index 726224c4b0bd119a2396490a0e59889b8813dd7d..86a46684341a41f6bae42aa4342107585050c06a 100644 (file)
         </div>
     </div>
 </div>
+
+<!-- EditConfig -->
+<div class="modal fade" id="editConfigModal" tabindex="-1" aria-labelledby="editConfigTitle" aria-hidden="true">
+    <div class="modal-dialog modal-dialog-centered"><!-- modal-sm|md|lg se vuoi cambiare -->
+        <div class="modal-content modal-content-sm 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"><i class="bi bi-diagram-2"></i></span>
+                    <h5 class="modal-title mb-0" id="editConfigTitle">Modifica Parametro</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="editConfigForm">
+                    <div class="mb-2">
+                        <label for="configKey" class="form-label">Configuration Parameter</label>
+                        <input type="text" id="configKey" class="form-control" readonly>
+                    </div>
+
+                    <div class="mb-2">
+                        <label for="configDescription" class="form-label">Description</label>
+                        <input type="text" id="configDescription" class="form-control" readonly>
+                    </div>
+
+                    <!-- Boolean Input -->
+                    <div class="mb-2 d-none" id="configValueBoolGroup">
+                        <label class="form-label">Value</label>
+                        <div class="form-check form-switch">
+                            <input type="checkbox" id="configValueBool" class="form-check-input">
+                            <label class="form-check-label" for="configValueBool">Enabled</label>
+                        </div>
+                    </div>
+
+                    <!-- Range Input -->
+                    <div class="mb-2 d-none" id="configValueRangeGroup">
+                        <label for="configValueRange" class="form-label">Value</label>
+                        <input type="range" id="configValueRange" class="form-range">
+                        <div class="small text-muted">
+                            Current: <span id="configValueRangeDisplay"></span>
+                        </div>
+                    </div>
+
+                    <!-- Select Input -->
+                    <div class="mb-2 d-none" id="configValueSelectGroup">
+                        <label for="configValueSelect" class="form-label">Value</label>
+                        <select id="configValueSelect" class="form-select"></select>
+                    </div>
+
+                    <!-- Text Input -->
+                    <div class="mb-2 d-none" id="configValueGroup">
+                        <label for="configValue" class="form-label">Value</label>
+                        <input type="text" id="configValue" class="form-control">
+                    </div>
+                </form>
+            </div>
+
+            <div class="modal-footer">
+                <button type="submit" form="editConfigForm" 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>
+
+<!-- Modal di conferma generico -->
+<div class="modal fade" id="confirmModal" tabindex="-1" aria-hidden="true">
+  <div class="modal-dialog modal-dialog-centered">
+    <div class="modal-content">
+
+      <div class="modal-header">
+        <h5 class="modal-title">Confirmation</h5>
+        <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
+      </div>
+
+      <div class="modal-body" id="confirmModalBody">
+        Are you sure?
+      </div>
+
+      <div class="modal-footer">
+        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
+          Cancel
+        </button>
+        <button type="button" class="btn btn-danger" id="confirmModalOk">
+          Confirm
+        </button>
+      </div>
+
+    </div>
+  </div>
+</div>
diff --git a/frontend/settings.html b/frontend/settings.html
new file mode 100644 (file)
index 0000000..68347f2
--- /dev/null
@@ -0,0 +1,163 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="UTF-8">
+    <title>Network Manager</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+
+    <!-- Bootstrap 5.x CSS (CDN) -->
+    <link
+        href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
+        rel="stylesheet"
+        integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
+        crossorigin="anonymous"
+    >
+    <!-- Bootstrap Icons -->
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
+
+    <!-- Boostrap override -->
+    <link rel="stylesheet" href="css/variables.css">
+    <link rel="stylesheet" href="css/layout.css">
+</head>
+
+<body>
+    <!-- Topbar -->
+    <header class="topbar">
+        <div class="topbar-inner">
+            <a href="/home" class="logo text-decoration-none">
+                <svg width="30" height="30" viewBox="0 0 24 24" fill="var(--accent)" aria-hidden="true">
+                    <circle cx="12" cy="4" r="2"></circle>
+                    <circle cx="4" cy="12" r="2"></circle>
+                    <circle cx="20" cy="12" r="2"></circle>
+                    <circle cx="12" cy="20" r="2"></circle>
+                    <line x1="12" y1="6" x2="12" y2="18" stroke="var(--accent)" stroke-width="2"></line>
+                    <line x1="6" y1="12" x2="18" y2="12" stroke="var(--accent)" stroke-width="2"></line>
+                </svg>
+                <span>Network Manager</span>
+            </a>
+
+            <!-- Spacer -->
+            <div class="col d-none d-md-block"></div>
+
+            <nav class="navbar navbar-expand-md px-3">
+                <!-- Bottone hamburger -->
+                <button class="navbar-toggler bg-light" type="button" data-bs-toggle="collapse" data-bs-target="#menuNav">
+                    <span class="navbar-toggler-icon"></span>
+                </button>
+                <!-- Menu -->
+                <div class="collapse navbar-collapse" id="menuNav">
+                    <div class="navbar-nav ms-auto gap-2">
+                        <a href="/hosts"   id="hostsBtn"     class="btn btn-primary"        aria-current="page">Hostname</a>
+                        <a href="/aliases" id="aliasesBtn"   class="btn btn-primary"        aria-current="page">Alias</a>
+                        <a href="/leases"  id="leasesBtn"    class="btn btn-primary"        aria-current="page">DHCP Leases</a>
+                        <a href="/devices" id="devicesBtn"   class="btn btn-primary"        aria-current="page">Devices</a>
+                        <a href="/settings" id="settingsBtn" class="btn btn-primary active" aria-current="page">Settings</a>
+                        <button id="logoutBtn" class="btn btn-primary">Logout</button>
+                    </div>
+                </div>
+            </nav>
+        </div>
+    </header>
+
+    <!-- Toast -->
+    <div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
+
+    <!-- Toolbar / Section header -->
+    <section class="page-frame">
+        <div class="container-fluid p-0">
+            <div class="row g-2 align-items-center">
+                <!-- Title -->
+                <div class="col-12 col-md-auto">
+                    <h2 class="mb-0 d-flex align-items-center gap-2 lh-1">
+                        <span class="title-icon"><i class="bi bi-gear"></i></span>
+                        <span class="section-title">Configuration Parameter 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="Search..."
+                            class="form-control form-control-sm placeholder-italic"
+                            aria-label="Search config"
+                        >
+                    </div>
+                </div>
+
+                <!-- Bottoni -->
+                <div class="col-12 col-md-auto d-flex align-items-center gap-2 flex-wrap">
+
+                       <!-- Expand -->
+                    <button id="expandAllBtn"
+                            class="btn btn-sm btn-outline-primary d-flex align-items-center gap-2 px-3"
+                            title="Expand all groups"
+                            aria-label="Expand all groups">
+                        <i class="bi bi-arrows-expand"></i>
+                        <span>Expand</span>
+                       </button>
+
+                    <!-- Collapse -->
+                    <button id="collapseAllBtn"
+                            class="btn btn-sm btn-outline-secondary d-flex align-items-center gap-2 px-3"
+                            title="Collapse all groups"
+                            aria-label="Collapse all groups">
+                        <i class="bi bi-arrows-collapse"></i>
+                        <span>Collapse</span>
+                    </button>
+
+                    <!-- Separator -->
+                    <div class="vr mx-2"></div>
+
+                    <!-- Restart -->
+                    <button id="restartBtn"
+                            class="btn btn-outline-danger d-flex align-items-center gap-2"
+                            title="Restart system"
+                            aria-label="Restart system">
+                        <i class="bi bi-arrow-repeat"></i>
+                        <span>Restart</span>
+                    </button>
+                </div>
+            </div>
+        </div>
+    </section>
+
+    <!-- Tabella -->
+    <table id="dataTable" class="table table-bordered table-hover align-middle d-none">
+        <thead class="table-light">
+            <tr>
+                <th data-type="string" data-sortable="false">Parameter  <span class="sort-arrow" aria-hidden="true"></span></th>
+                <th data-type="string" data-sortable="false">Value      <span class="sort-arrow" aria-hidden="true"></span></th>
+                <th data-type="string" data-sortable="false">Description<span class="sort-arrow" aria-hidden="true"></span></th>
+                <th data-type="string" data-sortable="false">Actions   <span class="sort-arrow" aria-hidden="true"></span></th>
+            </tr>
+        </thead>
+        <tbody></tbody>
+    </table>
+
+    <!-- Loader -->
+    <div id="loader" class="text-center my-3" style="display: none;">
+        <div class="spinner-border text-primary" role="status">
+            <span class="visually-hidden">Loading...</span>
+        </div>
+    </div>
+    <div id="devices-container"></div>
+
+    <!-- Modals -->
+    <div id="modals-container"></div>
+
+    <!-- Scripts -->
+    <script type="module" src="js/settings.js"></script>
+    <script type="module" src="js/session.js"></script>
+
+    <!-- Bootstrap JS -->
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
+            integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
+            crossorigin="anonymous"></script>
+</body>
+</html>