feat(backups): restore target node/storage selection + task progress tracking
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 1m6s
Deploy to Production / Trigger production update (push) Successful in 8s

Add target node and storage overrides to single and bulk restore flows, and
track Proxmox task progress (parse_task_progress in the monitoring worker,
new ProxmoxTask progress column + migration, enriched task API responses).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:26:01 +05:00
parent fbf6d174b6
commit 681aa2f303
10 changed files with 234 additions and 25 deletions

View File

@@ -19,6 +19,7 @@ from ...auth import PermissionChecker
from ...logging_service import LoggingService
from ._helpers import _get_proxmox_client
from ...proxmox import _run_in_executor
from .tasks import ProxmoxTaskService
router = APIRouter()
@@ -322,16 +323,18 @@ async def restore_backup(
node = data.get("node")
vmid = data.get("vmid")
archive = data.get("archive") # volume id
storage = data.get("storage")
# Target storage for the restored disks. Empty => "from backup configuration"
# (disks go to the storage recorded in the backup). NOT the backup storage.
storage = data.get("storage") or None
vm_type = data.get("vm_type", "qemu")
new_vmid = data.get("new_vmid")
start = bool(data.get("start", False))
unique = bool(data.get("unique", True))
if not all([server_id, node, vmid, archive, storage]):
if not all([server_id, node, vmid, archive]):
raise HTTPException(
status_code=400,
detail="server_id, node, vmid, archive, storage are required",
detail="server_id, node, vmid, archive are required",
)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -356,6 +359,21 @@ async def restore_backup(
)
if result.get("success"):
target_vmid = int(new_vmid) if new_vmid else int(vmid)
upid = result.get("upid")
# Track the restore as a Proxmox UPID task so it shows up on the Tasks
# page and the poller can report live progress / final status.
if upid:
try:
ptask = ProxmoxTaskService.register(
db=db, upid=upid, server_id=server_id, user_id=current_user.id,
action="backup_restore", node=node, vmid=target_vmid,
vm_type=vm_type, description=f"Восстановление {vm_type.upper()} {target_vmid}",
)
ptask.progress = 0
db.commit()
except Exception as _te:
logger.warning(f"Could not register restore task: {_te}")
LoggingService.log_proxmox_action(
db=db, action="backup_restore", resource_type="backup",
resource_id=str(vmid), username=current_user.username,
@@ -363,7 +381,7 @@ async def restore_backup(
node_name=node, success=True,
details={"archive": archive, "storage": storage, "vm_type": vm_type},
)
return JSONResponse(content={"success": True, "upid": result.get("upid")})
return JSONResponse(content={"success": True, "upid": upid})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
@@ -389,13 +407,14 @@ async def restore_backup_bulk(
"""
data = await request.json()
server_id = data.get("server_id")
storage = data.get("storage")
# Target storage for restored disks. Empty => "from backup configuration".
storage = data.get("storage") or None
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 not server_id or not items:
raise HTTPException(status_code=400, detail="server_id 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'")
@@ -466,6 +485,18 @@ async def restore_backup_bulk(
if r.get("success"):
results.append({"vmid": vmid, "target_vmid": target, "archive": archive,
"success": True, "upid": r.get("upid")})
upid = r.get("upid")
if upid:
try:
ptask = ProxmoxTaskService.register(
db=db, upid=upid, server_id=server_id, user_id=current_user.id,
action="backup_restore", node=node, vmid=target,
vm_type=vm_type, description=f"Восстановление {vm_type.upper()} {target}",
)
ptask.progress = 0
db.commit()
except Exception as _te:
logger.warning(f"Could not register bulk restore task: {_te}")
LoggingService.log_proxmox_action(
db=db, action="backup_restore", resource_type="backup",
resource_id=str(target), username=current_user.username,

View File

@@ -223,8 +223,28 @@ def get_all_tasks(
proxmox_tasks = proxmox_q.order_by(ProxmoxTask.created_at.desc()).limit(limit).all()
deploy_tasks = deploy_q.order_by(DeployTask.created_at.desc()).limit(limit).all()
# Resolve server_id -> name once for enrichment (proxmox/deploy tasks carry server_id).
server_ids = {t.server_id for t in proxmox_tasks if t.server_id} | {t.server_id for t in deploy_tasks if t.server_id}
server_names = {}
if server_ids:
for sid, sname in db.query(ProxmoxServer.id, ProxmoxServer.name).filter(ProxmoxServer.id.in_(server_ids)).all():
server_names[sid] = sname
def _enrich(d: dict) -> dict:
# All listed tasks belong to the current user.
if not d.get("user"):
d["user"] = current_user.username
if d.get("server_id") and not d.get("server_name"):
d["server_name"] = server_names.get(d["server_id"])
# Тип column reads `type` first; fall back to action/task_type/kind.
if not d.get("type"):
d["type"] = d.get("action") or d.get("task_type") or d.get("kind")
return d
all_tasks = sorted(
[t.to_dict() for t in bulk_tasks] + [t.to_dict() for t in proxmox_tasks] + [t.to_dict() for t in deploy_tasks],
[_enrich(t.to_dict()) for t in bulk_tasks]
+ [_enrich(t.to_dict()) for t in proxmox_tasks]
+ [_enrich(t.to_dict()) for t in deploy_tasks],
key=lambda t: t.get("created_at") or "",
reverse=True,
)[:limit]

View File

@@ -79,6 +79,9 @@ class ProxmoxTask(Base):
status = Column(String(20), nullable=False, default="running", index=True)
exit_status = Column(String(100), nullable=True)
log_text = Column(Text, nullable=True)
# 0-100 percentage parsed from the task log (e.g. qmrestore "progress N%").
# NULL when the task type does not report progress.
progress = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False, index=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
@@ -97,6 +100,7 @@ class ProxmoxTask(Base):
'status': self.status,
'exit_status': self.exit_status,
'log_text': self.log_text,
'progress': self.progress,
'created_at': self.created_at.isoformat() if self.created_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
}

View File

@@ -2394,12 +2394,16 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
logger.error(f"Error creating backup for VM {vmid} on {node}: {e}")
return {"success": False, "error": str(e)}
def restore_vm(self, node: str, vmid: int, archive: str, storage: str,
def restore_vm(self, node: str, vmid: int, archive: str, storage: str = None,
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-...'
storage: TARGET storage for the restored disks. When empty/None, Proxmox
restores each disk to the storage recorded in the backup config
("from backup configuration"). The backup source is encoded in
`archive`, so this must NOT be the backup storage.
force: overwrite an existing VM with the same vmid (must be stopped).
"""
if not self.proxmox:
@@ -2410,10 +2414,11 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
params = {
"vmid": new_vmid if new_vmid else vmid,
"archive": archive,
"storage": storage,
"start": 1 if start else 0,
"unique": 1 if unique else 0,
}
if storage:
params["storage"] = storage
if force:
params["force"] = 1
upid = self.proxmox.nodes(node).qemu.post(**params)
@@ -2422,11 +2427,14 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
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,
def restore_lxc(self, node: str, vmid: int, archive: str, storage: str = None,
new_vmid: int = None, start: bool = False,
force: bool = False) -> Dict:
"""Restore an LXC container from backup.
storage: TARGET storage for the restored rootfs. When empty/None, Proxmox
restores to the storage recorded in the backup config. The backup
source is encoded in `archive`, so this must NOT be the backup storage.
force: overwrite an existing container with the same vmid (must be stopped).
"""
if not self.proxmox:
@@ -2435,10 +2443,11 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
params = {
"vmid": new_vmid if new_vmid else vmid,
"ostemplate": archive,
"storage": storage,
"restore": 1,
"start": 1 if start else 0,
}
if storage:
params["storage"] = storage
if force:
params["force"] = 1
upid = self.proxmox.nodes(node).lxc.post(**params)

View File

@@ -4,14 +4,35 @@ Uses APScheduler for periodic tasks
"""
import asyncio
import re
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, List
from typing import Dict, Any, List, Optional
from loguru import logger
from ..config import utcnow
# qmrestore / vma extract emit lines like "progress 47% (read ...)".
# Some restores instead print bare "47% ..." markers. Capture either form.
_PROGRESS_RE = re.compile(r"(?:progress\s+)?(\d{1,3})\s*%")
def parse_task_progress(log_text: Optional[str]) -> Optional[int]:
"""Extract the latest restore/backup progress percentage from a task log.
Returns the last percentage seen (0-100), or None when the log has none.
"""
if not log_text:
return None
matches = _PROGRESS_RE.findall(log_text)
if not matches:
return None
# The log is appended in order, so the last match is the most recent progress.
pct = int(matches[-1])
return max(0, min(100, pct))
try:
from backend.app.db import SessionLocal
from backend.app.models import User, ProxmoxServer, PanelSettings, VMInstance, IPAMAllocation, Notification, ProxmoxTask
@@ -968,6 +989,10 @@ class MonitoringWorker:
entry.get("t", "") if isinstance(entry, dict) else str(entry)
for entry in log_entries
)
# Extract a live progress % from the log (qmrestore etc.)
parsed = parse_task_progress(task.log_text)
if parsed is not None:
task.progress = parsed
except Exception:
pass
@@ -977,6 +1002,10 @@ class MonitoringWorker:
task.exit_status = exit_status
task.status = "completed" if exit_status == "OK" else "failed"
task.completed_at = utcnow()
# A successfully finished task is 100% done even if the last
# log line didn't print a percentage.
if exit_status == "OK" and task.progress is not None:
task.progress = 100
logger.info(f"[UPID SYNC] Task #{task.id} finished: {task.status} ({exit_status})")
db.commit()

View File

@@ -1354,6 +1354,18 @@ def migrate_user_ssh_keys_table(conn):
logger.info("✓ user_ssh_keys table created")
def migrate_proxmox_task_progress(conn):
"""Migration 25: Add 'progress' column to proxmox_tasks (0-100, parsed from log)."""
if not table_exists(conn, 'proxmox_tasks'):
logger.info("Table proxmox_tasks does not exist, skipping progress migration")
return
if add_column_if_not_exists(conn, 'proxmox_tasks', 'progress', 'INTEGER'):
logger.info("✓ Added progress column to proxmox_tasks")
else:
logger.info("✓ proxmox_tasks.progress already exists")
def run_all_migrations(engine, db_session=None):
"""
Run all migrations in order.
@@ -1560,6 +1572,14 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"User SSH keys table migration: {e}")
conn.rollback()
# Migration 25: proxmox_tasks.progress column
try:
migrate_proxmox_task_progress(conn)
conn.commit()
except Exception as e:
logger.warning(f"Proxmox task progress migration: {e}")
conn.rollback()
logger.info("=" * 50)
logger.info("✓ All migrations completed successfully")
logger.info("=" * 50)

View File

@@ -30,6 +30,9 @@ import { formatBytes } from '@/lib/format';
import { toast } from 'sonner';
const ALL_NODES = '__all__';
// Sentinel for the target-storage selects: restore disks to the storage recorded
// in the backup config (i.e. omit the `storage` param), rather than a chosen one.
const FROM_BACKUP = '__from_backup__';
type BackupItem = {
volid: string;
@@ -126,6 +129,8 @@ export default function BackupsPage() {
// --- Restore dialog state ---
const [restoreTarget, setRestoreTarget] = useState<BackupItem | null>(null);
const [restoreTargetNode, setRestoreTargetNode] = useState('');
const [restoreTargetStorage, setRestoreTargetStorage] = useState(FROM_BACKUP);
const [restoreNewVmid, setRestoreNewVmid] = useState('');
const [restoreStart, setRestoreStart] = useState(false);
const [restoreUnique, setRestoreUnique] = useState(true);
@@ -137,14 +142,32 @@ export default function BackupsPage() {
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 [bulkTargetNode, setBulkTargetNode] = useState('__source__');
const [bulkTargetStorage, setBulkTargetStorage] = useState(FROM_BACKUP);
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 }[];
return list.filter(s => (s.content || '').split(',').includes('backup'));
}, [storagesData]);
const allStorages = useMemo(
() => (storagesData?.storages || []) as { storage: string; type: string; content: string; nodes?: string; used?: number; total?: number }[],
[storagesData],
);
const storages = useMemo(
() => allStorages.filter(s => (s.content || '').split(',').includes('backup')),
[allStorages],
);
// Storages that can hold restored guest disks on a given node.
// qemu => content 'images', lxc => 'rootdir', 'any' => either (bulk, mixed types).
const diskStoragesFor = (node: string, vmType: 'qemu' | 'lxc' | 'any') => {
const needed = vmType === 'lxc' ? ['rootdir'] : vmType === 'qemu' ? ['images'] : ['images', 'rootdir'];
return allStorages.filter(s => {
const contents = (s.content || '').split(',');
if (!needed.some(c => contents.includes(c))) return false;
// `nodes` empty/absent => storage is available on all nodes.
if (node && s.nodes) return s.nodes.split(',').includes(node);
return true;
});
};
const backups = (backupsData?.backups || []) as BackupItem[];
const jobs = (jobsData?.jobs || []) as BackupJob[];
@@ -209,6 +232,8 @@ export default function BackupsPage() {
const openRestore = (b: BackupItem) => {
setRestoreTarget(b);
setRestoreTargetNode(b.node || (selectedNode === ALL_NODES ? '' : selectedNode) || nodes[0]?.node || '');
setRestoreTargetStorage(FROM_BACKUP);
setRestoreNewVmid('');
setRestoreStart(false);
setRestoreUnique(true);
@@ -216,7 +241,7 @@ export default function BackupsPage() {
const submitRestore = () => {
if (!restoreTarget || !sid) return;
const node = restoreTarget.node || (selectedNode === ALL_NODES ? '' : selectedNode) || nodes[0]?.node;
const node = restoreTargetNode;
if (!node) {
toast.error(t('backups.node_required'));
return;
@@ -233,7 +258,7 @@ export default function BackupsPage() {
node,
vmid: newVmidNum ?? restoreTarget.vmid,
archive: restoreTarget.volid,
storage: selectedStorage,
...(restoreTargetStorage !== FROM_BACKUP ? { storage: restoreTargetStorage } : {}),
vm_type: vmType,
new_vmid: newVmidNum,
start: restoreStart,
@@ -271,6 +296,8 @@ export default function BackupsPage() {
const openBulkRestore = () => {
if (selectedBackups.size === 0) return;
setBulkMode('in_place');
setBulkTargetNode('__source__');
setBulkTargetStorage(FROM_BACKUP);
setBulkStart(false);
setBulkConfirm(false);
setBulkOpen(true);
@@ -278,8 +305,9 @@ export default function BackupsPage() {
const submitBulkRestore = () => {
if (!sid || selectedBackups.size === 0) return;
const overrideNode = bulkTargetNode === '__source__' ? '' : bulkTargetNode;
const items = Array.from(selectedBackups.values()).map(b => ({
node: b.node || (selectedNode === ALL_NODES ? '' : selectedNode) || nodes[0]?.node || '',
node: overrideNode || b.node || (selectedNode === ALL_NODES ? '' : selectedNode) || nodes[0]?.node || '',
vmid: b.vmid,
archive: b.volid,
vm_type: detectVmType(b.volid),
@@ -289,7 +317,13 @@ export default function BackupsPage() {
return;
}
bulkRestore.mutate(
{ server_id: sid, storage: selectedStorage, mode: bulkMode, start: bulkStart, items },
{
server_id: sid,
...(bulkTargetStorage !== FROM_BACKUP ? { storage: bulkTargetStorage } : {}),
mode: bulkMode,
start: bulkStart,
items,
},
{
onSuccess: (res) => {
if (res.failed === 0) {
@@ -614,6 +648,28 @@ export default function BackupsPage() {
<div><span className="text-muted-foreground">{t('backups.type')}:</span> <Badge variant="outline" className="uppercase ml-1">{detectVmType(restoreTarget.volid)}</Badge></div>
<div><span className="text-muted-foreground">{t('backups.size')}:</span> {formatBytes(restoreTarget.size)}</div>
</div>
<div>
<Label>{t('backups.restore_target_node')}</Label>
<Select value={restoreTargetNode} onValueChange={v => { if (v !== null) setRestoreTargetNode(v); }}>
<SelectTrigger className="mt-1 w-full"><SelectValue /></SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{nodes.map(n => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('backups.restore_target_storage')}</Label>
<Select value={restoreTargetStorage} onValueChange={v => { if (v !== null) setRestoreTargetStorage(v); }}>
<SelectTrigger className="mt-1 w-full"><SelectValue /></SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectItem value={FROM_BACKUP}>{t('backups.storage_from_backup')}</SelectItem>
{diskStoragesFor(restoreTargetNode, detectVmType(restoreTarget.volid)).map(s => (
<SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">{t('backups.target_storage_hint')}</p>
</div>
<div>
<Label>{t('backups.new_vmid_optional')}</Label>
<Input
@@ -662,7 +718,7 @@ export default function BackupsPage() {
<DialogHeader>
<DialogTitle>{t('backups.bulk_restore_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 text-sm">
<div className="min-w-0 space-y-4 text-sm">
{/* Mode toggle */}
<div>
<Label>{t('backups.bulk_mode')}</Label>
@@ -692,7 +748,7 @@ export default function BackupsPage() {
{/* 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">
<div className="mt-1 max-h-44 overflow-y-auto overflow-x-hidden 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">
@@ -706,6 +762,32 @@ export default function BackupsPage() {
</div>
</div>
<div>
<Label>{t('backups.bulk_target_node')}</Label>
<Select value={bulkTargetNode} onValueChange={v => setBulkTargetNode(v)}>
<SelectTrigger className="mt-1 w-full"><SelectValue /></SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectItem value="__source__">— {t('backups.bulk_target_node_source')}</SelectItem>
{nodes.map(n => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">{t('backups.bulk_target_node_hint')}</p>
</div>
<div>
<Label>{t('backups.restore_target_storage')}</Label>
<Select value={bulkTargetStorage} onValueChange={v => setBulkTargetStorage(v)}>
<SelectTrigger className="mt-1 w-full"><SelectValue /></SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectItem value={FROM_BACKUP}>{t('backups.storage_from_backup')}</SelectItem>
{diskStoragesFor(bulkTargetNode === '__source__' ? '' : bulkTargetNode, 'any').map(s => (
<SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">{t('backups.target_storage_hint')}</p>
</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>

View File

@@ -60,7 +60,7 @@ export function useDeleteBackup() {
export function useRestoreBackup() {
return useMutation({
mutationFn: (data: { server_id: number; node: string; vmid: number; archive: string; storage: string; vm_type: string; new_vmid?: number; start?: boolean }) =>
mutationFn: (data: { server_id: number; node: string; vmid: number; archive: string; storage?: string; vm_type: string; new_vmid?: number; start?: boolean; unique?: boolean }) =>
apiClient.post<{ success: boolean; upid: string }>('/proxmox/api/backups/restore', data),
});
}
@@ -77,7 +77,7 @@ export type BulkRestoreResult = {
export function useBulkRestoreBackup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { server_id: number; storage: string; mode: 'in_place' | 'new_vmid'; start: boolean; items: BulkRestoreItem[] }) =>
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'] }),
});

View File

@@ -380,6 +380,13 @@
"bulk_restore_done": "Restore started for {{count}} backup(s)",
"bulk_restore_partial": "Started: {{ok}}, failed: {{fail}}",
"node_required": "Could not determine node for the operation",
"restore_target_node": "Restore to node",
"restore_target_storage": "Target storage",
"storage_from_backup": "From backup configuration",
"target_storage_hint": "Where to place the restored disks. \"From backup configuration\" keeps the original storage — pick another when restoring to a node that lacks it.",
"bulk_target_node": "Target node (override)",
"bulk_target_node_source": "Use each backup's source node",
"bulk_target_node_hint": "Leave empty to restore each backup to its original node",
"invalid_vmid": "Invalid VMID",
"delete_title": "Delete backup",
"delete_confirm": "The backup file will be permanently deleted. Continue?",

View File

@@ -380,6 +380,13 @@
"bulk_restore_done": "Восстановление запущено для {{count}} бэкап(ов)",
"bulk_restore_partial": "Запущено: {{ok}}, с ошибкой: {{fail}}",
"node_required": "Не удалось определить узел для операции",
"restore_target_node": "Восстановить на узел",
"restore_target_storage": "Целевое хранилище",
"storage_from_backup": "Из конфигурации бэкапа",
"target_storage_hint": "Куда положить восстановленные диски. «Из конфигурации бэкапа» сохраняет исходное хранилище — выберите другое, если восстанавливаете на узел, где его нет.",
"bulk_target_node": "Целевой узел (переопределить)",
"bulk_target_node_source": "Исходный узел каждого бэкапа",
"bulk_target_node_hint": "Оставьте по умолчанию, чтобы каждый бэкап восстанавливался на свой исходный узел",
"invalid_vmid": "Некорректный VMID",
"delete_title": "Удалить бэкап",
"delete_confirm": "Файл бэкапа будет удалён без возможности восстановления. Продолжить?",