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
# 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,
# 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""):
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())
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,
}
if errors:
result: Dict[str, Any] = {
"status": "failure",
- "file": path,
+ "file": str(file),
"errors": errors,
"took_ms": took_ms,
}
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,
# ---------------------------------------------------------
# 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)
if errors:
result: Dict[str, Any] = {
"status": "failure",
- "file": path,
+ "file": str(file),
"errors": errors,
"took_ms": took_ms,
}
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
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,
}
if errors:
result: Dict[str, Any] = {
"status": "failure",
- "file": path,
+ "file": str(file),
"errors": errors,
"took_ms": took_ms,
}
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,
# ---------------------------------------------------------
# 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)
if errors:
result: Dict[str, Any] = {
"status": "failure",
- "file": path,
+ "file": str(file),
"errors": errors,
"took_ms": took_ms,
}
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,
{
"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),
},
]
}
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,
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,
# ---------------------------------------------------------
# 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
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"),
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,
+ }
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
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")
# 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
# 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"},
)
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
================================ */
<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>
// 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())
// -------------------------------------------------------
// Action Handlers
// -------------------------------------------------------
const actionHandlers = {
- // Backup
+ // Create Backup
startBackup: async (e, el) => {
const btn = el;
+ const modal = document.getElementById('backupModal');
if (!btn) return;
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 {
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;
}
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 {
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 {
showToast(err?.message || "Error loading modals", false);
}
- // Init Restore Backup Modal (backdrop click to close)
- initRestoreBackupModal();
+ // Init Backup Modal (backdrop click to close)
+ initBackupModal();
});
// -------------------------------------------------------
// -------------------------------------------------------
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();
});
}
}
// -------------------------------------------------------
-// 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' },
});
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')) {
}
// -------------------------------------------------------
-// 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 })
});
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 }
}
}
+// -------------------------------------------------------
+// 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
// -------------------------------------------------------
</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>