From: Giorgio Ravera Date: Wed, 27 May 2026 14:08:52 +0000 (+0200) Subject: improved restore X-Git-Url: http://git.giorgioravera.it/?a=commitdiff_plain;h=507f1d52f4932971e341a2d0396e0d776a581f07;p=network-manager.git improved restore --- diff --git a/README.md b/README.md index 9c6fd28..12e1a47 100644 --- a/README.md +++ b/README.md @@ -151,8 +151,11 @@ secrets: | `DHCP4_LEASES_FILE` | /dhcp/lib/dhcp4.leases | KEA-DHCP4 leases file | | `DHCP6_HOST_FILE` | /dhcp/etc/hosts-ipv6.json | KEA-DHCP6 Hosts file | | `DHCP6_LEASES_FILE` | /dhcp/lib/dhcp6.leases | KEA-DHCP6 leases file | +| `BACKUP_PATH` | backup | Backup folder (*) | | `PING_WORKERS` | 25 | Number of threads used for pinging | +(*) Note: If the path starts with '/', it is treated as an absolute path. Otherwise, it is considered relative to DATA_PATH. + --- ## 🔐 Admin credential management diff --git a/backend/backup.py b/backend/backup.py index 8cfcfec..b6f7bf4 100644 --- a/backend/backup.py +++ b/backend/backup.py @@ -1,10 +1,12 @@ # backend/backup.py # import standard modules +from datetime import datetime, timezone +import hashlib import json import os import time -from typing import Iterable, List, Tuple, Dict, Any +from typing import List, Dict, Any, Optional # Import local modules from backend.db.hosts import get_hosts, add_host, reset_hosts_db @@ -12,47 +14,34 @@ from backend.db.aliases import get_aliases, add_alias, reset_aliases_db # Import Settings & Logging from backend.settings.settings import settings +from backend.settings import config 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") + # --------------------------------------------------------- -# Internal: load NDJSON utility +# Internal: Calculate file checksum # --------------------------------------------------------- -def _load_ndjson(path: str) -> Tuple[List[Dict[str, Any]], List[str]]: - records: List[Dict[str, Any]] = [] - errors: List[str] = [] - - if not os.path.exists(path): - errors.append(f"File not found: {path}") - return records, errors - - with open(path, "r", encoding="utf-8") as f: - for lineno, line in enumerate(f, start=1): - line = line.strip() - if not line: - continue - try: - obj = json.loads(line) - if isinstance(obj, dict): - records.append(obj) - else: - errors.append(f"{os.path.basename(path)}:{lineno} -> JSON is not an object") - except json.JSONDecodeError as e: - errors.append(f"{os.path.basename(path)}:{lineno} -> JSON decode error: {str(e)}") - - return records, errors +def file_checksum(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return h.hexdigest() # --------------------------------------------------------- # Save Hosts DB # --------------------------------------------------------- -def store_hosts() -> Dict[str, Any]: +def store_hosts(timestamp: Optional[str] = None) -> Dict[str, Any]: # Initialization start_ns = time.monotonic_ns() - path = os.path.join(settings.DATA_PATH, "hosts.json") - stored = 0 + path = os.path.join(settings.BACKUP_PATH, config.BACKUP_HOSTS_FILE) + count_stored = 0 count_loaded = 0 errors: List[str] = [] @@ -62,9 +51,12 @@ def store_hosts() -> Dict[str, Any]: count_loaded = len(hosts) with open(path, "w", encoding="utf-8") as f: - for h in hosts: - f.write(json.dumps(h, ensure_ascii=False) + "\n") - stored += 1 + data = { + "generated_at": timestamp or TIMESTAMP, + "count": count_loaded, + "hosts": hosts, + } + json.dump(data, f, ensure_ascii=False, indent=2) except Exception as e: logger.exception("store_hosts failed saving records: %s", str(e).strip()) @@ -72,58 +64,84 @@ def store_hosts() -> Dict[str, Any]: took_ms = (time.monotonic_ns() - start_ns) / 1_000_000 - result: Dict[str, Any] = { - "file": path, - "count_loaded": count_loaded, - "count_stored": stored, - "took_ms": took_ms, - } - if errors: - result["errors"] = errors + result: Dict[str, Any] = { + "status": "failure", + "file": path, + "errors": errors, + "took_ms": took_ms, + } + else: + count_stored = count_loaded + result: Dict[str, Any] = { + "status": "success", + "file": path, + "count_loaded": count_loaded, + "count_stored": count_stored, + "took_ms": took_ms, + } return result # --------------------------------------------------------- # Restore Hosts DB # --------------------------------------------------------- -def restore_hosts() -> Dict[str, Any]: +def restore_hosts(file: Optional[str] = None) -> Dict[str, Any]: # Initialization start_ns = time.monotonic_ns() - path = os.path.join(settings.DATA_PATH, "hosts.json") - restored = 0 - - # load records from NDJSON file - records, errors = _load_ndjson(path) + if file is None: + file = config.BACKUP_HOSTS_FILE + path = os.path.join(settings.BACKUP_PATH, file) + count_restored = 0 + count_loaded = 0 + hosts: List[Dict[str, Any]] = [] + errors: List[str] = [] try: - for r in records: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + hosts = data.get("hosts", []) + count_loaded = data.get("count", 0) + + for r in hosts: add_host(r) - restored += 1 + count_restored += 1 except Exception as e: logger.exception("restore_hosts failed applying records: %s", str(e).strip()) errors.append(str(e)); took_ms = (time.monotonic_ns() - start_ns) / 1_000_000 - return { - "file": path, - "count_loaded": len(records), - "count_restored": restored, - "errors": errors, - "took_ms": took_ms, - } + + if errors: + result: Dict[str, Any] = { + "status": "failure", + "file": path, + "errors": errors, + "took_ms": took_ms, + } + else: + count_stored = count_loaded + result: Dict[str, Any] = { + "status": "success", + "file": path, + "count_loaded": count_loaded, + "count_stored": count_stored, + "took_ms": took_ms, + } + + return result # --------------------------------------------------------- # Save Aliases DB # --------------------------------------------------------- -def store_aliases() -> Dict[str, Any]: +def store_aliases(timestamp: Optional[str] = None) -> Dict[str, Any]: # Initialization start_ns = time.monotonic_ns() - path = os.path.join(settings.DATA_PATH, "hosts.json") - stored = 0 + path = os.path.join(settings.BACKUP_PATH, config.BACKUP_ALIASES_FILE) + count_stored = 0 count_loaded = 0 errors: List[str] = [] @@ -133,11 +151,14 @@ def store_aliases() -> Dict[str, Any]: count_loaded = len(aliases) # Backup Aliases DB - path = os.path.join(settings.DATA_PATH, "aliases.json") + path = os.path.join(settings.BACKUP_PATH, config.BACKUP_ALIASES_FILE) with open(path, "w", encoding="utf-8") as f: - for a in aliases: - f.write(json.dumps(a, ensure_ascii=False) + "\n") - stored += 1 + data = { + "generated_at": timestamp or TIMESTAMP, + "count": count_loaded, + "aliases": aliases, + } + json.dump(data, f, ensure_ascii=False, indent=2) except Exception as e: logger.exception("store_aliases failed saving records: %s", str(e).strip()) @@ -145,65 +166,239 @@ def store_aliases() -> Dict[str, Any]: took_ms = (time.monotonic_ns() - start_ns) / 1_000_000 - result: Dict[str, Any] = { - "file": path, - "count_loaded": count_loaded, - "count_stored": stored, - "took_ms": took_ms, - } - if errors: - result["errors"] = errors + result: Dict[str, Any] = { + "status": "failure", + "file": path, + "errors": errors, + "took_ms": took_ms, + } + else: + count_stored = count_loaded + result: Dict[str, Any] = { + "status": "success", + "file": path, + "count_loaded": count_loaded, + "count_stored": count_stored, + "took_ms": took_ms, + } return result # --------------------------------------------------------- # Restore Aliases DB # --------------------------------------------------------- -def restore_aliases() -> Dict[str, Any]: +def restore_aliases(file: Optional[str] = None) -> Dict[str, Any]: # Initialization start_ns = time.monotonic_ns() - src_path = os.path.join(settings.DATA_PATH, "aliases.json") - restored = 0 - - # load records from NDJSON file - records, errors = _load_ndjson(src_path) + if file is None: + file = config.BACKUP_ALIASES_FILE + path = os.path.join(settings.BACKUP_PATH, file) + count_restored = 0 + count_loaded = 0 + aliases: List[Dict[str, Any]] = [] + errors: List[str] = [] try: - for r in records: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + aliases = data.get("aliases", []) + count_loaded = data.get("count", 0) + + for r in aliases: add_alias(r) - restored += 1 + count_restored += 1 except Exception as e: logger.exception("restore_aliases failed applying records: %s", str(e).strip()) errors.append(str(e)); took_ms = (time.monotonic_ns() - start_ns) / 1_000_000 - return { - "file": src_path, - "count_loaded": len(records), - "count_restored": restored, - "errors": errors, - "took_ms": took_ms, - } + + if errors: + result: Dict[str, Any] = { + "status": "failure", + "file": path, + "errors": errors, + "took_ms": took_ms, + } + else: + count_stored = count_loaded + result: Dict[str, Any] = { + "status": "success", + "file": path, + "count_loaded": count_loaded, + "count_stored": count_stored, + "took_ms": took_ms, + } + + return result + +# --------------------------------------------------------- +# Save Metadata DB +# --------------------------------------------------------- +def store_metadata(timestamp: Optional[str] = None) -> Dict[str, Any]: + + # Initialization + start_ns = time.monotonic_ns() + path = os.path.join(settings.BACKUP_PATH, config.BACKUP_METADATA_FILE) + errors: List[str] = [] + + try: + with open(path, "w", encoding="utf-8") as f: + data = { + "generated_at": timestamp or TIMESTAMP, + "backup_version": config.BACKUP_VERSION, + "db_structure_version": config.BACKUP_DB_STRUCTURE_VERSION, + "file_count": 2, + "files": [ + { + "name": "hosts", + "file": config.BACKUP_HOSTS_FILE, + "sha256": file_checksum(os.path.join(settings.BACKUP_PATH, config.BACKUP_HOSTS_FILE)), + }, + { + "name": "aliases", + "file": config.BACKUP_ALIASES_FILE, + "sha256": file_checksum(os.path.join(settings.BACKUP_PATH, config.BACKUP_ALIASES_FILE)), + }, + ] + } + json.dump(data, f, ensure_ascii=False, indent=2) + + except Exception as e: + logger.exception("store_metadata failed saving records: %s", str(e).strip()) + errors.append(str(e)) + + took_ms = (time.monotonic_ns() - start_ns) / 1_000_000 + + if errors: + result: Dict[str, Any] = { + "status": "failure", + "file": path, + "version": config.BACKUP_VERSION, + "db_structure_version": config.BACKUP_DB_STRUCTURE_VERSION, + "errors": errors, + "took_ms": took_ms, + } + else: + result: Dict[str, Any] = { + "status": "success", + "file": path, + "version": config.BACKUP_VERSION, + "db_structure_version": config.BACKUP_DB_STRUCTURE_VERSION, + "file_count": 2, + "took_ms": took_ms, + } + + return result + +# --------------------------------------------------------- +# Check Metadata +# --------------------------------------------------------- +def check_metadata() -> Dict[str, Any]: + + # Initialization + start_ns = time.monotonic_ns() + path = os.path.join(settings.BACKUP_PATH, config.BACKUP_METADATA_FILE) + + try: + with open(path, "r", encoding="utf-8") as f: + metadata = json.load(f) + + # Validate structure + if "files" not in metadata or not isinstance(metadata["files"], list): + raise ValueError("Invalid metadata: missing or invalid 'files'") + + # Validate versions + if metadata.get("backup_version") != config.BACKUP_VERSION: + raise ValueError("Backup version mismatch") + + if metadata.get("db_structure_version") != config.BACKUP_DB_STRUCTURE_VERSION: + raise ValueError("DB structure not compatible") + + # Validate files + for file_meta in metadata["files"]: + + if not isinstance(file_meta, dict): + raise ValueError("Invalid metadata entry: must be an object") + + if "file" not in file_meta: + raise ValueError("Invalid metadata entry: missing 'file'") + + 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): + raise FileNotFoundError(f"Backup file not found: {file_meta['file']}") + + if file_checksum(file_path) != file_meta["sha256"]: + raise ValueError(f"Checksum mismatch for file: {file_meta['file']}") + + result: Dict[str, Any] = { + "status": "success", + "file": path, + "version": metadata.get("backup_version"), + "db_structure_version": metadata.get("db_structure_version"), + "file_count": metadata.get("file_count"), + "files": metadata.get("files"), + "took_ms": (time.monotonic_ns() - start_ns) / 1_000_000, + } + + except Exception as e: + logger.exception("check_metadata failed reading metadata: %s", str(e).strip()) + result = { + "status": "failure", + "file": path, + "errors": [str(e)], + "took_ms": (time.monotonic_ns() - start_ns) / 1_000_000, + } + + return result # --------------------------------------------------------- # Backup DB # --------------------------------------------------------- def backup() -> Dict[str, Any]: - hosts_result = store_hosts() - aliases_result = store_aliases() - errors = (hosts_result.get("errors") or []) + (aliases_result.get("errors") or []) + # Ensure backup directory exists + os.makedirs(settings.BACKUP_PATH, exist_ok=True) + + # Timestamp used for backup file naming + timestamp = TIMESTAMP + hosts_result = store_hosts(timestamp) + aliases_result = store_aliases(timestamp) + metadata_result = store_metadata(timestamp) + errors = ((metadata_result.get("errors") or []) + + (hosts_result.get("errors") or []) + + (aliases_result.get("errors") or []) + ) + + # Collect errors and results result = { + "metadata": metadata_result, "hosts": hosts_result, "aliases": aliases_result, } - if errors: - result["errors"] = errors + # 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"), + } + + result = { + "metadata": metadata_result, + "hosts": hosts_result, + "aliases": aliases_result, + "summary": summary, + } return result @@ -212,6 +407,20 @@ def backup() -> Dict[str, Any]: # --------------------------------------------------------- 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, + }, + } + if cleanup: try: reset_hosts_db() @@ -221,18 +430,31 @@ def restore(cleanup: bool = True) -> Dict[str, Any]: logger.exception("Cleanup failed %s", str(e).strip()) raise - hosts_result = restore_hosts() - aliases_result = restore_aliases() - errors = (hosts_result.get("errors") or []) + (aliases_result.get("errors") or []) + for f in metadata_result["files"]: + if f["name"] == "hosts": + hosts_result = restore_hosts(f["file"]) + + elif f["name"] == "aliases": + aliases_result = restore_aliases(f["file"]) + + errors = ((metadata_result.get("errors") or []) + + (hosts_result.get("errors") or []) + + (aliases_result.get("errors") or []) + ) + + # 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"), + } result = { - "cleanup": cleanup, + "metadata": metadata_result, "hosts": hosts_result, "aliases": aliases_result, + "summary": summary, } - if errors: - result["errors"] = errors - # GRGR -> reset db in caso di errori - return result diff --git a/backend/bootstrap.py b/backend/bootstrap.py index 379b8e1..768bce1 100644 --- a/backend/bootstrap.py +++ b/backend/bootstrap.py @@ -52,6 +52,10 @@ def print_welcome(logger): "DHCP: ipv4 host file=%s | ipv4 leases file=%s | ipv6 host file=%s | ipv6 leases file=%s", settings.DHCP4_HOST_FILE, settings.DHCP4_LEASES_FILE, settings.DHCP6_HOST_FILE, settings.DHCP6_LEASES_FILE ) + logger.info( + "Backup: path=%s", + settings.BACKUP_PATH + ) logger.info( "App features: ping_workers=%d", settings.PING_WORKERS diff --git a/backend/routes/backup.py b/backend/routes/backup.py index 39bd08b..a2fe268 100644 --- a/backend/routes/backup.py +++ b/backend/routes/backup.py @@ -2,7 +2,7 @@ # import standard modules from fastapi import APIRouter, Request, Response, HTTPException, status -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse import asyncio import time from typing import Iterable, List, Tuple, Dict, Any @@ -28,6 +28,7 @@ router = APIRouter() 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"}, }, ) @@ -39,16 +40,45 @@ async def api_backup(request: Request): try: # Backup DB result = backup() - errors = result.get("errors") or [] + 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 - return { - "code": "BACKUP_OK" if not errors else "BACKUP_ERROR", - "status": "success" if not errors else "failure", - "message": "Backup executed successfully" if not errors else "Some operations failed", - "took_ms": took_ms, - "results": result, - } + 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, + }, + ) + + else: + return { + "code": "BACKUP_OK", + "status": "success", + "message": "Backup executed successfully", + "took_ms": took_ms, + "results": result, + } except HTTPException: raise @@ -74,6 +104,7 @@ async def api_backup(request: Request): 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"}, } ) @@ -83,15 +114,44 @@ async def api_restore(request: Request): try: # Restore DB result = restore() - errors = (result.get("errors") or []) + 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 - return { - "code": "RESTORE_OK" if not errors else "RESTORE_ERROR", - "status": "success" if not errors else "failure", - "message": "Restore executed successfully" if not errors else "Some operations failed", - "took_ms": took_ms, - "results": result, + 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, + }, + ) + + else: + return { + "code": "RESTORE_OK", + "status": "success", + "message": "Restore executed successfully", + "took_ms": took_ms, + "results": result, } except HTTPException: diff --git a/backend/settings/config.py b/backend/settings/config.py index e5aea1a..6fe32ce 100644 --- a/backend/settings/config.py +++ b/backend/settings/config.py @@ -5,3 +5,12 @@ # --------------------------------------------------------- APP_NAME = "network-manager" APP_VERSION = "0.0.2" + +# --------------------------------------------------------- +# Backup System +# --------------------------------------------------------- +BACKUP_VERSION = "1.0" +BACKUP_DB_STRUCTURE_VERSION = "1.0" +BACKUP_METADATA_FILE = "metadata.json" +BACKUP_HOSTS_FILE = "hosts.json" +BACKUP_ALIASES_FILE = "aliases.json" \ No newline at end of file diff --git a/backend/settings/default.py b/backend/settings/default.py index 37ca4b2..3b0ad50 100644 --- a/backend/settings/default.py +++ b/backend/settings/default.py @@ -60,6 +60,11 @@ DHCP4_LEASES_FILE="/dhcp/lib/dhcp4.leases" DHCP6_HOST_FILE="/dhcp/etc/hosts-ipv6.json" DHCP6_LEASES_FILE="/dhcp/lib/dhcp6.leases" +# --------------------------------------------------------- +# Backup +# --------------------------------------------------------- +BACKUP_PATH = "backup" + # --------------------------------------------------------- # APP Features # --------------------------------------------------------- diff --git a/backend/settings/settings.py b/backend/settings/settings.py index e3a6e8a..43a80f2 100644 --- a/backend/settings/settings.py +++ b/backend/settings/settings.py @@ -95,6 +95,9 @@ class Settings(BaseModel): DHCP6_HOST_FILE: str = Field(default_factory=lambda: os.getenv("DHCP6_HOST_FILE", default.DHCP6_HOST_FILE)) DHCP6_LEASES_FILE: str = Field(default_factory=lambda: os.getenv("DHCP6_LEASES_FILE", default.DHCP6_LEASES_FILE)) + # Backup + BACKUP_PATH: str= Field(default_factory=lambda: os.getenv("BACKUP_PATH", default.BACKUP_PATH)) + # APP Features PING_WORKERS: int = Field(default_factory=lambda: int(os.getenv("PING_WORKERS", default.PING_WORKERS))) @@ -110,6 +113,9 @@ class Settings(BaseModel): self.LOG_FILE = self.DATA_PATH + "/" + self.LOG_FILE self.LOG_ACCESS_FILE = self.DATA_PATH + "/" + self.LOG_ACCESS_FILE + # Backup + self.BACKUP_PATH = self.BACKUP_PATH if os.path.isabs(self.BACKUP_PATH) else os.path.join(self.DATA_PATH, self.BACKUP_PATH) + # Update DNS Files if self.DOMAIN.lower() != default.DOMAIN.lower(): self.DNS_HOST_FILE = self.DNS_HOST_FILE.replace(default.DOMAIN, self.DOMAIN) diff --git a/frontend/js/index.js b/frontend/js/index.js index 6704c02..2f1b762 100644 --- a/frontend/js/index.js +++ b/frontend/js/index.js @@ -162,7 +162,13 @@ const actionHandlers = { const msg = (typeof result === 'object' && result?.message) ? result.message : 'Backup compleated successfully'; - showToast(msg, true); + if (result?.partial) { + // partial success + showToast(msg, false); + } else { + // success + showToast(msg, true); + } } catch (err) { showToast(err?.message || "Error performing backup", false); } finally { @@ -196,7 +202,13 @@ const actionHandlers = { const msg = (typeof result === 'object' && result?.message) ? result.message : 'Restore completed successfully'; - showToast(msg, true); + if (result?.partial) { + // partial success + showToast(msg, false); + } else { + // success + showToast(msg, true); + } // Close modal and reset input if (modal) modal.style.display = 'none'; input.value = ''; diff --git a/frontend/js/services.js b/frontend/js/services.js index d86492c..a9aa81c 100644 --- a/frontend/js/services.js +++ b/frontend/js/services.js @@ -195,6 +195,11 @@ export async function doBackup() { if (res.ok && (data.status === 'success' || data.code === 'BACKUP_OK')) { // Success return data?.message ? { message: data.message } : true; + } else if (res.ok && (data.status === 'partial' || data.code === 'BACKUP_PARTIAL')) { + // Partial success + return data?.message + ? { message: data.message, partial: true } + : { partial: true }; } else { // Failed with JSON error message return data?.message ? { message: data.message } : false; @@ -257,6 +262,11 @@ export async function doRestore(id) { if (res.ok && (data.status === 'success' || data.code === 'RESTORE_OK')) { // Success return data?.message ? { message: data.message } : true; + } else if (res.ok && (data.status === 'partial' || data.code === 'RESTORE_PARTIAL')) { + // Partial success + return data?.message + ? { message: data.message, partial: true } + : { partial: true }; } else { // Failed with JSON error message return data?.message ? { message: data.message } : false;