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
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
)
# 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)
app.include_router(aliases_router)
app.include_router(dns_router)
app.include_router(dhcp_router)
+ app.include_router(settings_router)
# CORS
cors_origins = [
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
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
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:
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 ---
# 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)
# 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
# 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
# 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
# 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
# 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
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
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():
}
# 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
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:
)
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",
# 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
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}")
# 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}")
# -----------------------------
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}")
# 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
# ---------------------------------------------------------
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,
+ },
}
# ---------------------------------------------------------
# ---------------------------------------------------------
_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
# ---------------------------------------------------------
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:
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:
# ---- Save in cache ----
_config_cache[key] = value
+
return value
# ---------------------------------------------------------
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
# ---------------------------------------------------------
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'))
);
""")
# 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"])
+ )
-# 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():
"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..."}
# 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
# ---------------------------------------------------------
@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}"
},
)
- backup_dir = Path(settings.BACKUP_PATH)
+ backup_dir = Path(get_config("BACKUP_PATH"))
backup_dir.mkdir(parents=True, exist_ok=True)
# safe filename
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
})
# 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"
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"
import asyncio
import json
import ipaddress
+from pathlib import Path
import time
# Import local modules
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)
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")
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)
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)
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)
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):
--- /dev/null
+# 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,
+ },
+ },
+ )
--- /dev/null
+# 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..."}
# 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
# -----------------------------
<!-- 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>
.form-check-input {
font-size: 0.80rem; /* più piccina */
margin-bottom: 2px; /* Bootstrap usa 8px; lo riduciamo */
+ cursor: pointer;
}
/* ================================
.table thead th {
background-color: #eee;
font-weight: 700;
- cursor: pointer;
+ cursor: default;
user-select: none;
position: relative;
}
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;
margin-right: 8px;
}
-/* removed due to background with icons
-.actions span:hover {
- background-color: #e0f0ff;
-}*/
-
/* ================================
Toast
================================ */
<!-- 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>
<!-- 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>
</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>
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();
+ });
+}
// -------------------------------------------------------
// 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
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)
// -------------------------------------------------------
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);
},
// Check API status
apiCheck: async () => {
- const result = await serviceCheckAbount();
+ const result = await serviceCheckAbout();
if(result) {
showToast('API status updated succesfully', true);
} else {
// Periodic API Check
// -------------------------------------------------------
async function periodicTest() {
- await serviceCheckAbount();
+ await serviceCheckAbout();
setTimeout(periodicTest, 10000);
}
// -------------------------------------------------------
// 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;
}
}
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;
+}
--- /dev/null
+// 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);
+ }
+}
<!-- 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>
</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>
--- /dev/null
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>Network Manager</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+
+ <!-- Bootstrap 5.x CSS (CDN) -->
+ <link
+ href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
+ rel="stylesheet"
+ integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
+ crossorigin="anonymous"
+ >
+ <!-- Bootstrap Icons -->
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
+
+ <!-- Boostrap override -->
+ <link rel="stylesheet" href="css/variables.css">
+ <link rel="stylesheet" href="css/layout.css">
+</head>
+
+<body>
+ <!-- Topbar -->
+ <header class="topbar">
+ <div class="topbar-inner">
+ <a href="/home" class="logo text-decoration-none">
+ <svg width="30" height="30" viewBox="0 0 24 24" fill="var(--accent)" aria-hidden="true">
+ <circle cx="12" cy="4" r="2"></circle>
+ <circle cx="4" cy="12" r="2"></circle>
+ <circle cx="20" cy="12" r="2"></circle>
+ <circle cx="12" cy="20" r="2"></circle>
+ <line x1="12" y1="6" x2="12" y2="18" stroke="var(--accent)" stroke-width="2"></line>
+ <line x1="6" y1="12" x2="18" y2="12" stroke="var(--accent)" stroke-width="2"></line>
+ </svg>
+ <span>Network Manager</span>
+ </a>
+
+ <!-- Spacer -->
+ <div class="col d-none d-md-block"></div>
+
+ <nav class="navbar navbar-expand-md px-3">
+ <!-- Bottone hamburger -->
+ <button class="navbar-toggler bg-light" type="button" data-bs-toggle="collapse" data-bs-target="#menuNav">
+ <span class="navbar-toggler-icon"></span>
+ </button>
+ <!-- Menu -->
+ <div class="collapse navbar-collapse" id="menuNav">
+ <div class="navbar-nav ms-auto gap-2">
+ <a href="/hosts" id="hostsBtn" class="btn btn-primary" aria-current="page">Hostname</a>
+ <a href="/aliases" id="aliasesBtn" class="btn btn-primary" aria-current="page">Alias</a>
+ <a href="/leases" id="leasesBtn" class="btn btn-primary" aria-current="page">DHCP Leases</a>
+ <a href="/devices" id="devicesBtn" class="btn btn-primary" 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>