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)
|
||||
|
||||
Reference in New Issue
Block a user