mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
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 '<volid>', 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
This commit is contained in:
@@ -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<void>
|
||||
storageColor?: (storage: string) => { bg: string; text: string; border: string }
|
||||
}) {
|
||||
const t = useT()
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
@@ -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 }: {
|
||||
<Checkbox aria-label={`${t("vmLxc.backups.selectBackup")} ${backup.date} ${backup.storage}`} checked={selected.includes(backup.volid)}
|
||||
disabled={disabled || !!backup.protected || (chosen.length >= 20 && !selected.includes(backup.volid))}
|
||||
onCheckedChange={checked => setSelected(previous => checked ? [...previous, backup.volid] : previous.filter(id => id !== backup.volid))} />
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
|
||||
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm min-w-0 truncate" title={backup.volid}>{backup.date}</span>
|
||||
{backup.protected && <Badge variant="secondary" className="gap-1 text-xs"><LockKeyhole className="h-3 w-3" />{t("vmLxc.backups.protected")}</Badge>}
|
||||
<Badge variant="outline" className="text-xs ml-auto shrink-0">{backup.storage}</Badge>
|
||||
<Badge variant="outline" className={`text-xs ml-auto shrink-0 ${storageClass(backup.storage)}`}>{backup.storage}</Badge>
|
||||
<Badge variant="outline" className="font-mono shrink-0">{backup.size_human}</Badge>
|
||||
</label>)}
|
||||
</div>
|
||||
|
||||
@@ -7499,6 +7499,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
vmModalCacheRef.current.backups.delete(selectedVM.vmid)
|
||||
await fetchVmBackups(selectedVM.vmid, true)
|
||||
}}
|
||||
storageColor={getStorageColor}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
@@ -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]},
|
||||
|
||||
Reference in New Issue
Block a user