diff --git a/AppImage/ProxMenux-1.2.2.2-beta.AppImage b/AppImage/ProxMenux-1.2.2.2-beta.AppImage index 0dfa3c11..d64c2417 100755 Binary files a/AppImage/ProxMenux-1.2.2.2-beta.AppImage and b/AppImage/ProxMenux-1.2.2.2-beta.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index afedf81f..cb6b0ea6 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -83702ea66ef42f7c472783e3f5d9deed77299722e02af0e874901fa5db2ba57d ProxMenux-1.2.2.2-beta.AppImage +3bd1434e5ed24ab8244a6288a841615f6a126b9689069a8f46797d2d1ddbe139 ProxMenux-1.2.2.2-beta.AppImage diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index c8987c1f..37929e4b 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -762,7 +762,10 @@ export function HostBackup() { onClose={() => setInspectingArchive(null)} onDeleted={() => { setInspectingArchive(null) + // Refresh both lists — a deleted item might have been local + // or remote (PBS/Borg). Cheap enough to revalidate both. mutateArchives() + mutateRemoteArchives() }} /> @@ -1129,21 +1132,40 @@ function InspectModal({ return downloadLocalArchive() } - // Local archive deletion. The backend purges the .tar.zst, the - // .proxmenux.json sidecar and the matching .log so the - // /var/log/proxmenux directory stays in sync with the archive list. - // Remote archives (PBS / Borg) are not deletable from here — those - // belong to their own datastore prune policy. - const deleteLocalArchive = async () => { - if (!localArc) return + // Archive deletion — works for local, PBS and Borg backends. + // • local: DELETE /archives/ → removes .tar.zst + sidecar + run log. + // • pbs: DELETE /remote-archives → `proxmox-backup-client snapshot forget`. + // • borg: DELETE /remote-archives → `borg delete ::`. + // PBS-protected snapshots come back as 409 with the original message so + // the operator can decide whether to lift the protection first. + const deleteArchive = async () => { + if (!archive) return setDeletingArchive(true) setError(null) try { - const resp = await fetchApi<{ status: string; removed: string[] }>( - `/api/host-backups/archives/${encodeURIComponent(localArc.id)}`, - { method: "DELETE" }, - ) - setArchiveDeleteResult(resp.removed || []) + let removed: string[] = [] + if (archive.source === "local" && localArc) { + const resp = await fetchApi<{ status: string; removed: string[] }>( + `/api/host-backups/archives/${encodeURIComponent(localArc.id)}`, + { method: "DELETE" }, + ) + removed = resp.removed || [] + } else if (remoteArc) { + const resp = await fetchApi<{ status: string; removed: string }>( + `/api/host-backups/remote-archives`, + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + backend: remoteArc.backend, + repo_name: remoteArc.repo_name, + snapshot: remoteArc.snapshot, + }), + }, + ) + removed = resp.removed ? [resp.removed] : [] + } + setArchiveDeleteResult(removed) setShowDeleteArchiveConfirm(false) if (onDeleted) onDeleted(); else onClose() } catch (e) { @@ -1387,22 +1409,26 @@ function InspectModal({ View contents - {archive?.source === "local" && ( - - )} + @@ -1521,26 +1547,30 @@ function InspectModal({ - Delete archive + Delete {backendLabel} backup - Removes the archive, its sidecar JSON and the matching run log. The action is permanent — restore needs an off-host copy. + {archive?.source === "local" + ? "Removes the archive, its sidecar JSON and the matching run log. The action is permanent — restore needs an off-host copy." + : archive?.source === "pbs" + ? `Forgets this snapshot from the PBS repository "${remoteArc?.repo_name ?? ""}". The action is permanent — PBS GC may reclaim the underlying chunks at the next garbage-collection run.` + : `Deletes this archive from the Borg repository "${remoteArc?.repo_name ?? ""}". The action is permanent — Borg compacts the freed space at the next prune.`}
- {localArc?.id} + {archive?.source === "local" ? localArc?.id : remoteArc?.snapshot}
-
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 02ffab7a..f2bbff95 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -15440,6 +15440,82 @@ def api_host_backups_remote_archives(): return jsonify({'snapshots': snapshots, 'errors': errors}) +@app.route('/api/host-backups/remote-archives', methods=['DELETE']) +@require_auth +def api_host_backups_remote_archive_delete(): + """Forget/delete a single PBS snapshot or Borg archive. + + Body: {"backend": "pbs"|"borg", "repo_name": "...", "snapshot": "..."} + - PBS snapshot: "host//" → invokes + `proxmox-backup-client snapshot forget`. + - Borg archive: "" → invokes + `borg delete ::`. + + Local archives have their own DELETE endpoint + (`/api/host-backups/archives/`); this one only handles remotes. + Protected PBS snapshots and missing remotes surface a clear error. + """ + payload = request.get_json(silent=True) or {} + backend = (payload.get('backend') or '').strip().lower() + repo_nm = (payload.get('repo_name') or '').strip() + snapshot = (payload.get('snapshot') or '').strip() + + if backend not in ('pbs', 'borg'): + return jsonify({'error': "backend must be 'pbs' or 'borg'"}), 400 + if not repo_nm: + return jsonify({'error': 'repo_name is required'}), 400 + if not snapshot: + return jsonify({'error': 'snapshot is required'}), 400 + + if backend == 'pbs': + repo = next((r for r in _list_pbs_destinations() if r['name'] == repo_nm), None) + if not repo: + return jsonify({'error': f"PBS destination '{repo_nm}' not found"}), 404 + pwd, fp = _pbs_secret_for(repo_nm) + if not pwd: + return jsonify({'error': f"no password available for PBS repo '{repo_nm}'"}), 400 + env = {**os.environ, 'PBS_PASSWORD': pwd} + if fp: + env['PBS_FINGERPRINT'] = fp + try: + r = subprocess.run( + ['proxmox-backup-client', 'snapshot', 'forget', + snapshot, '--repository', repo['repository']], + capture_output=True, text=True, timeout=30, env=env, + ) + except (subprocess.TimeoutExpired, OSError) as e: + return jsonify({'error': str(e)}), 500 + if r.returncode != 0: + msg = (r.stderr.strip() or r.stdout.strip() or + f'proxmox-backup-client exited {r.returncode}') + # Surface PBS's own "protected" error as a 409 so the UI + # can show a clearer message than the generic 500. + status = 409 if 'protected' in msg.lower() else 500 + return jsonify({'error': msg}), status + return jsonify({'status': 'ok', 'removed': snapshot}) + + # backend == 'borg' + target = next((t for t in _list_borg_destinations() if t['name'] == repo_nm), None) + if not target: + return jsonify({'error': f"Borg destination '{repo_nm}' not found"}), 404 + repo = target.get('repository') or '' + if not repo: + return jsonify({'error': "target has no repository path"}), 400 + env = _borg_env_for(target) + try: + r = subprocess.run( + ['borg', 'delete', f'{repo}::{snapshot}'], + capture_output=True, text=True, timeout=120, env=env, + ) + except (subprocess.TimeoutExpired, OSError) as e: + return jsonify({'error': str(e)}), 500 + if r.returncode != 0: + msg = (r.stderr.strip() or r.stdout.strip() or + f'borg delete exited {r.returncode}') + return jsonify({'error': msg}), 500 + return jsonify({'status': 'ok', 'removed': snapshot}) + + # ── Borg helpers (mirrors the PBS pair above) ──────────────────────