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
| .. | .. |
|---|
| 1 | 1 | import json |
|---|
| 2 | +import os |
|---|
| 2 | 3 | from datetime import datetime, timezone |
|---|
| 3 | 4 | from typing import Any, AsyncGenerator |
|---|
| 4 | 5 | |
|---|
| 5 | 6 | from fastapi import APIRouter, Depends, HTTPException, Query |
|---|
| 6 | 7 | from fastapi.responses import StreamingResponse |
|---|
| 8 | +from starlette.responses import FileResponse |
|---|
| 7 | 9 | |
|---|
| 8 | 10 | from app.auth import verify_token |
|---|
| 9 | 11 | from app.ops_runner import ( |
|---|
| .. | .. |
|---|
| 304 | 306 | raise HTTPException(status_code=500, detail=f"Delete failed: {results}") |
|---|
| 305 | 307 | |
|---|
| 306 | 308 | 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 | + ) |
|---|
| .. | .. |
|---|
| 29 | 29 | db_only: bool, |
|---|
| 30 | 30 | uploads_only: bool, |
|---|
| 31 | 31 | dry_run: bool = False, |
|---|
| 32 | + skip_backup: bool = False, |
|---|
| 32 | 33 | ) -> AsyncGenerator[str, None]: |
|---|
| 33 | 34 | """Stream sync output via SSE.""" |
|---|
| 34 | 35 | args = ["sync", project, "--from", from_env, "--to", to_env] |
|---|
| .. | .. |
|---|
| 38 | 39 | args.append("--uploads-only") |
|---|
| 39 | 40 | if dry_run: |
|---|
| 40 | 41 | args.append("--dry-run") |
|---|
| 42 | + if skip_backup: |
|---|
| 43 | + args.append("--skip-backup") |
|---|
| 41 | 44 | |
|---|
| 42 | 45 | mode = "db-only" if db_only else ("uploads-only" if uploads_only else "full") |
|---|
| 43 | 46 | yield _sse_line({ |
|---|
| .. | .. |
|---|
| 62 | 65 | db_only: bool = Query(default=False), |
|---|
| 63 | 66 | uploads_only: bool = Query(default=False), |
|---|
| 64 | 67 | dry_run: bool = Query(default=False), |
|---|
| 68 | + skip_backup: bool = Query(default=False), |
|---|
| 65 | 69 | _: str = Depends(verify_token), |
|---|
| 66 | 70 | ) -> StreamingResponse: |
|---|
| 67 | 71 | """Sync data backward (prod->int, int->dev) with SSE streaming.""" |
|---|
| .. | .. |
|---|
| 71 | 75 | detail=f"Invalid sync path '{from_env} -> {to_env}'. Only adjacent pairs are allowed: prod<->int, int<->dev.", |
|---|
| 72 | 76 | ) |
|---|
| 73 | 77 | 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), |
|---|
| 75 | 79 | media_type="text/event-stream", |
|---|
| 76 | 80 | headers={ |
|---|
| 77 | 81 | "Cache-Control": "no-cache", |
|---|
| .. | .. |
|---|
| 287 | 287 | </div> |
|---|
| 288 | 288 | </div> |
|---|
| 289 | 289 | |
|---|
| 290 | | -<script src="/static/js/app.js?v=13"></script> |
|---|
| 290 | +<script src="/static/js/app.js?v=15"></script> |
|---|
| 291 | 291 | </body> |
|---|
| 292 | 292 | </html> |
|---|
| .. | .. |
|---|
| 1 | 1 | 'use strict'; |
|---|
| 2 | | -const APP_VERSION = 'v14-20260222'; |
|---|
| 2 | +const APP_VERSION = 'v15-20260226'; |
|---|
| 3 | 3 | |
|---|
| 4 | 4 | // ============================================================ |
|---|
| 5 | 5 | // OPS Dashboard — Vanilla JS Application (v6) |
|---|
| .. | .. |
|---|
| 827 | 827 | const downloadBtn = (!b.hasLocal && b.hasOffsite) |
|---|
| 828 | 828 | ? `<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>` |
|---|
| 829 | 829 | : ''; |
|---|
| 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 | + : ''; |
|---|
| 830 | 833 | h += `<tr> |
|---|
| 831 | 834 | <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> |
|---|
| 832 | 835 | <td>${locationBadge}</td> |
|---|
| .. | .. |
|---|
| 834 | 837 | <td>${esc(b.size_human || '\u2014')}</td> |
|---|
| 835 | 838 | <td style="white-space:nowrap;"> |
|---|
| 836 | 839 | <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} |
|---|
| 837 | 841 | ${uploadBtn} |
|---|
| 838 | 842 | ${downloadBtn} |
|---|
| 839 | 843 | ${deleteBtn} |
|---|
| .. | .. |
|---|
| 927 | 931 | cachedBackups = null; |
|---|
| 928 | 932 | toast(`Deleted ${ok}${fail > 0 ? ', ' + fail + ' failed' : ''}`, fail > 0 ? 'warning' : 'success'); |
|---|
| 929 | 933 | 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'); |
|---|
| 930 | 938 | } |
|---|
| 931 | 939 | |
|---|
| 932 | 940 | async function uploadOffsiteBackup(project, env, name) { |
|---|
| .. | .. |
|---|
| 1702 | 1710 | ih += '<span style="font-size:0.75rem;color:#6b7280;margin-left:auto;">content flows up</span>'; |
|---|
| 1703 | 1711 | ih += '</label>'; |
|---|
| 1704 | 1712 | 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>'; |
|---|
| 1705 | 1717 | |
|---|
| 1706 | 1718 | info.innerHTML = ih; |
|---|
| 1707 | 1719 | startBtn.className = 'btn btn-primary btn-sm'; |
|---|
| .. | .. |
|---|
| 1871 | 1883 | if (type === 'promote') { |
|---|
| 1872 | 1884 | url = '/api/promote/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv) + '/' + encodeURIComponent(toEnv) + '?dry_run=' + dryRun + '&token=' + encodeURIComponent(getToken()); |
|---|
| 1873 | 1885 | } 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()); |
|---|
| 1875 | 1889 | } else if (type === 'restart' || type === 'rebuild') { |
|---|
| 1876 | 1890 | url = '/api/rebuild/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv) |
|---|
| 1877 | 1891 | + '?action=' + encodeURIComponent(type) + '&token=' + encodeURIComponent(getToken()); |
|---|