From 63ae1ba68f1d2eda85b3df75df48f34d2cb1e63b Mon Sep 17 00:00:00 2001 From: MacRimi <123239993+MacRimi@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:48:49 +0200 Subject: [PATCH] Confirm deletions that end within the delay and keep the storage colours - A deletion that Proxmox completes within --delay prints its task log line, Removed volume '', instead of JSON; that exact line for the requested volume now counts as deleted - The backup list keeps the green dot and the colour of each storage - The deletion request waits up to 300 s, the worst case of 20 archives checked and deleted four at a time --- AppImage/components/guest-backup-list.tsx | 10 ++++++++-- AppImage/components/virtual-machines.tsx | 1 + AppImage/lib/guest-backup-archives.ts | 3 ++- AppImage/scripts/guest_backup_archives.py | 16 ++++++++++++---- tests/test_guest_backup_archives.py | 13 +++++++++++++ 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/AppImage/components/guest-backup-list.tsx b/AppImage/components/guest-backup-list.tsx index b98826f2..3f6878f7 100644 --- a/AppImage/components/guest-backup-list.tsx +++ b/AppImage/components/guest-backup-list.tsx @@ -9,11 +9,12 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { useT } from "@/lib/i18n/provider" import { deleteGuestBackups, type GuestBackup } from "../lib/guest-backup-archives" -export function GuestBackupList({ vmid, backups, loading, onRefresh }: { +export function GuestBackupList({ vmid, backups, loading, onRefresh, storageColor }: { vmid: number backups: GuestBackup[] loading: boolean onRefresh: () => Promise + storageColor?: (storage: string) => { bg: string; text: string; border: string } }) { const t = useT() const [selected, setSelected] = useState([]) @@ -35,6 +36,10 @@ export function GuestBackupList({ vmid, backups, loading, onRefresh }: { const chosen = backups.filter(backup => selected.includes(backup.volid) && !backup.protected) const selectable = backups.filter(backup => !backup.protected) const disabled = loading || busy || refreshing + const storageClass = (storage: string) => { + const color = storageColor?.(storage) + return color ? `${color.bg} ${color.text} ${color.border}` : "" + } async function refresh() { setRefreshing(true) @@ -99,10 +104,11 @@ export function GuestBackupList({ vmid, backups, loading, onRefresh }: { = 20 && !selected.includes(backup.volid))} onCheckedChange={checked => setSelected(previous => checked ? [...previous, backup.volid] : previous.filter(id => id !== backup.volid))} /> +
{backup.date} {backup.protected && {t("vmLxc.backups.protected")}} - {backup.storage} + {backup.storage} {backup.size_human} )}
diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 3fbc38e1..a538627a 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -7499,6 +7499,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { vmModalCacheRef.current.backups.delete(selectedVM.vmid) await fetchVmBackups(selectedVM.vmid, true) }} + storageColor={getStorageColor} /> diff --git a/AppImage/lib/guest-backup-archives.ts b/AppImage/lib/guest-backup-archives.ts index 36b8dffa..68dceb18 100644 --- a/AppImage/lib/guest-backup-archives.ts +++ b/AppImage/lib/guest-backup-archives.ts @@ -25,7 +25,8 @@ export async function deleteGuestBackups(vmid: number, backups: GuestBackup[]): const response = await fetchApi<{ results: BackupDeletionResult[] }>(`/api/vms/${vmid}/backups/delete`, { method: 'POST', body: JSON.stringify({ confirm: true, backups: backups.map(({ volid, storage }) => ({ volid, storage })) }), - signal: AbortSignal.timeout(120_000), + // Up to 20 archives, checked first and then deleted four at a time. + signal: AbortSignal.timeout(300_000), }) if (!Array.isArray(response.results) || response.results.length !== backups.length || response.results.some((result, index) => result.volid !== backups[index].volid || diff --git a/AppImage/scripts/guest_backup_archives.py b/AppImage/scripts/guest_backup_archives.py index c95077fc..7458c631 100644 --- a/AppImage/scripts/guest_backup_archives.py +++ b/AppImage/scripts/guest_backup_archives.py @@ -25,6 +25,7 @@ from concurrent.futures import ThreadPoolExecutor MAX_SELECTION = 20 +REMOVED_RE = re.compile(r"Removed volume '[^'\n]+'") STORAGE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}") NODE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]{0,62}") _tasks = {} @@ -45,10 +46,15 @@ def _pvesh(args, timeout=20): raise BackupArchiveError("Proxmox command unavailable or timed out; outcome unconfirmed", 502) from error if result.returncode: raise BackupArchiveError((result.stderr or "Proxmox command failed").strip()[:1000], 502) + output = result.stdout.strip() try: # pvesh can print no bytes for the API's successful null result. - return json.loads(result.stdout.strip() or "null") + return json.loads(output or "null") except (ValueError, TypeError) as error: + # With --delay, a deletion that ends in time prints the line of its + # task log instead of JSON. Only that exact line is accepted. + if args[0] == "delete" and REMOVED_RE.fullmatch(output): + return output raise BackupArchiveError("Invalid Proxmox response; outcome unconfirmed", 502) from error @@ -134,10 +140,12 @@ def delete_guest_backups(vmid, data): # associated notes/log files and returns null only after success. task = _pvesh(["delete", f'/nodes/{node}/storage/{item["storage"]}/content/{item["volid"]}', "--delay", "1"]) - if task is None: + if isinstance(task, str) and task.startswith("UPID:"): + _remember_task(task, vmid, node, item["volid"]) + return {**item, "status": "pending", "task": task} + if task is None or task == f"Removed volume '{item['volid']}'": return {**item, "status": "deleted"} - _remember_task(task, vmid, node, item["volid"]) - return {**item, "status": "pending", "task": task} + return {**item, "status": "unconfirmed", "error": "Invalid Proxmox response; outcome unconfirmed"} except BackupArchiveError as error: return {**item, "status": "unconfirmed", "error": str(error)} diff --git a/tests/test_guest_backup_archives.py b/tests/test_guest_backup_archives.py index 5d20b328..f9ff6769 100644 --- a/tests/test_guest_backup_archives.py +++ b/tests/test_guest_backup_archives.py @@ -56,6 +56,19 @@ class DeletionTests(unittest.TestCase): ["delete", f"/nodes/pve1/storage/local/content/{VOLUME}", "--delay", "1"], ]) + def test_a_deletion_that_ends_within_the_delay_prints_its_log_and_counts_as_deleted(self): + # Real pvesh output on a directory storage: the task log, not JSON. + run = subprocess.CompletedProcess([], 0, stdout=f"Removed volume '{VOLUME}'\n", stderr="") + with patch.object(archives.subprocess, "run", return_value=run): + self.assertEqual(archives._pvesh(["delete", f"/nodes/pve1/storage/local/content/{VOLUME}", "--delay", "1"]), + f"Removed volume '{VOLUME}'") + self.response = f"Removed volume '{VOLUME}'" + self.assertEqual(self.delete()["results"][0]["status"], "deleted") + self.response = "Removed volume 'local:backup/another-archive.tar.zst'" + self.assertEqual(self.delete()["results"][0]["status"], "unconfirmed") + with patch.object(archives.subprocess, "run", return_value=run), self.assertRaises(archives.BackupArchiveError): + archives._pvesh(["get", "/nodes/pve1/storage/local/content"]) + def test_bad_payloads_and_paths_never_reach_proxmox(self): payloads = [None, [], {}, {"confirm": 1}, {"confirm": True, "backups": []}, {"confirm": True, "backups": [None]}, {"confirm": True, "backups": [VOLUME]},