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)