From: Giorgio Ravera Date: Thu, 28 May 2026 14:33:50 +0000 (+0200) Subject: Progress with backup X-Git-Url: http://git.giorgioravera.it/?a=commitdiff_plain;h=068c98d6d81a05a1cc335679158a60297f3a0ed8;p=network-manager.git Progress with backup --- diff --git a/backend/backup.py b/backend/backup.py index 64ab6b2..4495203 100644 --- a/backend/backup.py +++ b/backend/backup.py @@ -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, + } diff --git a/backend/bootstrap.py b/backend/bootstrap.py index 768bce1..f184200 100644 --- a/backend/bootstrap.py +++ b/backend/bootstrap.py @@ -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") diff --git a/backend/routes/backup.py b/backend/routes/backup.py index a2fe268..9d457e8 100644 --- a/backend/routes/backup.py +++ b/backend/routes/backup.py @@ -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"}, ) diff --git a/frontend/css/layout.css b/frontend/css/layout.css index d2a4c1b..8c11c64 100644 --- a/frontend/css/layout.css +++ b/frontend/css/layout.css @@ -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 ================================ */ diff --git a/frontend/index.html b/frontend/index.html index 13b3840..3d1544f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -87,7 +87,7 @@

Zone, record e configurazioni.

@@ -99,11 +99,10 @@

Pools, leases, reservations.

- - DHCP Leases + DHCP Leases
@@ -144,10 +143,10 @@
-
diff --git a/frontend/js/index.js b/frontend/js/index.js index 2f1b762..d4d59f4 100644 --- a/frontend/js/index.js +++ b/frontend/js/index.js @@ -2,28 +2,136 @@ // 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 = ` + + + Loading backups... + + + `; + } + + 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 = ` + + + No backups available + + + `; + 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 = ``; + 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(); }); } diff --git a/frontend/js/services.js b/frontend/js/services.js index a9aa81c..894c5bb 100644 --- a/frontend/js/services.js +++ b/frontend/js/services.js @@ -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 // ------------------------------------------------------- diff --git a/frontend/modals.html b/frontend/modals.html index 42c65c1..ccd3b16 100644 --- a/frontend/modals.html +++ b/frontend/modals.html @@ -139,25 +139,52 @@ -