feat(backups): restore target node/storage selection + task progress tracking
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:
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'] }),
|
||||
});
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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": "Файл бэкапа будет удалён без возможности восстановления. Продолжить?",
|
||||
|
||||
Reference in New Issue
Block a user