fix(backend): restore operations — add force param, fix QEMU restore API
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 47s
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 47s
- restore_vm/restore_lxc: add force=True param (needed for in-place overwrite) - QEMU restore: remove `restore: 1` from params (breaks Proxmox API — restore is triggered by archive presence alone, unlike LXC where restore: 1 required). This fixes both single and bulk QEMU restores. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -371,6 +371,124 @@ async def restore_backup(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/backups/restore-bulk")
|
||||
async def restore_backup_bulk(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("backup:restore")),
|
||||
):
|
||||
"""Restore several backups at once.
|
||||
|
||||
Body:
|
||||
server_id (int), storage (str)
|
||||
mode: "in_place" (overwrite original VMID, force) | "new_vmid" (clone to fresh VMIDs)
|
||||
start (bool): start each guest after restore
|
||||
items: [{node, vmid, archive, vm_type}] — expected one archive per distinct VMID
|
||||
|
||||
Returns a per-item result list with UPIDs / errors.
|
||||
"""
|
||||
data = await request.json()
|
||||
server_id = data.get("server_id")
|
||||
storage = data.get("storage")
|
||||
mode = data.get("mode", "in_place")
|
||||
start = bool(data.get("start", False))
|
||||
items = data.get("items", [])
|
||||
|
||||
if not server_id or not storage or not items:
|
||||
raise HTTPException(status_code=400, detail="server_id, storage and items are required")
|
||||
if mode not in ("in_place", "new_vmid"):
|
||||
raise HTTPException(status_code=400, detail="mode must be 'in_place' or 'new_vmid'")
|
||||
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
client = _get_proxmox_client(server)
|
||||
allocated: set = set()
|
||||
|
||||
def alloc_vmid(start_from: int):
|
||||
"""Find the next free VMID at/above start_from, skipping ones already handed out."""
|
||||
candidate = start_from
|
||||
for _ in range(10000):
|
||||
if candidate in allocated:
|
||||
candidate += 1
|
||||
continue
|
||||
try:
|
||||
# /cluster/nextid?vmid=X returns X if free, raises if taken
|
||||
ok = client.proxmox.cluster.nextid.get(vmid=candidate)
|
||||
chosen = int(ok) if ok else candidate
|
||||
allocated.add(chosen)
|
||||
return chosen
|
||||
except Exception:
|
||||
candidate += 1
|
||||
return None
|
||||
|
||||
next_start = None
|
||||
if mode == "new_vmid":
|
||||
next_start = await _run_in_executor(client.get_next_vmid) or 100
|
||||
|
||||
results = []
|
||||
for it in items:
|
||||
node = it.get("node")
|
||||
vmid = it.get("vmid")
|
||||
archive = it.get("archive")
|
||||
vm_type = it.get("vm_type", "qemu")
|
||||
|
||||
if not node or not vmid or not archive:
|
||||
results.append({"vmid": vmid, "archive": archive, "success": False,
|
||||
"error": "node, vmid and archive are required"})
|
||||
continue
|
||||
|
||||
new_vmid = None
|
||||
if mode == "new_vmid":
|
||||
new_vmid = await _run_in_executor(alloc_vmid, next_start)
|
||||
if not new_vmid:
|
||||
results.append({"vmid": vmid, "archive": archive, "success": False,
|
||||
"error": "No free VMID available"})
|
||||
continue
|
||||
next_start = new_vmid + 1
|
||||
|
||||
force = (mode == "in_place")
|
||||
try:
|
||||
if vm_type == "lxc":
|
||||
r = await _run_in_executor(
|
||||
client.restore_lxc,
|
||||
node=node, vmid=int(vmid), archive=archive, storage=storage,
|
||||
new_vmid=new_vmid, start=start, force=force,
|
||||
)
|
||||
else:
|
||||
r = await _run_in_executor(
|
||||
client.restore_vm,
|
||||
node=node, vmid=int(vmid), archive=archive, storage=storage,
|
||||
new_vmid=new_vmid, start=start, unique=(mode == "new_vmid"), force=force,
|
||||
)
|
||||
target = new_vmid or int(vmid)
|
||||
if r.get("success"):
|
||||
results.append({"vmid": vmid, "target_vmid": target, "archive": archive,
|
||||
"success": True, "upid": r.get("upid")})
|
||||
LoggingService.log_proxmox_action(
|
||||
db=db, action="backup_restore", resource_type="backup",
|
||||
resource_id=str(target), username=current_user.username,
|
||||
server_id=server_id, server_name=server.name, node_name=node, success=True,
|
||||
details={"archive": archive, "storage": storage, "vm_type": vm_type,
|
||||
"mode": mode, "bulk": True},
|
||||
)
|
||||
else:
|
||||
results.append({"vmid": vmid, "target_vmid": target, "archive": archive,
|
||||
"success": False, "error": r.get("error", "Failed")})
|
||||
except Exception as e:
|
||||
results.append({"vmid": vmid, "archive": archive, "success": False, "error": str(e)})
|
||||
|
||||
succeeded = sum(1 for r in results if r["success"])
|
||||
return JSONResponse(content={
|
||||
"success": succeeded > 0,
|
||||
"total": len(results),
|
||||
"succeeded": succeeded,
|
||||
"failed": len(results) - succeeded,
|
||||
"results": results,
|
||||
})
|
||||
|
||||
|
||||
# ==================== Scheduled Backup Jobs ====================
|
||||
|
||||
@router.get("/api/backups/proxmox-jobs/{server_id}")
|
||||
|
||||
@@ -2395,14 +2395,18 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def restore_vm(self, node: str, vmid: int, archive: str, storage: str,
|
||||
new_vmid: int = None, start: bool = False, unique: bool = True) -> Dict:
|
||||
new_vmid: int = None, start: bool = False, unique: bool = True,
|
||||
force: bool = False) -> Dict:
|
||||
"""
|
||||
Restore a QEMU VM from backup.
|
||||
archive: volume id like 'local:backup/vzdump-qemu-100-...'
|
||||
force: overwrite an existing VM with the same vmid (must be stopped).
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
# For QEMU, restore is triggered by passing `archive` to POST /nodes/{node}/qemu.
|
||||
# There is NO `restore` parameter (unlike LXC) — sending it returns a 400.
|
||||
params = {
|
||||
"vmid": new_vmid if new_vmid else vmid,
|
||||
"archive": archive,
|
||||
@@ -2410,15 +2414,21 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
"start": 1 if start else 0,
|
||||
"unique": 1 if unique else 0,
|
||||
}
|
||||
upid = self.proxmox.nodes(node).qemu.post(**{"restore": 1, **params})
|
||||
if force:
|
||||
params["force"] = 1
|
||||
upid = self.proxmox.nodes(node).qemu.post(**params)
|
||||
return {"success": True, "upid": upid}
|
||||
except Exception as e:
|
||||
logger.error(f"Error restoring VM {vmid} on {node}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def restore_lxc(self, node: str, vmid: int, archive: str, storage: str,
|
||||
new_vmid: int = None, start: bool = False) -> Dict:
|
||||
"""Restore an LXC container from backup"""
|
||||
new_vmid: int = None, start: bool = False,
|
||||
force: bool = False) -> Dict:
|
||||
"""Restore an LXC container from backup.
|
||||
|
||||
force: overwrite an existing container with the same vmid (must be stopped).
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
@@ -2429,6 +2439,8 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
"restore": 1,
|
||||
"start": 1 if start else 0,
|
||||
}
|
||||
if force:
|
||||
params["force"] = 1
|
||||
upid = self.proxmox.nodes(node).lxc.post(**params)
|
||||
return {"success": True, "upid": upid}
|
||||
except Exception as e:
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
useRunBackupJob,
|
||||
useDeleteBackup,
|
||||
useRestoreBackup,
|
||||
useBulkRestoreBackup,
|
||||
useCreateBackupJob,
|
||||
useUpdateBackupJob,
|
||||
useDeleteBackupJob,
|
||||
@@ -108,6 +109,7 @@ export default function BackupsPage() {
|
||||
const runJob = useRunBackupJob();
|
||||
const deleteBackup = useDeleteBackup();
|
||||
const restoreBackup = useRestoreBackup();
|
||||
const bulkRestore = useBulkRestoreBackup();
|
||||
const createJob = useCreateBackupJob();
|
||||
const updateJob = useUpdateBackupJob();
|
||||
const deleteJob = useDeleteBackupJob();
|
||||
@@ -131,6 +133,13 @@ export default function BackupsPage() {
|
||||
// --- Delete confirmation state ---
|
||||
const [deleteTarget, setDeleteTarget] = useState<BackupItem | null>(null);
|
||||
|
||||
// --- Bulk restore selection (one archive per VMID) ---
|
||||
const [selectedBackups, setSelectedBackups] = useState<Map<number, BackupItem>>(new Map());
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [bulkMode, setBulkMode] = useState<'in_place' | 'new_vmid'>('in_place');
|
||||
const [bulkStart, setBulkStart] = useState(false);
|
||||
const [bulkConfirm, setBulkConfirm] = useState(false);
|
||||
|
||||
const nodes = useMemo(() => (nodesData?.nodes || []), [nodesData]);
|
||||
const storages = useMemo(() => {
|
||||
const list = (storagesData?.storages || []) as { storage: string; type: string; content: string; used: number; total: number }[];
|
||||
@@ -139,6 +148,17 @@ export default function BackupsPage() {
|
||||
const backups = (backupsData?.backups || []) as BackupItem[];
|
||||
const jobs = (jobsData?.jobs || []) as BackupJob[];
|
||||
|
||||
// Latest backup per VMID — used by the "select all" checkbox (one archive per VMID).
|
||||
const latestPerVmid = useMemo(() => {
|
||||
const m = new Map<number, BackupItem>();
|
||||
for (const b of backups) {
|
||||
const cur = m.get(b.vmid);
|
||||
if (!cur || b.ctime > cur.ctime) m.set(b.vmid, b);
|
||||
}
|
||||
return m;
|
||||
}, [backups]);
|
||||
const allVmidsSelected = latestPerVmid.size > 0 && selectedBackups.size === latestPerVmid.size;
|
||||
|
||||
// Servers/storages for the job dialog (across all servers, not only the file-tab selection)
|
||||
const jobServerId = jobForm.server_id;
|
||||
const { data: jobNodesData } = useNodes(jobServerId || 0);
|
||||
@@ -229,6 +249,62 @@ export default function BackupsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// --- Bulk restore selection helpers (one archive per VMID) ---
|
||||
// Reset selection whenever the visible backup set changes (server/node/storage switch).
|
||||
useEffect(() => {
|
||||
setSelectedBackups(new Map());
|
||||
}, [sid, nodeParam, selectedStorage]);
|
||||
|
||||
const toggleBackupSelected = (b: BackupItem) => {
|
||||
setSelectedBackups(prev => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(b.vmid);
|
||||
if (current && current.volid === b.volid) {
|
||||
next.delete(b.vmid); // unselect this one
|
||||
} else {
|
||||
next.set(b.vmid, b); // select / replace the archive chosen for this VMID
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const openBulkRestore = () => {
|
||||
if (selectedBackups.size === 0) return;
|
||||
setBulkMode('in_place');
|
||||
setBulkStart(false);
|
||||
setBulkConfirm(false);
|
||||
setBulkOpen(true);
|
||||
};
|
||||
|
||||
const submitBulkRestore = () => {
|
||||
if (!sid || selectedBackups.size === 0) return;
|
||||
const items = Array.from(selectedBackups.values()).map(b => ({
|
||||
node: b.node || (selectedNode === ALL_NODES ? '' : selectedNode) || nodes[0]?.node || '',
|
||||
vmid: b.vmid,
|
||||
archive: b.volid,
|
||||
vm_type: detectVmType(b.volid),
|
||||
}));
|
||||
if (items.some(it => !it.node)) {
|
||||
toast.error(t('backups.node_required'));
|
||||
return;
|
||||
}
|
||||
bulkRestore.mutate(
|
||||
{ server_id: sid, storage: selectedStorage, mode: bulkMode, start: bulkStart, items },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
if (res.failed === 0) {
|
||||
toast.success(t('backups.bulk_restore_done', { count: res.succeeded }));
|
||||
} else {
|
||||
toast.warning(t('backups.bulk_restore_partial', { ok: res.succeeded, fail: res.failed }));
|
||||
}
|
||||
setBulkOpen(false);
|
||||
setSelectedBackups(new Map());
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || t('backups.restore_failed')),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openCreateJob = () => {
|
||||
const initialServer = sid || (servers[0]?.id ?? 0);
|
||||
setEditingJobId(null);
|
||||
@@ -356,12 +432,38 @@ export default function BackupsPage() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Bulk selection toolbar */}
|
||||
{selectedBackups.size > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t('backups.selected_count', { count: selectedBackups.size })}
|
||||
</span>
|
||||
<Button size="sm" onClick={openBulkRestore}>
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" />{t('backups.restore_selected')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setSelectedBackups(new Map())}>
|
||||
{t('common.clear', 'Clear')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup files table */}
|
||||
{backups.length > 0 ? (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4"
|
||||
checked={allVmidsSelected}
|
||||
title={t('backups.select_all_one_per_vmid')}
|
||||
onChange={() =>
|
||||
setSelectedBackups(allVmidsSelected ? new Map() : new Map(latestPerVmid))
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>VMID</TableHead>
|
||||
<TableHead>{t('backups.type')}</TableHead>
|
||||
<TableHead>{t('backups.node')}</TableHead>
|
||||
@@ -376,8 +478,17 @@ export default function BackupsPage() {
|
||||
<TableBody>
|
||||
{backups.map((b, i) => {
|
||||
const vmType = detectVmType(b.volid);
|
||||
const isSelected = selectedBackups.get(b.vmid)?.volid === b.volid;
|
||||
return (
|
||||
<TableRow key={i}>
|
||||
<TableRow key={i} data-selected={isSelected}>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleBackupSelected(b)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">{b.vmid}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="uppercase text-xs">{vmType}</Badge>
|
||||
@@ -545,6 +656,86 @@ export default function BackupsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk restore dialog */}
|
||||
<Dialog open={bulkOpen} onOpenChange={(o) => { if (!o) setBulkOpen(false); }}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('backups.bulk_restore_title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 text-sm">
|
||||
{/* Mode toggle */}
|
||||
<div>
|
||||
<Label>{t('backups.bulk_mode')}</Label>
|
||||
<div className="mt-1 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={bulkMode === 'in_place' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setBulkMode('in_place')}
|
||||
>
|
||||
{t('backups.mode_in_place')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={bulkMode === 'new_vmid' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setBulkMode('new_vmid')}
|
||||
>
|
||||
{t('backups.mode_new_vmid')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{bulkMode === 'in_place' ? t('backups.mode_in_place_hint') : t('backups.mode_new_vmid_hint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Selected items */}
|
||||
<div>
|
||||
<Label>{t('backups.selected_count', { count: selectedBackups.size })}</Label>
|
||||
<div className="mt-1 max-h-44 overflow-y-auto rounded-md border divide-y">
|
||||
{Array.from(selectedBackups.values()).map((b) => (
|
||||
<div key={b.volid} className="flex items-center justify-between gap-2 px-2.5 py-1.5">
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<Badge variant="outline" className="uppercase text-[10px] shrink-0">{detectVmType(b.volid)}</Badge>
|
||||
<span className="font-mono shrink-0">#{b.vmid}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground truncate">{b.volid}</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{new Date(b.ctime * 1000).toLocaleDateString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" className="h-4 w-4" checked={bulkStart} onChange={(e) => setBulkStart(e.target.checked)} />
|
||||
<span>{t('backups.start_after_restore')}</span>
|
||||
</label>
|
||||
|
||||
{/* In-place warning + confirm */}
|
||||
{bulkMode === 'in_place' && (
|
||||
<div className="space-y-2 rounded-md bg-destructive/10 p-3">
|
||||
<p className="text-xs">{t('backups.bulk_in_place_warning')}</p>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" className="h-4 w-4" checked={bulkConfirm} onChange={(e) => setBulkConfirm(e.target.checked)} />
|
||||
<span>{t('backups.confirm_overwrite')}</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
|
||||
<Button
|
||||
onClick={submitBulkRestore}
|
||||
disabled={bulkRestore.isPending || (bulkMode === 'in_place' && !bulkConfirm)}
|
||||
variant={bulkMode === 'in_place' ? 'destructive' : 'default'}
|
||||
>
|
||||
{bulkRestore.isPending && <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />}
|
||||
{t('backups.restore_selected')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Job create/edit dialog */}
|
||||
<Dialog open={jobDialogOpen} onOpenChange={(o) => { if (!o) setJobDialogOpen(false); }}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
|
||||
@@ -65,6 +65,24 @@ export function useRestoreBackup() {
|
||||
});
|
||||
}
|
||||
|
||||
export type BulkRestoreItem = { node: string; vmid: number; archive: string; vm_type: string };
|
||||
export type BulkRestoreResult = {
|
||||
success: boolean;
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
results: { vmid: number; target_vmid?: number; archive: string; success: boolean; upid?: string; error?: string }[];
|
||||
};
|
||||
|
||||
export function useBulkRestoreBackup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { server_id: number; storage: string; mode: 'in_place' | 'new_vmid'; start: boolean; items: BulkRestoreItem[] }) =>
|
||||
apiClient.post<BulkRestoreResult>('/proxmox/api/backups/restore-bulk', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['backups'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export type BackupJobInput = {
|
||||
server_id: number;
|
||||
node: string;
|
||||
|
||||
Reference in New Issue
Block a user