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 { useT } from "@/lib/i18n/provider"
|
||||||
import { deleteGuestBackups, type GuestBackup } from "../lib/guest-backup-archives"
|
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
|
vmid: number
|
||||||
backups: GuestBackup[]
|
backups: GuestBackup[]
|
||||||
loading: boolean
|
loading: boolean
|
||||||
onRefresh: () => Promise<void>
|
onRefresh: () => Promise<void>
|
||||||
|
storageColor?: (storage: string) => { bg: string; text: string; border: string }
|
||||||
}) {
|
}) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const [selected, setSelected] = useState<string[]>([])
|
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 chosen = backups.filter(backup => selected.includes(backup.volid) && !backup.protected)
|
||||||
const selectable = backups.filter(backup => !backup.protected)
|
const selectable = backups.filter(backup => !backup.protected)
|
||||||
const disabled = loading || busy || refreshing
|
const disabled = loading || busy || refreshing
|
||||||
|
const storageClass = (storage: string) => {
|
||||||
|
const color = storageColor?.(storage)
|
||||||
|
return color ? `${color.bg} ${color.text} ${color.border}` : ""
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
setRefreshing(true)
|
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)}
|
<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))}
|
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))} />
|
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" />
|
<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>
|
<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>}
|
{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>
|
<Badge variant="outline" className="font-mono shrink-0">{backup.size_human}</Badge>
|
||||||
</label>)}
|
</label>)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7499,6 +7499,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
vmModalCacheRef.current.backups.delete(selectedVM.vmid)
|
vmModalCacheRef.current.backups.delete(selectedVM.vmid)
|
||||||
await fetchVmBackups(selectedVM.vmid, true)
|
await fetchVmBackups(selectedVM.vmid, true)
|
||||||
}}
|
}}
|
||||||
|
storageColor={getStorageColor}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export async function deleteGuestBackups(vmid: number, backups: GuestBackup[]):
|
|||||||
const response = await fetchApi<{ results: BackupDeletionResult[] }>(`/api/vms/${vmid}/backups/delete`, {
|
const response = await fetchApi<{ results: BackupDeletionResult[] }>(`/api/vms/${vmid}/backups/delete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ confirm: true, backups: backups.map(({ volid, storage }) => ({ volid, storage })) }),
|
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 ||
|
if (!Array.isArray(response.results) || response.results.length !== backups.length ||
|
||||||
response.results.some((result, index) => result.volid !== backups[index].volid ||
|
response.results.some((result, index) => result.volid !== backups[index].volid ||
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|||||||
|
|
||||||
|
|
||||||
MAX_SELECTION = 20
|
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}")
|
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}")
|
NODE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]{0,62}")
|
||||||
_tasks = {}
|
_tasks = {}
|
||||||
@@ -45,10 +46,15 @@ def _pvesh(args, timeout=20):
|
|||||||
raise BackupArchiveError("Proxmox command unavailable or timed out; outcome unconfirmed", 502) from error
|
raise BackupArchiveError("Proxmox command unavailable or timed out; outcome unconfirmed", 502) from error
|
||||||
if result.returncode:
|
if result.returncode:
|
||||||
raise BackupArchiveError((result.stderr or "Proxmox command failed").strip()[:1000], 502)
|
raise BackupArchiveError((result.stderr or "Proxmox command failed").strip()[:1000], 502)
|
||||||
|
output = result.stdout.strip()
|
||||||
try:
|
try:
|
||||||
# pvesh can print no bytes for the API's successful null result.
|
# 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:
|
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
|
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.
|
# associated notes/log files and returns null only after success.
|
||||||
task = _pvesh(["delete", f'/nodes/{node}/storage/{item["storage"]}/content/{item["volid"]}',
|
task = _pvesh(["delete", f'/nodes/{node}/storage/{item["storage"]}/content/{item["volid"]}',
|
||||||
"--delay", "1"])
|
"--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"}
|
return {**item, "status": "deleted"}
|
||||||
_remember_task(task, vmid, node, item["volid"])
|
return {**item, "status": "unconfirmed", "error": "Invalid Proxmox response; outcome unconfirmed"}
|
||||||
return {**item, "status": "pending", "task": task}
|
|
||||||
except BackupArchiveError as error:
|
except BackupArchiveError as error:
|
||||||
return {**item, "status": "unconfirmed", "error": str(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"],
|
["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):
|
def test_bad_payloads_and_paths_never_reach_proxmox(self):
|
||||||
payloads = [None, [], {}, {"confirm": 1}, {"confirm": True, "backups": []},
|
payloads = [None, [], {}, {"confirm": 1}, {"confirm": True, "backups": []},
|
||||||
{"confirm": True, "backups": [None]}, {"confirm": True, "backups": [VOLUME]},
|
{"confirm": True, "backups": [None]}, {"confirm": True, "backups": [VOLUME]},
|
||||||
|
|||||||
Reference in New Issue
Block a user