Matthias Nott
2026-02-26 5d0247159b125bf035285d56c2b9bb58d6bb3029
feat: backup download endpoint + skip-backup sync option

- Add GET /api/backups/download/{project}/{env}/{name} for browser file saves
- Add Save button on local backups in the UI
- Add skip_backup query param to sync endpoint (skips pre-sync snapshot)
- Add Skip safety backup checkbox in sync modal
- Bump app version to v15
4 files modified
changed files
app/routers/backups.py patch | view | blame | history
app/routers/sync_data.py patch | view | blame | history
static/index.html patch | view | blame | history
static/js/app.js patch | view | blame | history
app/routers/backups.py
....@@ -1,9 +1,11 @@
11 import json
2
+import os
23 from datetime import datetime, timezone
34 from typing import Any, AsyncGenerator
45
56 from fastapi import APIRouter, Depends, HTTPException, Query
67 from fastapi.responses import StreamingResponse
8
+from starlette.responses import FileResponse
79
810 from app.auth import verify_token
911 from app.ops_runner import (
....@@ -304,3 +306,27 @@
304306 raise HTTPException(status_code=500, detail=f"Delete failed: {results}")
305307
306308 return {"success": True, "project": project, "env": env, "name": name, "results": results}
309
+
310
+
311
+@router.get("/download/{project}/{env}/{name}", summary="Download a backup to browser")
312
+async def download_backup(
313
+ project: str,
314
+ env: str,
315
+ name: str,
316
+ _: str = Depends(verify_token),
317
+) -> FileResponse:
318
+ """Serve a local backup file as a browser download."""
319
+ for param in (project, env, name):
320
+ if "/" in param or "\\" in param or ".." in param:
321
+ raise HTTPException(status_code=400, detail="Invalid path parameter")
322
+
323
+ backup_path = f"/opt/data/backups/{project}/{env}/{name}"
324
+ if not os.path.isfile(backup_path):
325
+ raise HTTPException(status_code=404, detail="Backup file not found")
326
+
327
+ media_type = "application/gzip" if name.endswith(".gz") else "application/octet-stream"
328
+ return FileResponse(
329
+ path=backup_path,
330
+ media_type=media_type,
331
+ filename=name,
332
+ )
app/routers/sync_data.py
....@@ -29,6 +29,7 @@
2929 db_only: bool,
3030 uploads_only: bool,
3131 dry_run: bool = False,
32
+ skip_backup: bool = False,
3233 ) -> AsyncGenerator[str, None]:
3334 """Stream sync output via SSE."""
3435 args = ["sync", project, "--from", from_env, "--to", to_env]
....@@ -38,6 +39,8 @@
3839 args.append("--uploads-only")
3940 if dry_run:
4041 args.append("--dry-run")
42
+ if skip_backup:
43
+ args.append("--skip-backup")
4144
4245 mode = "db-only" if db_only else ("uploads-only" if uploads_only else "full")
4346 yield _sse_line({
....@@ -62,6 +65,7 @@
6265 db_only: bool = Query(default=False),
6366 uploads_only: bool = Query(default=False),
6467 dry_run: bool = Query(default=False),
68
+ skip_backup: bool = Query(default=False),
6569 _: str = Depends(verify_token),
6670 ) -> StreamingResponse:
6771 """Sync data backward (prod->int, int->dev) with SSE streaming."""
....@@ -71,7 +75,7 @@
7175 detail=f"Invalid sync path '{from_env} -> {to_env}'. Only adjacent pairs are allowed: prod<->int, int<->dev.",
7276 )
7377 return StreamingResponse(
74
- _sync_generator(project, from_env, to_env, db_only, uploads_only, dry_run),
78
+ _sync_generator(project, from_env, to_env, db_only, uploads_only, dry_run, skip_backup),
7579 media_type="text/event-stream",
7680 headers={
7781 "Cache-Control": "no-cache",
static/index.html
....@@ -287,6 +287,6 @@
287287 </div>
288288 </div>
289289
290
-<script src="/static/js/app.js?v=13"></script>
290
+<script src="/static/js/app.js?v=15"></script>
291291 </body>
292292 </html>
static/js/app.js
....@@ -1,5 +1,5 @@
11 'use strict';
2
-const APP_VERSION = 'v14-20260222';
2
+const APP_VERSION = 'v15-20260226';
33
44 // ============================================================
55 // OPS Dashboard — Vanilla JS Application (v6)
....@@ -827,6 +827,9 @@
827827 const downloadBtn = (!b.hasLocal && b.hasOffsite)
828828 ? `<button class="btn btn-ghost btn-xs" style="color:#34d399;border-color:rgba(52,211,153,0.25);" onclick="downloadOffsiteBackup('${esc(b.project)}','${esc(b.env)}','${esc(b.name)}')">Download</button>`
829829 : '';
830
+ const saveBtn = b.hasLocal
831
+ ? `<button class="btn btn-ghost btn-xs" style="color:#60a5fa;border-color:rgba(96,165,250,0.25);" onclick="downloadBackupFile('${esc(b.project)}','${esc(b.env)}','${esc(b.name)}')">Save</button>`
832
+ : '';
830833 h += `<tr>
831834 <td style="padding-left:0.75rem;"><input type="checkbox" class="backup-cb" value="${esc(b.name)}"${checked} onclick="toggleBackupSelect('${esc(b.name)}')" style="accent-color:#3b82f6;cursor:pointer;"></td>
832835 <td>${locationBadge}</td>
....@@ -834,6 +837,7 @@
834837 <td>${esc(b.size_human || '\u2014')}</td>
835838 <td style="white-space:nowrap;">
836839 <button class="btn btn-danger btn-xs" onclick="openRestoreModal('${esc(b.project)}','${esc(b.env)}','${restoreSource}','${esc(b.name)}',${b.hasLocal},${b.hasOffsite})">Restore</button>
840
+ ${saveBtn}
837841 ${uploadBtn}
838842 ${downloadBtn}
839843 ${deleteBtn}
....@@ -927,6 +931,10 @@
927931 cachedBackups = null;
928932 toast(`Deleted ${ok}${fail > 0 ? ', ' + fail + ' failed' : ''}`, fail > 0 ? 'warning' : 'success');
929933 if (currentPage === 'backups') renderBackups();
934
+}
935
+
936
+function downloadBackupFile(project, env, name) {
937
+ window.open(`/api/backups/download/${encodeURIComponent(project)}/${encodeURIComponent(env)}/${encodeURIComponent(name)}?token=${encodeURIComponent(getToken())}`, '_blank');
930938 }
931939
932940 async function uploadOffsiteBackup(project, env, name) {
....@@ -1702,6 +1710,10 @@
17021710 ih += '<span style="font-size:0.75rem;color:#6b7280;margin-left:auto;">content flows up</span>';
17031711 ih += '</label>';
17041712 ih += '</div>';
1713
+ ih += '<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#d1d5db;cursor:pointer;margin-top:0.875rem;">';
1714
+ ih += '<input type="checkbox" id="ops-sync-skip-backup" style="width:1rem;height:1rem;accent-color:#f59e0b;">';
1715
+ ih += 'Skip safety backup <span style="font-size:0.75rem;color:#f59e0b;margin-left:0.25rem;">(faster, no pre-sync snapshot)</span>';
1716
+ ih += '</label>';
17051717
17061718 info.innerHTML = ih;
17071719 startBtn.className = 'btn btn-primary btn-sm';
....@@ -1871,7 +1883,9 @@
18711883 if (type === 'promote') {
18721884 url = '/api/promote/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv) + '/' + encodeURIComponent(toEnv) + '?dry_run=' + dryRun + '&token=' + encodeURIComponent(getToken());
18731885 } else if (type === 'sync') {
1874
- url = '/api/sync/' + encodeURIComponent(project) + '?from=' + encodeURIComponent(fromEnv) + '&to=' + encodeURIComponent(toEnv) + '&dry_run=' + dryRun + '&token=' + encodeURIComponent(getToken());
1886
+ const skipBackupEl = document.getElementById('ops-sync-skip-backup');
1887
+ const skipBackup = skipBackupEl ? skipBackupEl.checked : false;
1888
+ url = '/api/sync/' + encodeURIComponent(project) + '?from=' + encodeURIComponent(fromEnv) + '&to=' + encodeURIComponent(toEnv) + '&dry_run=' + dryRun + (skipBackup ? '&skip_backup=true' : '') + '&token=' + encodeURIComponent(getToken());
18751889 } else if (type === 'restart' || type === 'rebuild') {
18761890 url = '/api/rebuild/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv)
18771891 + '?action=' + encodeURIComponent(type) + '&token=' + encodeURIComponent(getToken());