From: Giorgio Ravera Date: Wed, 3 Jun 2026 12:56:17 +0000 (+0200) Subject: Added settings X-Git-Url: http://git.giorgioravera.it/?a=commitdiff_plain;h=605e59781b8eacc1f750d5a3219b5ece1e939a25;p=network-manager.git Added settings --- diff --git a/backend/app.py b/backend/app.py index 6f85f26..5df07a2 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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 = [ diff --git a/backend/backup.py b/backend/backup.py index 63eea9c..9c8ac87 100644 --- a/backend/backup.py +++ b/backend/backup.py @@ -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: diff --git a/backend/bootstrap.py b/backend/bootstrap.py index 2888520..a098680 100644 --- a/backend/bootstrap.py +++ b/backend/bootstrap.py @@ -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", diff --git a/backend/db/leases.py b/backend/db/leases.py index b4bc3f3..30d929c 100644 --- a/backend/db/leases.py +++ b/backend/db/leases.py @@ -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}") diff --git a/backend/db/settings.py b/backend/db/settings.py index 636c939..44dc1d6 100644 --- a/backend/db/settings.py +++ b/backend/db/settings.py @@ -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"]) + ) diff --git a/backend/routes/about.py b/backend/routes/about.py index 2f5fd02..95ffe31 100644 --- a/backend/routes/about.py +++ b/backend/routes/about.py @@ -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..."} diff --git a/backend/routes/backup.py b/backend/routes/backup.py index 0fcf2b7..4bf2326 100644 --- a/backend/routes/backup.py +++ b/backend/routes/backup.py @@ -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 diff --git a/backend/routes/dhcp.py b/backend/routes/dhcp.py index 9cdf71a..55e8229 100644 --- a/backend/routes/dhcp.py +++ b/backend/routes/dhcp.py @@ -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" diff --git a/backend/routes/dns.py b/backend/routes/dns.py index 2e697fb..b39173c 100644 --- a/backend/routes/dns.py +++ b/backend/routes/dns.py @@ -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 index 0000000..2802698 --- /dev/null +++ b/backend/routes/settings.py @@ -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 index 0000000..ad20b74 --- /dev/null +++ b/backend/routes/system.py @@ -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..."} diff --git a/backend/utils.py b/backend/utils.py index ae8f706..fb93cb2 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -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 # ----------------------------- diff --git a/frontend/aliases.html b/frontend/aliases.html index 0e77aea..efd4a5d 100644 --- a/frontend/aliases.html +++ b/frontend/aliases.html @@ -47,10 +47,11 @@ diff --git a/frontend/css/layout.css b/frontend/css/layout.css index 2579bb8..2e990f0 100644 --- a/frontend/css/layout.css +++ b/frontend/css/layout.css @@ -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 ================================ */ diff --git a/frontend/devices.html b/frontend/devices.html index fc24525..1153377 100644 --- a/frontend/devices.html +++ b/frontend/devices.html @@ -47,10 +47,11 @@ diff --git a/frontend/hosts.html b/frontend/hosts.html index d995379..5707b43 100644 --- a/frontend/hosts.html +++ b/frontend/hosts.html @@ -47,10 +47,11 @@ diff --git a/frontend/index.html b/frontend/index.html index 226eddc..6c71f99 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -159,7 +159,7 @@ - +

Impostazioni

Configurazione sistema e variabili.

diff --git a/frontend/js/common.js b/frontend/js/common.js index 63b4758..a2a4b83 100644 --- a/frontend/js/common.js +++ b/frontend/js/common.js @@ -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} - 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(); + }); +} diff --git a/frontend/js/index.js b/frontend/js/index.js index 40cdd88..16c3a74 100644 --- a/frontend/js/index.js +++ b/frontend/js/index.js @@ -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); } diff --git a/frontend/js/services.js b/frontend/js/services.js index efa949d..cd0531c 100644 --- a/frontend/js/services.js +++ b/frontend/js/services.js @@ -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 index 0000000..c3cd443 --- /dev/null +++ b/frontend/js/settings.js @@ -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 + ? '' + : ''; + 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); + } +} diff --git a/frontend/leases.html b/frontend/leases.html index b6df996..c29f980 100644 --- a/frontend/leases.html +++ b/frontend/leases.html @@ -47,10 +47,11 @@
diff --git a/frontend/modals.html b/frontend/modals.html index 726224c..86a4668 100644 --- a/frontend/modals.html +++ b/frontend/modals.html @@ -253,3 +253,100 @@ + + + + + + diff --git a/frontend/settings.html b/frontend/settings.html new file mode 100644 index 0000000..68347f2 --- /dev/null +++ b/frontend/settings.html @@ -0,0 +1,163 @@ + + + + + Network Manager + + + + + + + + + + + + + + +
+
+ + + +
+ + +
+
+ + +
+ + +
+
+
+ +
+

+ + Configuration Parameter List +

+
+ + +
+ + +
+
+ +
+
+ + +
+ + + + + + + + +
+ + + +
+
+
+
+ + + + + + + + + + + + +
Parameter Value DescriptionActions
+ + + +
+ + +
+ + + + + + + + +