From f1f0e0c6e2d6aa5947425f5f7c64c37f68a24ed3 Mon Sep 17 00:00:00 2001 From: Giorgio Ravera Date: Sat, 30 May 2026 19:40:17 +0200 Subject: [PATCH] Added button to download backup --- backend/routes/backup.py | 33 ++++++++++++++++++++--- frontend/index.html | 4 +-- frontend/js/api.js | 48 ++++++++++++++++++++++++++++++++++ frontend/js/index.js | 56 +++++++++++++++++++++++++++++++++++----- frontend/js/services.js | 27 +++++++++++++++++-- frontend/modals.html | 5 +++- requirements.txt | 1 + 7 files changed, 159 insertions(+), 15 deletions(-) diff --git a/backend/routes/backup.py b/backend/routes/backup.py index 9d457e8..9166b34 100644 --- a/backend/routes/backup.py +++ b/backend/routes/backup.py @@ -1,8 +1,8 @@ # backend/routes/backup.py # import standard modules -from fastapi import APIRouter, HTTPException, status -from fastapi.responses import Response, JSONResponse +from fastapi.responses import Response, JSONResponse, FileResponse +from pathlib import Path from pydantic import BaseModel import time from typing import Dict, Any @@ -10,6 +10,8 @@ from typing import Dict, Any # Import local modules from backend.backup import backup_create, backup_list, backup_restore, backup_delete +# Import Settings +from backend.settings.settings import settings # Import Logging from backend.log.log import get_logger @@ -87,7 +89,7 @@ def build_operation_response( } # --------------------------------------------------------- -# API ENDPOINTS +# API: Create Backup # --------------------------------------------------------- @router.post("/api/backup/create") async def api_backup_create(): @@ -224,3 +226,28 @@ async def api_backup_delete(payload: BackupDeleteRequest): status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={"code": "BACKUP_DELETE_ERROR", "message": "Internal error"}, ) + +# --------------------------------------------------------- +# API: Download backup +# --------------------------------------------------------- +@router.get("/api/backup/download/{backup_id}") +def download_backup(backup_id: str): + backup_dir = Path(settings.BACKUP_PATH) + + zip_path = backup_dir / f"{backup_id}" + + if not zip_path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "BACKUP_NOT_FOUND", + "status": "failure", + "message": "Backup not found", + }, + ) + + return FileResponse( + path=zip_path, + filename=zip_path.name, + media_type="application/zip" + ) diff --git a/frontend/index.html b/frontend/index.html index 3d1544f..226eddc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -136,7 +136,7 @@ -
+

Backup & Restore

Esecuzione backup e gestione archivi.

@@ -145,7 +145,7 @@ -
diff --git a/frontend/js/api.js b/frontend/js/api.js index 34a1e75..f28a3b5 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -70,10 +70,16 @@ export async function apiRequest( return data; } +// ------------------------------------------------------- +// API Get +// ------------------------------------------------------- export function apiGet(url, errorPrefix = 'Fetch error') { return apiRequest(url, { method: 'GET' }, errorPrefix); } +// ------------------------------------------------------- +// API Post +// ------------------------------------------------------- export function apiPost(url, payload, errorPrefix = 'Request error') { return apiRequest( url, @@ -85,3 +91,45 @@ export function apiPost(url, payload, errorPrefix = 'Request error') { errorPrefix ); } + +// ------------------------------------------------------- +// API Download +// ------------------------------------------------------- +export async function apiDownload(url, errorPrefix = 'Download error') { + let res; + + try { + res = await fetch(url); + } catch (err) { + throw new Error(`${errorPrefix}: network error`); + } + + const contentType = res.headers.get("content-type") || ""; + + // JSON message (error) + if (contentType.includes("application/json")) { + let data; + + try { + data = await res.json(); + } catch { + throw new Error(`${errorPrefix}: Invalid error payload`); + } + + const msg = + data?.detail?.message || + data?.message || + "Download failed"; + + throw new Error(`${errorPrefix}: ${msg}`); + } + + // File + if (!res.ok) { + throw new Error( + `${errorPrefix}: ${res.status} ${res.statusText || ''}`.trim() + ); + } + + return res; +} \ No newline at end of file diff --git a/frontend/js/index.js b/frontend/js/index.js index 249c2b1..655ec1b 100644 --- a/frontend/js/index.js +++ b/frontend/js/index.js @@ -2,7 +2,7 @@ // IMPORT // ------------------------------------------------------- import { loadModals, showToast } from './common.js'; -import { serviceCheckAbount, serviceCheckHealth , serviceReloadDNS, serviceReloadDHCP, serviceBackupCreate, serviceBackupList, serviceBackupRestore, deleteBackup} from './services.js'; +import { serviceCheckAbount, serviceCheckHealth , serviceReloadDNS, serviceReloadDHCP, serviceBackupCreate, serviceBackupList, serviceBackupRestore, serviceDownloadBackup, serviceDeleteBackup} from './services.js'; // ------------------------------------------------------- // BACKUP MODAL OPEN/CLOSE @@ -93,9 +93,18 @@ function renderBackupList(data) { tdSize.textContent = formattedSize; tdSize.classList.add("text-end"); - // actions (DELETE) + // actions const tdActions = document.createElement("td"); tdActions.classList.add("text-end"); + // download button + const downloadBtn = document.createElement("button"); + downloadBtn.className = "btn btn-sm btn-outline-primary me-2"; + downloadBtn.title = "Download backup"; + downloadBtn.innerHTML = ``; + downloadBtn.setAttribute("data-action", "downloadBackup"); + downloadBtn.setAttribute("data-id", b.name); + tdActions.appendChild(downloadBtn); + // delete button const deleteBtn = document.createElement("button"); deleteBtn.className = "btn btn-sm btn-outline-danger"; deleteBtn.title = "Delete backup"; @@ -272,8 +281,9 @@ const actionHandlers = { ? result.message : 'Backup completed successfully'; showToast(msg, !result?.partial); - // Close modal - if (modal) modal.style.display = 'none'; + // reload list + const data = await serviceBackupList(); + renderBackupList(data); } catch (err) { showToast(err?.message || "Error performing backup", false); } finally { @@ -305,7 +315,7 @@ const actionHandlers = { : 'Restore completed successfully'; showToast(msg, !result?.partial); // Close modal - if (modal) modal.style.display = 'none'; + //if (modal) modal.style.display = 'none'; } catch (err) { showToast(err?.message || "Error performing restore", false); } finally { @@ -313,6 +323,27 @@ const actionHandlers = { btn.disabled = false; } }, + // Download Backup + downloadBackup: async (e, el) => { + e.stopPropagation(); + + const id = el.dataset.id; + if (!id) return; + + try { + const result = await serviceDownloadBackup(id); + + const msg = (typeof result === 'object' && result?.message) + ? result.message + : 'Backup downloaded successfully'; + + showToast(msg, true); + + } catch (err) { + console.error(err); + showToast(err?.message || "Error downloading backup", false); + } + }, // Delete Backup deleteBackup: async (e, el) => { @@ -325,7 +356,7 @@ const actionHandlers = { if (!confirmDelete) return; try { - const result = await deleteBackup(id); + const result = await serviceDeleteBackup(id); const msg = (typeof result === 'object' && result?.message) ? result.message @@ -342,7 +373,18 @@ const actionHandlers = { showToast(err?.message || "Error deleting backup", false); } }, - + refreshBackupList: async () => { + try { + const data = await serviceBackupList(); + const msg = (typeof result === 'object' && result?.message) + ? result.message + : 'Backup list refreshed successfully'; + showToast(msg, true); + renderBackupList(data); + } catch (err) { + showToast(err?.message || "Error refreshing backup list", false); + } + }, openBackupModal, // managed by boostrap closeBackupModal, // managed by boostrap // Reload DNS diff --git a/frontend/js/services.js b/frontend/js/services.js index e34a17b..7a36686 100644 --- a/frontend/js/services.js +++ b/frontend/js/services.js @@ -1,5 +1,5 @@ // import api -import { apiRequest, apiGet, apiPost } from './api.js'; +import { apiRequest, apiGet, apiPost, apiDownload } from './api.js'; // ------------------------------------------------------- // Check Abount @@ -279,10 +279,33 @@ export async function serviceBackupRestore(id) { return false; } +// ------------------------------------------------------- +// Download a Backup +// ------------------------------------------------------- +export async function serviceDownloadBackup(id) { + const res = await apiDownload( + `/api/backup/download/${encodeURIComponent(id)}`, + "Error downloading backup" + ); + + const blob = await res.blob(); + + const url = window.URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.href = url; + a.download = id; + document.body.appendChild(a); + a.click(); + + a.remove(); + window.URL.revokeObjectURL(url); +} + // ------------------------------------------------------- // Delete a Backup // ------------------------------------------------------- -export async function deleteBackup(id) { +export async function serviceDeleteBackup(id) { const data = await apiPost( "/api/backup/delete", { backup_id: id }, diff --git a/frontend/modals.html b/frontend/modals.html index 0247a83..e3ec26f 100644 --- a/frontend/modals.html +++ b/frontend/modals.html @@ -160,7 +160,7 @@ Name Date Size - Actions + Actions @@ -182,6 +182,9 @@ + diff --git a/requirements.txt b/requirements.txt index 1e40b78..523cb9d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ bcrypt fastapi itsdangerous uvicorn[standard] +python-multipart -- 2.47.3