]> git.giorgioravera.it Git - network-manager.git/commitdiff
Progress with backup
authorGiorgio Ravera <giorgio.ravera@gmail.com>
Thu, 28 May 2026 14:33:50 +0000 (16:33 +0200)
committerGiorgio Ravera <giorgio.ravera@gmail.com>
Thu, 28 May 2026 14:33:50 +0000 (16:33 +0200)
backend/backup.py
backend/bootstrap.py
backend/routes/backup.py
frontend/css/layout.css
frontend/index.html
frontend/js/index.js
frontend/js/services.js
frontend/modals.html

index 64ab6b27423353542d9cad2d67100cf248d0f1fb..44952034ad31fd07306c7fe61b5fc0e5baa4540a 100644 (file)
@@ -5,8 +5,9 @@ from datetime import datetime, timezone
 import hashlib
 import json
 import os
+from pathlib import Path
 import time
-from typing import List, Dict, Any, Optional
+from typing import List, Dict, Any, Optional, Union
 import zipfile
 
 # Import local modules
@@ -21,9 +22,6 @@ from backend.log.log import get_logger
 # Logger initialization
 logger = get_logger(__name__)
 
-# Timestamp used for backup file naming
-TIMESTAMP = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
-
 # Backup files to include in the archive (must match metadata structure)
 backup_files = [
     config.BACKUP_METADATA_FILE,
@@ -34,10 +32,44 @@ backup_files = [
  # Set to True to remove individual backup files after creating the archive (optional, can be set to False for debugging)
 remove_backup_files = True
 
+# ---------------------------------------------------------
+# Internal: Generate Filestamp
+# ---------------------------------------------------------
+def generate_timestamps() -> dict:
+    now = datetime.now(timezone.utc)
+
+    return {
+        "iso": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
+        "file": now.strftime("%Y%m%d_%H%M%S"),
+    }
+
+# ---------------------------------------------------------
+# Internal: Build summary JSON
+# ---------------------------------------------------------
+def build_result(operations: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
+
+    summary = {
+        "total": len(operations),
+        "success": sum(1 for op in operations.values() if op.get("status") == "success"),
+        "failed": sum(1 for op in operations.values() if op.get("status") == "failure"),
+    }
+
+    errors = sum(
+        ((op.get("errors") or []) for op in operations.values()),
+        []
+    )
+
+    return {
+        **operations,
+        "summary": summary,
+        "errors": errors,
+    }
+
 # ---------------------------------------------------------
 # Internal: Calculate file checksum
 # ---------------------------------------------------------
-def file_checksum(path: str) -> str:
+def file_checksum(path: Union[str, Path]) -> str:
+    path = Path(path)
     h = hashlib.sha256()
     with open(path, "rb") as f:
         for chunk in iter(lambda: f.read(8192), b""):
@@ -45,46 +77,54 @@ def file_checksum(path: str) -> str:
     return h.hexdigest()
 
 # ---------------------------------------------------------
-# Create Backup Archive (ZIP)
+# Internal: Create Backup Archive (ZIP)
 # ---------------------------------------------------------
-def create_backup_archive(timestamp: Optional[str] = None) -> Dict[str, Any]:
+def create_backup_archive(
+    *,
+    zip_name: Optional[str] = None,
+    zip_dir: Optional[str] = None,
+    files_dir: Optional[str] = None,
+    remove_files: bool = False
+) -> Dict[str, Any]:
 
     # Initialization
     start_ns = time.monotonic_ns()
     count = 0
     errors: List[str] = []
-    ts = timestamp or TIMESTAMP
 
     try:
-        # File paths
-        backup_dir = settings.BACKUP_PATH
-        zip_name = f"backup_{ts}.zip"
-        zip_path = os.path.join(backup_dir, zip_name)
-
-        # Create ZIP
-        with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as z:
-
+        # --- Paths ---
+        base_zip_dir = Path(zip_dir or settings.BACKUP_PATH)
+        base_files_dir = Path(files_dir or settings.BACKUP_PATH)
+        base_zip_dir.mkdir(parents=True, exist_ok=True)
+
+        # zip name
+        if not zip_name:
+            ts = time.strftime("%Y%m%d_%H%M%S")
+            zip_name = f"backup_{ts}.zip"
+        zip_file = base_zip_dir / zip_name
+
+        # --- Create ZIP ---
+        with zipfile.ZipFile(zip_file, "w", compression=zipfile.ZIP_DEFLATED) as z:
             for fname in backup_files:
-                fpath = os.path.join(backup_dir, fname)
-
-                if not os.path.isfile(fpath):
+                fpath = base_files_dir / fname
+                if not fpath.is_file():
                     raise FileNotFoundError(f"Missing file for archive: {fname}")
-
-                # arcname evita path assoluti dentro lo zip
                 z.write(fpath, arcname=fname)
-
-                # increment count of included files
                 count += 1
 
-        # Calcolo SHA256 dello zip
-        archive_sha256 = file_checksum(zip_path)
+        # --- Checksum ---
+        archive_sha256 = file_checksum(zip_file)
 
-        if remove_backup_files == True:
-            # Remove files after archiving (optional, can be commented out if you want to keep them)
+        # --- Cleanup ---
+        if remove_files:
             for fname in backup_files:
-                fpath = os.path.join(backup_dir, fname)
-                if os.path.isfile(fpath):
-                    os.remove(fpath)
+                fpath = base_files_dir / fname
+                fpath.unlink(missing_ok=True)
+            # Remove folder if empty
+            p = base_files_dir
+            if p.is_dir() and not any(p.iterdir()):
+                p.rmdir()
 
     except Exception as e:
         logger.exception("create_backup_archive failed: %s", str(e).strip())
@@ -92,46 +132,93 @@ def create_backup_archive(timestamp: Optional[str] = None) -> Dict[str, Any]:
 
     took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
 
-    if errors:
-        result: Dict[str, Any] = {
-            "status": "failure",
-            "file": zip_path,
-            "errors": errors,
-            "took_ms": took_ms,
-        }
-    else:
-        result: Dict[str, Any] = {
-            "status": "success",
-            "file": zip_path,
-            "count": count,
-            "sha256": archive_sha256,
-            "took_ms": took_ms,
-        }
+    return {
+        "status": "failure" if errors else "success",
+        "file": str(zip_file) if not errors else None,
+        "count": count if not errors else 0,
+        "sha256": archive_sha256 if not errors else None,
+        "errors": errors,
+        "took_ms": took_ms,
+    }
 
-    return result
+# ---------------------------------------------------------
+# Internal: Unzip Backup Archive (ZIP)
+# ---------------------------------------------------------
+def unzip_backup_archive(
+    *,
+    zip_path: Optional[str] = None,
+    zip_name: Optional[str] = None,
+    zip_dir: Optional[str] = None,
+    extract_dir: Optional[str] = None
+) -> Dict[str, Any]:
+
+    # Initialization
+    start_ns = time.monotonic_ns()
+    count = 0
+    errors: List[str] = []
+
+    try:
+        # --- Resolve paths ---
+        base_zip_dir = zip_dir or settings.BACKUP_PATH
+
+        if not zip_path:
+            if not zip_name:
+                raise ValueError("Either zip_path or zip_name must be provided")
+
+            zip_path = Path(base_zip_dir) / zip_name
+
+        base_extract_dir = extract_dir or settings.BACKUP_PATH
+        Path(base_extract_dir).mkdir(parents=True, exist_ok=True)
+
+        # --- Unzip ---
+        with zipfile.ZipFile(zip_path, "r") as z:
+            z.extractall(base_extract_dir)
+            count = len(z.namelist())
+
+    except Exception as e:
+        logger.exception("unzip_backup_archive failed: %s", str(e).strip())
+        errors.append(str(e))
+
+    took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
 
+    return {
+        "status": "failure" if errors else "success",
+        "file": str(zip_path),
+        "extract_dir": str(base_extract_dir) if not errors else None,
+        "count": count if not errors else 0,
+        "errors": errors,
+        "took_ms": took_ms,
+    }
 
 # ---------------------------------------------------------
 # Save Hosts DB
 # ---------------------------------------------------------
-def store_hosts(timestamp: Optional[str] = None) -> Dict[str, Any]:
+def store_hosts(
+    *,
+    timestamp: str,
+    filename: Optional[str] = None,
+    filepath: Optional[str] = None,
+) -> Dict[str, Any]:
 
     # Initialization
     start_ns = time.monotonic_ns()
-    path = os.path.join(settings.BACKUP_PATH, config.BACKUP_HOSTS_FILE)
+    filepath = Path(filepath or settings.BACKUP_PATH)
+    filename = filename or config.BACKUP_HOSTS_FILE
+    file = filepath / filename
+    filepath.mkdir(parents=True, exist_ok=True)
     count_stored = 0
     count_loaded = 0
     errors: List[str] = []
-    ts = timestamp or TIMESTAMP
 
     try:
         # Get Hosts List
         hosts = get_hosts()
         count_loaded = len(hosts)
 
-        with open(path, "w", encoding="utf-8") as f:
+        # Backup Hosts DB
+        with open(file, "w", encoding="utf-8") as f:
             data = {
-                "generated_at": ts,
+                "generated_at": timestamp,
                 "count": count_loaded,
                 "hosts": hosts,
             }
@@ -146,7 +233,7 @@ def store_hosts(timestamp: Optional[str] = None) -> Dict[str, Any]:
     if errors:
         result: Dict[str, Any] = {
             "status": "failure",
-            "file": path,
+            "file": str(file),
             "errors": errors,
             "took_ms": took_ms,
         }
@@ -154,7 +241,7 @@ def store_hosts(timestamp: Optional[str] = None) -> Dict[str, Any]:
         count_stored = count_loaded
         result: Dict[str, Any] = {
             "status": "success",
-            "file": path,
+            "file": str(file),
             "count_loaded": count_loaded,
             "count_stored": count_stored,
             "took_ms": took_ms,
@@ -165,20 +252,24 @@ def store_hosts(timestamp: Optional[str] = None) -> Dict[str, Any]:
 # ---------------------------------------------------------
 # Restore Hosts DB
 # ---------------------------------------------------------
-def restore_hosts(file: Optional[str] = None) -> Dict[str, Any]:
+def restore_hosts(
+    filepath: Optional[str] = None,
+    filename: Optional[str] = None,
+    remove_file: bool = False
+) -> Dict[str, Any]:
 
     # Initialization
     start_ns = time.monotonic_ns()
-    if file is None:
-        file = config.BACKUP_HOSTS_FILE
-    path = os.path.join(settings.BACKUP_PATH, file)
+    filepath = Path(filepath or settings.BACKUP_PATH)
+    filename = filename or config.BACKUP_HOSTS_FILE
+    file = filepath / filename
     count_restored = 0
     count_loaded = 0
     hosts: List[Dict[str, Any]] = []
     errors: List[str] = []
 
     try:
-        with open(path, "r", encoding="utf-8") as f:
+        with open(file, "r", encoding="utf-8") as f:
             data = json.load(f)
             hosts = data.get("hosts", [])
             count_loaded = data.get("count", 0)
@@ -196,7 +287,7 @@ def restore_hosts(file: Optional[str] = None) -> Dict[str, Any]:
     if errors:
         result: Dict[str, Any] = {
             "status": "failure",
-            "file": path,
+            "file": str(file),
             "errors": errors,
             "took_ms": took_ms,
         }
@@ -204,26 +295,37 @@ def restore_hosts(file: Optional[str] = None) -> Dict[str, Any]:
         count_stored = count_loaded
         result: Dict[str, Any] = {
             "status": "success",
-            "file": path,
+            "file": str(file),
             "count_loaded": count_loaded,
             "count_stored": count_stored,
             "took_ms": took_ms,
         }
 
+    # --- Cleanup ---
+    if remove_file:
+        file.unlink(missing_ok=True)
+
     return result
 
 # ---------------------------------------------------------
 # Save Aliases DB
 # ---------------------------------------------------------
-def store_aliases(timestamp: Optional[str] = None) -> Dict[str, Any]:
+def store_aliases(
+    *,
+    timestamp: str,
+    filename: Optional[str] = None,
+    filepath: Optional[str] = None,
+) -> Dict[str, Any]:
 
     # Initialization
     start_ns = time.monotonic_ns()
-    path = os.path.join(settings.BACKUP_PATH, config.BACKUP_ALIASES_FILE)
+    filepath = Path(filepath or settings.BACKUP_PATH)
+    filepath.mkdir(parents=True, exist_ok=True)
+    filename = filename or config.BACKUP_ALIASES_FILE
+    file = filepath / filename
     count_stored = 0
     count_loaded = 0
     errors: List[str] = []
-    ts = timestamp or TIMESTAMP
 
     try:
         # Get Aliases List
@@ -231,10 +333,9 @@ def store_aliases(timestamp: Optional[str] = None) -> Dict[str, Any]:
         count_loaded = len(aliases)
 
         # Backup Aliases DB
-        path = os.path.join(settings.BACKUP_PATH, config.BACKUP_ALIASES_FILE)
-        with open(path, "w", encoding="utf-8") as f:
+        with open(file, "w", encoding="utf-8") as f:
             data = {
-                "generated_at": ts,
+                "generated_at": timestamp,
                 "count": count_loaded,
                 "aliases": aliases,
             }
@@ -249,7 +350,7 @@ def store_aliases(timestamp: Optional[str] = None) -> Dict[str, Any]:
     if errors:
         result: Dict[str, Any] = {
             "status": "failure",
-            "file": path,
+            "file": str(file),
             "errors": errors,
             "took_ms": took_ms,
         }
@@ -257,7 +358,7 @@ def store_aliases(timestamp: Optional[str] = None) -> Dict[str, Any]:
         count_stored = count_loaded
         result: Dict[str, Any] = {
             "status": "success",
-            "file": path,
+            "file": str(file),
             "count_loaded": count_loaded,
             "count_stored": count_stored,
             "took_ms": took_ms,
@@ -268,20 +369,25 @@ def store_aliases(timestamp: Optional[str] = None) -> Dict[str, Any]:
 # ---------------------------------------------------------
 # Restore Aliases DB
 # ---------------------------------------------------------
-def restore_aliases(file: Optional[str] = None) -> Dict[str, Any]:
+def restore_aliases(
+    filepath: Optional[str] = None,
+    filename: Optional[str] = None,
+    remove_file: bool = False
+) -> Dict[str, Any]:
+
 
     # Initialization
     start_ns = time.monotonic_ns()
-    if file is None:
-        file = config.BACKUP_ALIASES_FILE
-    path = os.path.join(settings.BACKUP_PATH, file)
+    filepath = Path(filepath or settings.BACKUP_PATH)
+    filename = filename or config.BACKUP_ALIASES_FILE
+    file = filepath / filename
     count_restored = 0
     count_loaded = 0
     aliases: List[Dict[str, Any]] = []
     errors: List[str] = []
 
     try:
-        with open(path, "r", encoding="utf-8") as f:
+        with open(file, "r", encoding="utf-8") as f:
             data = json.load(f)
             aliases = data.get("aliases", [])
             count_loaded = data.get("count", 0)
@@ -299,7 +405,7 @@ def restore_aliases(file: Optional[str] = None) -> Dict[str, Any]:
     if errors:
         result: Dict[str, Any] = {
             "status": "failure",
-            "file": path,
+            "file": str(file),
             "errors": errors,
             "took_ms": took_ms,
         }
@@ -307,29 +413,41 @@ def restore_aliases(file: Optional[str] = None) -> Dict[str, Any]:
         count_stored = count_loaded
         result: Dict[str, Any] = {
             "status": "success",
-            "file": path,
+            "file": str(file),
             "count_loaded": count_loaded,
             "count_stored": count_stored,
             "took_ms": took_ms,
         }
 
+
+    # --- Cleanup ---
+    if remove_file:
+        file.unlink(missing_ok=True)
+
     return result
 
 # ---------------------------------------------------------
-# Save Metadata DB
+# Save Metadata
 # ---------------------------------------------------------
-def store_metadata(timestamp: Optional[str] = None) -> Dict[str, Any]:
+def store_metadata(
+    *,
+    timestamp: str,
+    filename: Optional[str] = None,
+    filepath: Optional[str] = None,
+) -> Dict[str, Any]:
 
     # Initialization
     start_ns = time.monotonic_ns()
-    path = os.path.join(settings.BACKUP_PATH, config.BACKUP_METADATA_FILE)
+    filepath = Path(filepath or settings.BACKUP_PATH)
+    filepath.mkdir(parents=True, exist_ok=True)
+    filename = filename or config.BACKUP_METADATA_FILE
+    file = filepath / filename
     errors: List[str] = []
-    ts = timestamp or TIMESTAMP
 
     try:
-        with open(path, "w", encoding="utf-8") as f:
+        with open(file, "w", encoding="utf-8") as f:
             data = {
-                "generated_at": ts,
+                "generated_at": timestamp,
                 "backup_version": config.BACKUP_VERSION,
                 "db_structure_version": config.BACKUP_DB_STRUCTURE_VERSION,
                 "file_count": 2,
@@ -337,12 +455,12 @@ def store_metadata(timestamp: Optional[str] = None) -> Dict[str, Any]:
                     {
                         "name": "hosts",
                         "file": config.BACKUP_HOSTS_FILE,
-                        "sha256": file_checksum(os.path.join(settings.BACKUP_PATH, config.BACKUP_HOSTS_FILE)),
+                        "sha256": file_checksum(filepath / config.BACKUP_HOSTS_FILE),
                     },
                     {
                         "name": "aliases",
                         "file": config.BACKUP_ALIASES_FILE,
-                        "sha256": file_checksum(os.path.join(settings.BACKUP_PATH, config.BACKUP_ALIASES_FILE)),
+                        "sha256": file_checksum(filepath / config.BACKUP_ALIASES_FILE),
                     },
                 ]
             }
@@ -357,7 +475,7 @@ def store_metadata(timestamp: Optional[str] = None) -> Dict[str, Any]:
     if errors:
         result: Dict[str, Any] = {
             "status": "failure",
-            "file": path,
+            "file": str(file),
             "version": config.BACKUP_VERSION,
             "db_structure_version": config.BACKUP_DB_STRUCTURE_VERSION,
             "errors": errors,
@@ -366,7 +484,7 @@ def store_metadata(timestamp: Optional[str] = None) -> Dict[str, Any]:
     else:
         result: Dict[str, Any] = {
             "status": "success",
-            "file": path,
+            "file": str(file),
             "version": config.BACKUP_VERSION,
             "db_structure_version": config.BACKUP_DB_STRUCTURE_VERSION,
             "file_count": 2,
@@ -378,14 +496,20 @@ def store_metadata(timestamp: Optional[str] = None) -> Dict[str, Any]:
 # ---------------------------------------------------------
 # Check Metadata
 # ---------------------------------------------------------
-def check_metadata() -> Dict[str, Any]:
+def check_metadata(
+    filepath: Optional[str] = None,
+    filename: Optional[str] = None,
+    remove_file: bool = False
+) -> Dict[str, Any]:
 
     # Initialization
     start_ns = time.monotonic_ns()
-    path = os.path.join(settings.BACKUP_PATH, config.BACKUP_METADATA_FILE)
+    filepath = Path(filepath or settings.BACKUP_PATH)
+    filename = filename or config.BACKUP_METADATA_FILE
+    file = filepath / filename
 
     try:
-        with open(path, "r", encoding="utf-8") as f:
+        with open(file, "r", encoding="utf-8") as f:
             metadata = json.load(f)
 
         # Validate structure
@@ -411,17 +535,16 @@ def check_metadata() -> Dict[str, Any]:
             if "sha256" not in file_meta:
                 raise ValueError(f"Missing checksum for file: {file_meta.get('file')}")
 
-            file_path = os.path.join(settings.BACKUP_PATH, file_meta["file"])
-
-            if not os.path.isfile(file_path):
+            backup_file = filepath / file_meta["file"]
+            if not backup_file.is_file():
                 raise FileNotFoundError(f"Backup file not found: {file_meta['file']}")
 
-            if file_checksum(file_path) != file_meta["sha256"]:
+            if file_checksum(backup_file) != file_meta["sha256"]:
                 raise ValueError(f"Checksum mismatch for file: {file_meta['file']}")
 
         result: Dict[str, Any] = {
             "status": "success",
-            "file": path,
+            "file": str(file),
             "version": metadata.get("backup_version"),
             "db_structure_version": metadata.get("db_structure_version"),
             "file_count": metadata.get("file_count"),
@@ -433,111 +556,160 @@ def check_metadata() -> Dict[str, Any]:
         logger.exception("check_metadata failed reading metadata: %s", str(e).strip())
         result = {
             "status": "failure",
-            "file": path,
+            "file": str(file),
             "errors": [str(e)],
             "took_ms": (time.monotonic_ns() - start_ns) / 1_000_000,
         }
 
+    # --- Cleanup ---
+    if remove_file:
+        file.unlink(missing_ok=True)
+
     return result
 
 # ---------------------------------------------------------
 # Backup DB
 # ---------------------------------------------------------
-def backup() -> Dict[str, Any]:
+def backup_create() -> Dict[str, Any]:
 
     # Ensure backup directory exists
-    os.makedirs(settings.BACKUP_PATH, exist_ok=True)
+    base_dir = Path(settings.BACKUP_PATH)
+    base_dir.mkdir(parents=True, exist_ok=True)
 
     # Timestamp used for backup file naming
-    timestamp = TIMESTAMP
-
-    # Hosts
-    hosts_result = store_hosts(timestamp)
-    # Aliases
-    aliases_result = store_aliases(timestamp)
-    # Metadata (must be last to ensure it reflects the actual state of files)
-    metadata_result = store_metadata(timestamp)
-    # Create ZIP archive only if all individual file operations succeeded
-    archive_result = create_backup_archive(timestamp)
-    # Process errors from individual operations
-    errors = ((metadata_result.get("errors") or [])
-            + (hosts_result.get("errors") or [])
-            + (aliases_result.get("errors") or [])
-            + (archive_result.get("errors") or [])
-    )
-
-    # Compute summary
-    operations = [metadata_result, hosts_result, aliases_result, archive_result]
-    summary = {
-        "total": len(operations),
-        "success": sum(1 for op in operations if op.get("status") == "success"),
-        "failed": sum(1 for op in operations if op.get("status") == "failure"),
+    ts = generate_timestamps()
+    timestamp = ts["iso"]           # per metadata/API
+    file_timestamp = ts["file"]      # per filename
+
+    # Create zip folder
+    zip_name = f"backup_{file_timestamp}.zip"
+    backup_path=base_dir / f"backup_{file_timestamp}"
+    backup_path.mkdir(parents=True, exist_ok=True)
+
+    # Init struttura unica
+    operations = {
+        "metadata": {},
+        "hosts": {},
+        "aliases": {},
+        "archive": {},
     }
 
-    # Collect errors and results
-    result = {
-        "metadata": metadata_result,
-        "hosts": hosts_result,
-        "aliases": aliases_result,
-        "archive": archive_result,
-        "summary": summary,
-    }
+    # --- STEP ---
+    operations["hosts"] = store_hosts(timestamp=timestamp, filepath=backup_path)
+    operations["aliases"] = store_aliases(timestamp=timestamp, filepath=backup_path)
+    operations["metadata"] = store_metadata(timestamp=timestamp, filepath=backup_path)
 
-    return result
+    # Zip Creation
+    operations["archive"] = create_backup_archive(zip_name=zip_name, files_dir=backup_path, remove_files=remove_backup_files)
+
+    return build_result(operations)
+
+# ---------------------------------------------------------
+# Get list of available backup files in backup directory
+# ---------------------------------------------------------
+def backup_list() -> List[Dict[str, Any]]:
+
+    # Initialization
+    backup_dir = Path(settings.BACKUP_PATH)
+    backups = []
+
+    if backup_dir.is_dir():
+        for filepath in backup_dir.iterdir():
+            if filepath.name.startswith("backup_") and filepath.suffix == ".zip":
+                backups.append({
+                    "file": str(filepath),
+                    "name": filepath.name,
+                    "created_at": datetime.fromtimestamp(filepath.stat().st_mtime, timezone.utc).isoformat(),
+                    "size_bytes": filepath.stat().st_size,
+                })
+
+    return backups
 
 # ---------------------------------------------------------
 # Restore DB
 # ---------------------------------------------------------
-def restore(cleanup: bool = True) -> Dict[str, Any]:
-
-    # Check metadata first to ensure backup is valid before applying changes
-    metadata_result = check_metadata()
-    if(metadata_result.get("status") != "success"):
-        return {
-            "metadata": metadata_result,
-            "hosts": None,
-            "aliases": None,
-            "summary": {
-                "total": 1,
-                "success": 0,
-                "failed": 1,
-            },
-        }
+def backup_restore(backup_id: str, cleanup: bool = True) -> Dict[str, Any]:
+
+    # Init struttura unica
+    operations = {
+        "archive": {},
+        "metadata": {},
+        "hosts": {},
+        "aliases": {},
+    }
+
+    # Check if backup file exists
+    backup_dir = Path(settings.BACKUP_PATH)
+    backup_file = backup_dir / backup_id
+    extract_dir = backup_dir / Path(backup_id).stem
+
+    if not backup_file.is_file():
+        logger.error(f"Backup file not found: {backup_file}")
+        raise FileNotFoundError("Backup file not found")
 
+    # --- ARCHIVE ---
+    operations["archive"] = unzip_backup_archive(zip_name=backup_id, extract_dir=extract_dir)
+    if operations["archive"].get("status") != "success":
+        return build_result(operations)
+
+    # --- METADATA ---
+    operations["metadata"] = check_metadata(filepath=extract_dir, remove_file=remove_backup_files)
+    if operations["metadata"].get("status") != "success":
+        return build_result(operations)
+
+    # --- CLEANUP ---
     if cleanup:
         try:
             reset_hosts_db()
             reset_aliases_db()
-
         except Exception as e:
             logger.exception("Cleanup failed %s", str(e).strip())
             raise
 
-    for f in metadata_result["files"]:
+    # --- RESTORE FILES ---
+    for f in operations["metadata"]["files"]:
         if f["name"] == "hosts":
-            hosts_result = restore_hosts(f["file"])
+            operations["hosts"] = restore_hosts(filepath=extract_dir, filename=f["file"], remove_file=remove_backup_files)
 
         elif f["name"] == "aliases":
-            aliases_result = restore_aliases(f["file"])
+            operations["aliases"] = restore_aliases(filepath=extract_dir, filename=f["file"], remove_file=remove_backup_files)
 
-    errors = ((metadata_result.get("errors") or [])
-            + (hosts_result.get("errors") or [])
-            + (aliases_result.get("errors") or [])
-    )
+    if remove_backup_files:
+        p = Path(extract_dir)
+        if p.is_dir() and not any(p.iterdir()):
+            p.rmdir()
 
-    # Compute summary
-    operations = [metadata_result, hosts_result, aliases_result]
-    summary = {
-        "total": len(operations),
-        "success": sum(1 for op in operations if op.get("status") == "success"),
-        "failed": sum(1 for op in operations if op.get("status") == "failure"),
-    }
+    return build_result(operations)
 
-    result = {
-        "metadata": metadata_result,
-        "hosts": hosts_result,
-        "aliases": aliases_result,
-        "summary": summary,
-    }
+# ---------------------------------------------------------
+# Delete Backup Archive
+# ---------------------------------------------------------
+def backup_delete(backup_id: str) -> Dict[str, Any]:
 
-    return result
+    # Initialization
+    start_ns = time.monotonic_ns()
+    errors: List[str] = []
+
+    backup_dir = Path(settings.BACKUP_PATH)
+    backup_file = backup_dir / backup_id
+
+    try:
+        # Check if file exists
+        if not backup_file.is_file():
+            raise FileNotFoundError(f"Backup file not found: {backup_id}")
+
+        # Remove file
+        backup_file.unlink()
+
+    except Exception as e:
+        logger.exception("delete_backup failed: %s", str(e).strip())
+        errors.append(str(e))
+
+    took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+
+    return {
+        "status": "failure" if errors else "success",
+        "file": str(backup_file) if not errors else None,
+        "errors": errors,
+        "took_ms": took_ms,
+    }
index 768bce1fcb9973f123141c78ce4958390b9ac072..f184200a4bb328f112bceac87cb5e44633f7a61c 100644 (file)
@@ -10,7 +10,7 @@ import backend.db.config
 import backend.db.users
 import backend.db.hosts
 import backend.db.aliases
-from backend.backup import restore
+from backend.backup import backup_restore
 
 # Import Settings & Logging
 from backend.settings.settings import settings
@@ -93,7 +93,7 @@ def create_db(logger):
     init_db()
 
     # Restore from backup if requested
-    result = restore(cleanup=False)
+    result = backup_restore(cleanup=False)
     errors = result.get("errors") or []
     if errors:
         logger.warning("Failed restoring database from backup")
index a2fe26819080cfdb1128b2841f6ca806d3cdc082..9d457e83fda727cf6faaa371f06bf15df3abfb45 100644 (file)
@@ -1,17 +1,16 @@
 # backend/routes/backup.py
 
 # import standard modules
-from fastapi import APIRouter, Request, Response, HTTPException, status
-from fastapi.responses import FileResponse, JSONResponse
-import asyncio
+from fastapi import APIRouter, HTTPException, status
+from fastapi.responses import Response, JSONResponse
+from pydantic import BaseModel
 import time
-from typing import Iterable, List, Tuple, Dict, Any
+from typing import Dict, Any
 
 # Import local modules
-from backend.backup import backup, restore
+from backend.backup import backup_create, backup_list, backup_restore, backup_delete
 
-# Import Settings & Logging
-from backend.settings.settings import settings
+# Import Logging
 from backend.log.log import get_logger
 
 # Logger initialization
@@ -20,152 +19,208 @@ logger = get_logger(__name__)
 # Create Router
 router = APIRouter()
 
+class BackupRestoreRequest(BaseModel):
+    backup_id: str
+
+class BackupDeleteRequest(BaseModel):
+    backup_id: str
+
+# ---------------------------------------------------------
+# Internal: Convert _OK to _PARTIAL
+# ---------------------------------------------------------
+def to_partial_code(code_ok: str) -> str:
+    if code_ok.endswith("_OK"):
+        return code_ok[:-3] + "_PARTIAL"
+    return f"{code_ok}_PARTIAL"
+
+# ---------------------------------------------------------
+# Internal: Prepare answer
+# ---------------------------------------------------------
+def build_operation_response(
+    *,
+    code_ok: str,
+    code_error: str,
+    message_ok: str,
+    message_partial: str,
+    message_error: str,
+    result: dict,
+    start_ns: int
+) -> Dict[str, Any] | Response:
+    summary = result.get("summary") or {}
+    total = summary.get("total", 0)
+    success = summary.get("success", 0)
+    failed = summary.get("failed", 0)
+
+    took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+
+    is_partial = failed > 0 or success != total
+    if is_partial:
+        if success > 0:
+            return JSONResponse(
+                status_code=status.HTTP_207_MULTI_STATUS,
+                content={
+                    "code": to_partial_code(code_ok),
+                    "status": "partial",
+                    "message": message_partial,
+                    "took_ms": took_ms,
+                    "results": result,
+                },
+            )
+        else:
+            raise HTTPException(
+                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+                detail={
+                    "code": code_error,
+                    "status": "failure",
+                    "message": message_error,
+                    "took_ms": took_ms,
+                    "results": result,
+                },
+            )
+
+    return {
+        "code": code_ok,
+        "status": "success",
+        "message": message_ok,
+        "took_ms": took_ms,
+        "results": result,
+    }
+
 # ---------------------------------------------------------
 # API ENDPOINTS
 # ---------------------------------------------------------
-@router.post(
-    "/api/backup",
-    status_code=status.HTTP_200_OK,
-    responses={
-        200: {"description": "Backup executed with success or failure result"},
-        207: {"description": "Backup executed with partial success"},
-        500: {"description": "Internal server error"},
-    },
-)
-async def api_backup(request: Request):
+@router.post("/api/backup/create")
+async def api_backup_create():
 
     # Initialization
     start_ns = time.monotonic_ns()
 
     try:
-        # Backup DB
-        result = backup()
-        total = (result.get("summary") or []).get("total", 0)
-        success = (result.get("summary") or []).get("success", 0)
-        failed = (result.get("summary") or []).get("failed", 0)
-        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+        # Backup Creation
+        result = backup_create()
+
+        return build_operation_response(
+            code_ok="BACKUP_CREATE_OK",
+            code_error="BACKUP_CREATE_ERROR",
+            message_ok="Backup executed successfully",
+            message_partial="Backup completed with some failed operations",
+            message_error="Some operations failed",
+            result=result,
+            start_ns=start_ns,
+        )
 
-        if failed > 0 or success != total:
-            if success > 0:
-                status_code=status.HTTP_207_MULTI_STATUS
-                return JSONResponse(
-                    status_code=status.HTTP_207_MULTI_STATUS,
-                    content={
-                        "code": "BACKUP_PARTIAL",
-                        "status": "partial",
-                        "message": "Backup completed with some failed operations",
-                        "took_ms": took_ms,
-                        "results": result,
-                    },
-                )
-            else:
-                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
-                raise HTTPException(
-                    status_code=status_code,
-                    detail={
-                        "code": "BACKUP_ERROR",
-                        "status": "failure",
-                        "message": "Some operations failed",
-                        "took_ms": took_ms,
-                        "results": result,
-                    },
-                )
+    except HTTPException:
+        raise
 
-        else:
-            return {
-                "code": "BACKUP_OK",
-                "status": "success",
-                "message": "Backup executed successfully",
-                "took_ms": took_ms,
-                "results": result,
-            }
+    except Exception as err:
+        logger.exception("Error executing backup: %s", str(err))
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail={"code": "BACKUP_CREATE_ERROR", "message": "Internal error"},
+        )
+
+# ---------------------------------------------------------
+# API: List available backups
+# ---------------------------------------------------------
+@router.get("/api/backup/list")
+async def api_backup_list():
+
+    # Initialization
+    start_ns = time.monotonic_ns()
+
+    try:
+        backups = backup_list()
+        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+
+        return {
+            "status": "success",
+            "took_ms": took_ms,
+            "backups": backups
+        }
 
     except HTTPException:
         raise
 
     except Exception as err:
-        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
-        logger.exception("Error executing backup: %s", str(err).strip())
+        logger.exception("Error listing backups: %s", str(err))
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail={
-                "code": "BACKUP_ERROR",
-                "status": "failure",
-                "message": "Internal error executing backup",
-                "took_ms": took_ms,
-            },
+            detail={"code": "BACKUP_LIST_ERROR", "message": "Internal error"},
         )
 
 # ---------------------------------------------------------
 # API: Restore from backup
 # ---------------------------------------------------------
-@router.post(
-    "/api/restore",
-    status_code=status.HTTP_200_OK,
-    responses={
-        200: {"description": "Restore executed with success or failure result"},
-        207: {"description": "Restore executed with partial success"},
-        500: {"description": "Internal server error"},
-    }
-)
-async def api_restore(request: Request):
+@router.post("/api/backup/restore")
+async def api_backup_restore(payload: BackupRestoreRequest):
+
+    # Initialization
     start_ns = time.monotonic_ns()
 
     try:
-        # Restore DB
-        result = restore()
-        total = (result.get("summary") or []).get("total", 0)
-        success = (result.get("summary") or []).get("success", 0)
-        failed = (result.get("summary") or []).get("failed", 0)
-        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+        # Backup Restore
+        result = backup_restore(backup_id=payload.backup_id)
+
+        return build_operation_response(
+            code_ok="BACKUP_RESTORE_OK",
+            code_error="BACKUP_RESTORE_ERROR",
+            message_ok="Restore executed successfully",
+            message_partial="Restore completed with some failed operations",
+            message_error="Some operations failed",
+            result=result,
+            start_ns=start_ns,
+        )
 
-        if failed > 0 or success != total:
-            if success > 0:
-                status_code=status.HTTP_207_MULTI_STATUS
-                return JSONResponse(
-                    status_code=status.HTTP_207_MULTI_STATUS,
-                    content={
-                        "code": "RESTORE_PARTIAL",
-                        "status": "partial",
-                        "message": "Restore completed with some failed operations",
-                        "took_ms": took_ms,
-                        "results": result,
-                    },
-                )
-            else:
-                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
-                raise HTTPException(
-                    status_code=status_code,
-                    detail={
-                        "code": "RESTORE_ERROR",
-                        "status": "failure",
-                        "message": "Some operations failed",
-                        "took_ms": took_ms,
-                        "results": result,
-                    },
-                )
+    except HTTPException:
+        raise
 
-        else:
-            return {
-                "code": "RESTORE_OK",
-                "status": "success",
-                "message": "Restore executed successfully",
-                "took_ms": took_ms,
-                "results": result,
+    except Exception as err:
+        logger.exception("Error executing restore: %s", str(err))
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail={"code": "BACKUP_RESTORE_ERROR", "message": "Internal error"},
+        )
+
+# ---------------------------------------------------------
+# API: Delete a backup
+# ---------------------------------------------------------
+@router.post("/api/backup/delete")
+async def api_backup_delete(payload: BackupDeleteRequest):
+
+    # Initialization
+    start_ns = time.monotonic_ns()
+
+    try:
+        # Delete Backup
+        result = backup_delete(backup_id=payload.backup_id)
+        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
+
+        if result.get("status") != "success":
+            raise HTTPException(
+                status_code=status.HTTP_404_NOT_FOUND,
+                detail={
+                    "code": "BACKUP_NOT_FOUND",
+                    "status": "failure",
+                    "message": "Backup not found",
+                    "took_ms": took_ms,
+                    "results": result,
+                },
+            )
+
+        return {
+            "code": "BACKUP_DELETED",
+            "status": "success",
+            "message": "Backup deleted successfully",
+            "took_ms": took_ms,
+            "results": result,
         }
 
     except HTTPException:
         raise
 
     except Exception as err:
-        took_ms = (time.monotonic_ns() - start_ns) / 1_000_000
-        logger.exception("Error executing restore: %s", str(err).strip())
+        logger.exception("Error deleting backup: %s", str(err))
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail={
-                "code": "RESTORE_ERROR",
-                "status": "failure",
-                "message": "Internal error executing restore",
-                "took_ms": took_ms,
-            },
+            detail={"code": "BACKUP_DELETE_ERROR", "message": "Internal error"},
         )
index d2a4c1bd90aa3b42a4fed029549fb93c1346dede..8c11c6425dcf7ca4d75bcfa25fb23604e5233b7f 100644 (file)
@@ -367,6 +367,28 @@ select.form-select:focus,
     content: "â–Ľ";
 }
 
+/* ================================
+   Backup Table
+================================ */
+
+.table tbody#backupList tr {
+    cursor: pointer;
+    transition: background-color 0.15s ease;
+}
+
+.table tbody#backupList tr:hover > td {
+    background-color: var(--bg-hover);
+}
+
+.table tbody#backupList tr.table-active > td {
+    background-color: var(--bg-selected);
+    border-color: var(--border-selected);
+}
+
+.table tbody#backupList tr.table-active:hover > td {
+    background-color: rgba(var(--accent-rgb), 0.25);
+}
+
 /* ================================
    Actions column
    ================================ */
index 13b38404d5691ab4d4971c67490b50830366b17c..3d1544f7cc77c88c00914ebeda72757d9c9c3091 100644 (file)
@@ -87,7 +87,7 @@
                 <p>Zone, record e configurazioni.</p>
                 <div class="mt-2 d-flex gap-2 flex-wrap">
                     <button class="btn btn-primary btn-sm" title="Reload DNS (BIND)" aria-label="Reload DNS" data-action="reloadDns">
-                        <i class="bi bi-arrow-repeat"></i><span class="label"> Reload DNS</span>
+                        <i class="bi bi-arrow-repeat me-1"></i><span class="label">Reload DNS</span>
                     </button>
                 </div>
             </div>
                 <p>Pools, leases, reservations.</p>
                 <div class="mt-2 d-flex gap-2 flex-wrap">
                     <a href="/leases" class="btn btn-primary btn-sm" title="DHCP Leases (Kea)" aria-label="DHCP Leases">
-                        <i class="bi bi-list-ul"></i>
-                        <span class="label"> DHCP Leases</span>
+                        <i class="bi bi-list-ul me-1"></i><span class="label">DHCP Leases</span>
                     </a>
                     <button class="btn btn-primary btn-sm" title="Reload DHCP (Kea)" aria-label="Reload DHCP" data-action="reloadDhcp">
-                        <i class="bi bi-arrow-repeat"></i><span class="label"> Reload DHCP</span>
+                        <i class="bi bi-arrow-repeat me-1"></i><span class="label">Reload DHCP</span>
                     </button>
                 </div>
             </div>
 
                 <div class="mt-2 d-flex gap-2 flex-wrap">
                     <button class="btn btn-primary btn-sm" title="Start Backup" aria-label="Start Backup" data-action="startBackup">
-                        <i class="bi bi-cloud-upload"></i><span class="label"> Start Backup</span>
+                        <i class="bi bi-cloud-upload me-1"></i><span class="label">Create Backup</span>
                     </button>
-                    <button class="btn btn-primary btn-sm" title="Open Restore" aria-label="Open Restore" data-action="openRestoreModal">
-                        <i class="bi bi-cloud-download"></i><span class="label"> Open Restore</span>
+                    <button class="btn btn-primary btn-sm" title="Open Restore" aria-label="Open Restore" data-action="openBackupModal">
+                        <i class="bi bi-cloud-download me-1"></i><span class="label">Backup Management</span>
                     </button>
                 </div>
             </div>
index 2f1b7626c46d1d72f3cafbb3d58aeedf186b53c3..d4d59f4e980d90aa6f7b9ad1d3f3b7d217a30cf0 100644 (file)
 // IMPORT
 // -------------------------------------------------------
 import { loadModals, showToast } from './common.js';
-import { apiCheck, reloadDNS, reloadDHCP, doBackup, doRestore, checkHealth } from './services.js';
+import { apiCheck, reloadDNS, reloadDHCP, CreateBackup, fetchBackups, RestoreBackup, deleteBackup, checkHealth } from './services.js';
 
 // -------------------------------------------------------
-// RESTORE MODAL OPEN/CLOSE
+// BACKUP MODAL OPEN/CLOSE
 // -------------------------------------------------------
-function openRestoreModal() {
-    const modal = document.getElementById('restoreModal');
-    const input = document.getElementById('restoreBackupId');
-    if (!modal) return;
+async function openBackupModal() {
 
+    const modal = document.getElementById('backupModal');
+    if (!modal) return;
     modal.style.display = 'flex';
-    if (input) {
-        input.value = input.value?.trim() ?? '';
-        setTimeout(() => input.focus(), 50);
+
+    const tbody = document.getElementById("backupList");
+    if (tbody) {
+        tbody.innerHTML = `
+            <tr>
+                <td colspan="5" class="text-center text-muted">
+                    Loading backups...
+                </td>
+            </tr>
+        `;
+    }
+
+    try {
+        const data = await fetchBackups();
+        renderBackupList(data);
+    } catch (err) {
+        console.error(err);
+        showToast("Error loading backups", false);
     }
 }
 
-function closeRestoreModal() {
-    const modal = document.getElementById('restoreModal');
+function closeBackupModal() {
+    const modal = document.getElementById('backupModal');
     if (modal) modal.style.display = 'none';
 }
 
+// -------------------------------------------------------
+// Manage Backup List Rendering (usa fetchData() con apiMap.backups)
+// -------------------------------------------------------
+function renderBackupList(data) {
+    const tbody = document.getElementById("backupList");
+    tbody.innerHTML = "";
+
+    if (!data?.backups || !Array.isArray(data.backups) || data.backups.length === 0) {
+        tbody.innerHTML = `
+            <tr>
+                <td colspan="5" class="text-center text-muted">
+                    No backups available
+                </td>
+            </tr>
+        `;
+        return;
+    }
+
+    data.backups.sort((a, b) =>
+        new Date(b.created_at || 0) - new Date(a.created_at || 0)
+    );
+
+    data.backups.forEach((b, index) => {
+        const tr = document.createElement("tr");
+        const formattedDate = new Date(b.created_at).toLocaleString();
+        const formattedSize = (b.size_bytes / 1024).toFixed(2) + " KB";
+
+        // radio
+        const tdRadio = document.createElement("td");
+        const radio = document.createElement("input");
+        radio.type = "radio";
+        radio.name = "backupSelect";
+        radio.value = b.name;
+        radio.addEventListener("click", (e) => {
+            e.stopPropagation();
+            document.querySelectorAll("#backupList tr")
+                .forEach(r => r.classList.remove("table-active"));
+
+            tr.classList.add("table-active");
+        });
+        tdRadio.appendChild(radio);
+
+        // name
+        const tdName = document.createElement("td");
+        tdName.textContent = b.name;
+
+        // date
+        const tdDate = document.createElement("td");
+        tdDate.textContent = formattedDate;
+
+        // size
+        const tdSize = document.createElement("td");
+        tdSize.textContent = formattedSize;
+        tdSize.classList.add("text-end");
+
+        // actions (DELETE)
+        const tdActions = document.createElement("td");
+        tdActions.classList.add("text-end");
+        const deleteBtn = document.createElement("button");
+        deleteBtn.className = "btn btn-sm btn-outline-danger";
+        deleteBtn.title = "Delete backup";
+        deleteBtn.innerHTML = `<i class="bi bi-trash-fill text-danger"></i>`;
+        deleteBtn.setAttribute("data-action", "deleteBackup");
+        deleteBtn.setAttribute("data-id", b.name);
+        tdActions.appendChild(deleteBtn);
+
+        // Append all columns
+        tr.append(tdRadio, tdName, tdDate, tdSize, tdActions);
+
+        if (index === 0) {
+            radio.checked = true;
+            tr.classList.add("table-active");
+        }
+
+        // click su tutta la riga = selezione radio
+        tr.addEventListener("click", () => {
+            radio.checked = true;
+
+            // highlight selected row
+            document.querySelectorAll("#backupList tr")
+                .forEach(r => r.classList.remove("table-active"));
+
+            tr.classList.add("table-active");
+        });
+
+        tbody.appendChild(tr);
+    });
+}
+
+function getSelectedBackup() {
+    const selected = document.querySelector('input[name="backupSelect"]:checked');
+    return selected ? selected.value : null;
+}
+
 // -------------------------------------------------------
 // HEALTH MODAL OPEN/CLOSE + RENDER (usa checkHealth())
 // -------------------------------------------------------
@@ -145,9 +253,10 @@ function renderHealth(data) {
 // Action Handlers
 // -------------------------------------------------------
 const actionHandlers = {
-    // Backup
+    // Create Backup
     startBackup: async (e, el) => {
         const btn = el;
+        const modal = document.getElementById('backupModal');
 
         if (!btn) return;
 
@@ -158,17 +267,13 @@ const actionHandlers = {
         label.textContent = ' Exporting…';
 
         try {
-            const result = await doBackup();
+            const result = await CreateBackup();
             const msg = (typeof result === 'object' && result?.message)
                         ? result.message
-                        : 'Backup compleated successfully';
-            if (result?.partial) {
-                // partial success
-                showToast(msg, false);
-            } else {
-                // success
-                showToast(msg, true);
-            }
+                        : 'Backup completed successfully';
+            showToast(msg, !result?.partial);
+            // Close modal
+            if (modal) modal.style.display = 'none';
         } catch (err) {
             showToast(err?.message || "Error performing backup", false);
         } finally {
@@ -176,18 +281,14 @@ const actionHandlers = {
             btn.disabled = false;
         }
     },
-    // Restore
+    // Restore Backup
     startRestore: async (e, el) => {
         const btn = el;
-        const input = document.getElementById('restoreBackupId');
-        const modal = document.getElementById('restoreModal');
+        const modal = document.getElementById('backupModal');
 
-        if (!btn || !input) return;
-
-        const id = input.value.trim();
+        const id = getSelectedBackup();
         if (!id) {
-            showToast('Specify a Backup ID', false);
-            input.focus();
+            showToast('Select a backup', false);
             return;
         }
 
@@ -198,20 +299,13 @@ const actionHandlers = {
         label.textContent = ' Restoring…';
 
         try {
-            const result = await doRestore();
+            const result = await RestoreBackup(id);
             const msg = (typeof result === 'object' && result?.message)
                         ? result.message
                         : 'Restore completed successfully';
-            if (result?.partial) {
-                // partial success
-                showToast(msg, false);
-            } else {
-                // success
-                showToast(msg, true);
-            }
-            // Close modal and reset input
+            showToast(msg, !result?.partial);
+            // Close modal
             if (modal) modal.style.display = 'none';
-            input.value = '';
         } catch (err) {
             showToast(err?.message || "Error performing restore", false);
         } finally {
@@ -219,8 +313,38 @@ const actionHandlers = {
             btn.disabled = false;
         }
     },
-    openRestoreModal,       // managed by boostrap
-    closeRestoreModal,      // managed by boostrap
+    // Delete Backup
+    deleteBackup: async (e, el) => {
+
+        e.stopPropagation();
+
+        const id = el.dataset.id;
+        if (!id) return;
+
+        const confirmDelete = confirm(`Delete backup "${id}" ?`);
+        if (!confirmDelete) return;
+
+        try {
+            const result = await deleteBackup(id);
+
+            const msg = (typeof result === 'object' && result?.message)
+                ? result.message
+                : 'Backup deleted successfully';
+
+            showToast(msg, true);
+
+            // reload list
+            const data = await fetchBackups();
+            renderBackupList(data);
+
+        } catch (err) {
+            console.error(err);
+            showToast(err?.message || "Error deleting backup", false);
+        }
+    },
+
+    openBackupModal,       // managed by boostrap
+    closeBackupModal,      // managed by boostrap
     // Reload DNS
     reloadDns: async () => {
         try {
@@ -277,8 +401,8 @@ document.addEventListener("DOMContentLoaded", async () => {
         showToast(err?.message || "Error loading modals", false);
     }
 
-    // Init Restore Backup Modal (backdrop click to close)
-    initRestoreBackupModal();
+    // Init Backup Modal (backdrop click to close)
+    initBackupModal();
 });
 
 // -------------------------------------------------------
@@ -305,20 +429,20 @@ document.addEventListener('click', async (e) => {
 // -------------------------------------------------------
 document.addEventListener('keydown', (e) => {
     if (e.key === 'Escape') {
-        closeRestoreModal();
+        closeBackupModal();
         closeHealthModal();
     }
 });
 
 // -------------------------------------------------------
-// Init Restore Backup Modal (backdrop click to close)
+// Init Backup Modal (backdrop click to close)
 // -------------------------------------------------------
-function initRestoreBackupModal() {
-    const restoreModal = document.getElementById('restoreModal');
-    if (!restoreModal) return;
+function initBackupModal() {
+    const backupModal = document.getElementById('backupModal');
+    if (!backupModal) return;
 
-    restoreModal.addEventListener('click', (e) => {
-        if (e.target === restoreModal) closeRestoreModal();
+    backupModal.addEventListener('click', (e) => {
+        if (e.target === backupModal) closeBackupModal();
     });
 }
 
index a9aa81c5e007ff241c48ab0b6b5cc72be148691b..894c5bb2f967bbb27c210a889db6707e0fae6281 100644 (file)
@@ -141,14 +141,14 @@ export async function reloadDHCP() {
 }
 
 // -------------------------------------------------------
-// Execute Backup
+// Create a Backup
 // -------------------------------------------------------
-export async function doBackup() {
+export async function CreateBackup() {
     let res;
 
     try {
         // Fetch data
-        res = await fetch(`/api/backup`, {
+        res = await fetch(`/api/backup/create`, {
             method: 'POST',
             headers: { 'Accept': 'application/json' },
         });
@@ -192,7 +192,7 @@ export async function doBackup() {
         throw err;
     }
 
-    if (res.ok && (data.status === 'success' || data.code === 'BACKUP_OK')) {
+    if (res.ok && (data.status === 'success' || data.code === 'BACKUP_CREATE_OK')) {
         // Success
         return data?.message ? { message: data.message } : true;
     } else if (res.ok && (data.status === 'partial' || data.code === 'BACKUP_PARTIAL')) {
@@ -207,16 +207,74 @@ export async function doBackup() {
 }
 
 // -------------------------------------------------------
-// Execute Restore
+// Fetch Backups list
 // -------------------------------------------------------
-export async function doRestore(id) {
+export async function fetchBackups() {
     let res;
 
     try {
         // Fetch data
-        res = await fetch(`/api/restore`, {
-            method: 'POST',
+        res = await fetch(`/api/backup/list`, {
+            method: 'GET',
             headers: { 'Accept': 'application/json' },
+        });
+
+    } catch (err) {
+        const msg = 'Network error while fetching backups' + (err?.message ? `: ${err.message}` : '');
+        throw new Error(msg, { cause: err });
+    }
+
+    // Success without JSON
+    if (res.status === 204) {
+        return [];
+    }
+
+    // Check content-type to avoid parsing errors
+    const contentType = res.headers.get("content-type") || "";
+    if (!contentType.includes("application/json")) {
+        const err = new Error(`Error fetching backups: ${res.status}: ${res.statusText || 'Unexpected response'}`);
+        err.status = res.status;
+        throw err;
+    }
+
+    // Check JSON
+    let data = {};
+    try {
+        data = await res.json();
+    } catch {
+        throw new Error('Error fetching backups: Invalid JSON payload');
+    }
+
+    // Check JSON errors
+    if (!res.ok) {
+        const serverMsg =
+            data?.detail?.message?.trim()
+            || (typeof data?.detail === 'string' ? data.detail.trim() : '')
+            || data?.message?.trim()
+            || data?.error?.message?.trim()
+            || (typeof data?.error === 'string' ? data.error.trim() : '');
+        const err = new Error('Error fetching backups' + (serverMsg ? `: ${serverMsg}` : ''));
+        err.status = res.status;
+        throw err;
+    }
+
+    return (data ?? []);
+}
+
+// -------------------------------------------------------
+// Restore a Backup
+// -------------------------------------------------------
+export async function RestoreBackup(id) {
+    let res;
+
+    try {
+        // Fetch data
+        res = await fetch(`/api/backup/restore`, {
+            method: 'POST',
+            headers: {
+                'Accept': 'application/json',
+                'Content-Type': 'application/json',
+            },
             body: JSON.stringify({ backup_id: id })
         });
 
@@ -259,10 +317,10 @@ export async function doRestore(id) {
         throw err;
     }
 
-    if (res.ok && (data.status === 'success' || data.code === 'RESTORE_OK')) {
+    if (res.ok && (data.status === 'success' || data.code === 'BACKUP_RESTORE_OK')) {
         // Success
         return data?.message ? { message: data.message } : true;
-    } else if (res.ok && (data.status === 'partial' || data.code === 'RESTORE_PARTIAL')) {
+    } else if (res.ok && (data.status === 'partial' || data.code === 'BACKUP_RESTORE_PARTIAL')) {
         // Partial success
         return data?.message
             ? { message: data.message, partial: true }
@@ -273,6 +331,71 @@ export async function doRestore(id) {
     }
 }
 
+// -------------------------------------------------------
+// Delete a Backup
+// -------------------------------------------------------
+export async function deleteBackup(id) {
+    let res;
+
+    try {
+        // Fetch data
+        res = await fetch(`/api/backup/delete`, {
+            method: 'POST',
+            headers: {
+                'Accept': 'application/json',
+                'Content-Type': 'application/json',
+            },
+            body: JSON.stringify({ backup_id: id })
+        });
+
+    } catch (err) {
+        const msg = 'Network error while performing delete' + (err?.message ? `: ${err.message}` : '');
+        throw new Error(msg, { cause: err });
+    }
+
+    // Success without JSON
+    if (res.status === 204) {
+        return true;
+    }
+
+    // Check content-type to avoid parsing errors
+    const contentType = res.headers.get("content-type") || "";
+    if (!contentType.includes("application/json")) {
+        const err = new Error(`Error performing delete: ${res.status}: ${res.statusText || 'Unexpected response'}`);
+        err.status = res.status;
+        throw err;
+    }
+
+    // Check JSON
+    let data = {};
+    try {
+        data = await res.json();
+    } catch {
+        throw new Error('Error performing delete: Invalid JSON payload');
+    }
+
+    // Check JSON errors
+    if (!res.ok) {
+        const serverMsg =
+            data?.detail?.message?.trim()
+            || (typeof data?.detail === 'string' ? data.detail.trim() : '')
+            || data?.message?.trim()
+            || data?.error?.message?.trim()
+            || (typeof data?.error === 'string' ? data.error.trim() : '');
+        const err = new Error('Error performing delete' + (serverMsg ? `: ${serverMsg}` : ''));
+        err.status = res.status;
+        throw err;
+    }
+
+    if (res.ok && (data.status === 'success' || data.code === 'BACKUP_DELETEE_OK')) {
+        // Success
+        return data?.message ? { message: data.message } : true;
+    } else {
+        // Failed with JSON error message
+        return data?.message ? { message: data.message } : false;
+    }
+}
+
 // -------------------------------------------------------
 // Check Health
 // -------------------------------------------------------
index 42c65c19648156ff60e41a75900e0ac04c2e755f..ccd3b16edf0d85b2e5a3a905cb77f2e769abb7ec 100644 (file)
 </div>
 
 <!-- Modal Restore backup -->
-<div id="restoreModal" class="modal" role="dialog" aria-modal="true" aria-labelledby="restoreTitle">
+<div id="backupModal" class="modal" role="dialog" aria-modal="true" aria-labelledby="backupTitle">
     <div class="modal-content modal-content-xl addhost-modal">
         <div class="addhost-header d-flex justify-content-between align-items-center">
             <div class="d-flex align-items-center gap-2">
                 <span class="title-icon"><i class="bi bi-arrow-counterclockwise"></i></span>
-                <span id="restoreTitle" class="modal-title">Restore Backup</span>
+                <span id="backupTitle" class="modal-title">Restore Backup</span>
             </div>
-            <button type="button" class="btn-close" title="Close" aria-label="Close" data-action="closeRestoreModal"></button>
+            <button type="button" class="btn-close" title="Close" aria-label="Close" data-action="closeBackupModal"></button>
         </div>
 
         <div class="modal-body">
-            <label for="restoreBackupId" class="form-label">Backup ID</label>
-            <input type="text" id="restoreBackupId" class="form-control" placeholder="e.g., bk_20260317_200100" />
-            <small class="text-muted">Select the backup ID to restore.</small>
+            <label class="form-label">Available backups</label>
+
+            <div class="table-responsive" style="max-height: 250px; overflow-y: auto;">
+                <table class="table table-hover table-sm align-middle">
+                    <thead class="table-light sticky-top">
+                        <tr>
+                            <th style="width: 40px;"></th>
+                            <th>Name</th>
+                            <th>Date</th>
+                            <th class="text-end">Size</th>
+                            <th style="width: 60px;" class="text-end">Actions</th>
+                        </tr>
+                    </thead>
+                    <tbody id="backupList">
+                        <!-- populated via JS -->
+                    </tbody>
+                </table>
+            </div>
+
+            <!-- Hidden input opzionale se vuoi mantenere compatibilitĂ  -->
+            <input type="hidden" id="restoreBackupId" />
+
+            <small class="text-muted">Select one backup to restore.</small>
         </div>
 
         <div class="modal-footer modal-buttons">
-            <button type="button" class="btn btn-primary" data-action="startRestore"><span class="label">Start Restore</span></button>
-            <button type="button" class="btn btn-outline-primary" data-action="closeRestoreModal"><span class="label">Cancel</span></button>
+            <button type="button" class="btn btn-primary" data-action="startBackup">
+                <i class="bi bi-cloud-upload me-1"></i><span class="label">Create Backup</span>
+            </button>
+            <button type="button" class="btn btn-primary" data-action="startRestore">
+                <i class="bi bi-cloud-download me-1"></i><span class="label">Restore Restore</span>
+            </button>
+            <button type="button" class="btn btn-outline-primary" data-action="closeBackupModal">
+                <i class="bi bi-x-circle me-1"></i><span class="label">Cancel</span>
+            </button>
         </div>
     </div>
 </div>