From 9eb0f15261bf354af4a13616c8b68a4299afddbe Mon Sep 17 00:00:00 2001 From: Claus Lohmar Date: Thu, 23 Jul 2026 09:02:57 +0000 Subject: [PATCH] feat: Clone (qm clone) + Copy (re-convert) buttons on completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After conversion completes, the user sees a form with VM Name and ID prefilled, and three buttons: - Clone (instant): qm clone --full via backend, takes seconds - Copy (re-convert): shows CPU/RAM/Disk fields, re-runs full pipeline - Clean Up & Finish: deletes staging, returns to start page Backend: new /api/v1/clone endpoint → provisioner.clone_vm() Frontend: new /session/clone proxy, api_client.clone_vm() --- backend/app.py | 14 +++++- backend/models.py | 5 +++ backend/provisioner.py | 29 ++++++++++++ frontend/api_client.py | 8 ++++ frontend/app.py | 15 +++++++ frontend/templates/index.html | 78 +++++++++++++++++++-------------- frontend/templates/polling.html | 72 +++++++++++++++++++----------- 7 files changed, 161 insertions(+), 60 deletions(-) diff --git a/backend/app.py b/backend/app.py index 3ccbb56..a751001 100644 --- a/backend/app.py +++ b/backend/app.py @@ -22,11 +22,12 @@ from models import ( CleanupResponse, AnalyzeRequest, AnalyzeResponse, + CloneRequest, HealthResponse, ErrorResponse, ) from converter import extract_if_needed, discover_disk, detect_os, detect_efi -from provisioner import submit_job, get_job_status, cleanup_staging +from provisioner import submit_job, get_job_status, cleanup_staging, clone_vm # --------------------------------------------------------------------------- # Logging @@ -170,6 +171,17 @@ def cleanup_job(job_id: str, req: CleanupRequest) -> CleanupResponse: ) +@app.post(f"{API_PREFIX}/clone", response_model=JobStatusResponse, status_code=202) +def clone_vm_endpoint(req: CloneRequest) -> JobStatusResponse: + """Clone an existing VM with a new ID and name.""" + logger.info("Clone request: %d → %d (%s)", req.source_vmid, req.target_vmid, req.target_name) + try: + return clone_vm(req) + except Exception as exc: + logger.error("Clone failed: %s", exc) + raise HTTPException(status_code=400, detail=str(exc)) + + # --------------------------------------------------------------------------- # Run # --------------------------------------------------------------------------- diff --git a/backend/models.py b/backend/models.py index 9e6dfff..744c614 100644 --- a/backend/models.py +++ b/backend/models.py @@ -110,3 +110,8 @@ class CleanupResponse(BaseModel): job_id: str action_taken: str # "purged" | "retained" message: str + +class CloneRequest(BaseModel): + source_vmid: int + target_vmid: int + target_name: str diff --git a/backend/provisioner.py b/backend/provisioner.py index f413308..d983329 100644 --- a/backend/provisioner.py +++ b/backend/provisioner.py @@ -21,6 +21,7 @@ from typing import Optional from models import ( JobSubmissionRequest, JobStatusResponse, + CloneRequest, JobStatus, DiskType, BootType, @@ -88,6 +89,34 @@ def get_job_status(job_id: str) -> JobStatusResponse: ) +def clone_vm(req: CloneRequest) -> JobStatusResponse: + """qm clone — instant full clone of an existing VM.""" + safe_name = re.sub(r'[^a-zA-Z0-9-]', '-', req.target_name).strip('-').lower() + if not safe_name: + safe_name = f"vm-{req.target_vmid}" + + job_id = f"clone_{req.source_vmid}_{req.target_vmid}_{int(time.time())}" + logger.info("Cloning VM %d → %d (%s)", req.source_vmid, req.target_vmid, safe_name) + + try: + _run(["qm", "clone", str(req.source_vmid), str(req.target_vmid), + "--name", safe_name, "--full"], timeout=600) + except Exception as exc: + logger.error("Clone failed: %s", exc) + return JobStatusResponse( + job_id=job_id, vmid=req.target_vmid, + status=JobStatus.FAILED, progress_percentage=0, + message="Clone failed", error_details=str(exc), + ) + + logger.info("Clone complete: %d → %d", req.source_vmid, req.target_vmid) + return JobStatusResponse( + job_id=job_id, vmid=req.target_vmid, + status=JobStatus.COMPLETED, progress_percentage=100, + message=f"VM {req.target_vmid} cloned from {req.source_vmid}", + ) + + def cleanup_staging(vmid: int, delete: bool) -> dict: """Remove staging files for a VM ID.""" out_dir = STAGING_OUT / str(vmid) diff --git a/frontend/api_client.py b/frontend/api_client.py index 4425bd5..91a1930 100644 --- a/frontend/api_client.py +++ b/frontend/api_client.py @@ -63,6 +63,14 @@ class ApiClient: """POST /api/v1/jobs/{job_id}/cleanup — remove or keep staging files.""" return self._post(f"/api/v1/jobs/{job_id}/cleanup", {"delete_staging_files": delete}) + def clone_vm(self, source_vmid: int, target_vmid: int, target_name: str) -> dict: + """POST /api/v1/clone — clone an existing VM.""" + return self._post("/api/v1/clone", { + "source_vmid": source_vmid, + "target_vmid": target_vmid, + "target_name": target_name, + }) + # ------------------------------------------------------------------ # Internal # ------------------------------------------------------------------ diff --git a/frontend/app.py b/frontend/app.py index b610df8..9746886 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -500,6 +500,21 @@ async def session_cleanup(job_id: str, request: Request): return JSONResponse({"error": exc.detail}, status_code=502) +@app.post("/session/clone") +async def session_clone(request: Request): + """Proxy clone request to backend.""" + try: + body = await request.json() + result = api.clone_vm( + source_vmid=body["source_vmid"], + target_vmid=body["target_vmid"], + target_name=body["target_name"], + ) + return result + except ApiError as exc: + return JSONResponse({"error": exc.detail}, status_code=502) + + # --------------------------------------------------------------------------- # Run # --------------------------------------------------------------------------- diff --git a/frontend/templates/index.html b/frontend/templates/index.html index 485177e..be5489f 100644 --- a/frontend/templates/index.html +++ b/frontend/templates/index.html @@ -295,10 +295,18 @@ function initPolling() { let completed = false; // Reuse buttons - document.getElementById('btn-reuse-yes').onclick = () => { - document.getElementById('reuse-form-wrap').classList.remove('hidden'); + const prevVmid = parseInt(vmid) || 21000; + const srcFilename = sourceFilename || ''; + + document.getElementById('reuse-vmid').value = prevVmid + 1; + document.getElementById('reuse-vmname').value = (srcFilename.split('.')[0] || 'vm') + '-2'; + + document.getElementById('btn-clone').onclick = () => cloneVM(jobId, vmid); + document.getElementById('btn-copy').onclick = () => { + document.getElementById('copy-options').classList.remove('hidden'); }; - document.getElementById('btn-reuse-no').onclick = () => reuseImage(jobId, vmid, sourceFilename, false); + document.getElementById('btn-cleanup').onclick = () => reuseImage(jobId, vmid, sourceFilename, false); + document.getElementById('btn-copy-start').onclick = () => copyVM(jobId, vmid, sourceFilename); async function poll() { try { @@ -356,52 +364,54 @@ async function reuseImage(jobId, vmid, sourceFilename, keep) { try { await fetch('/session/cleanup/' + jobId, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ delete_staging_files: !keep }), + body: JSON.stringify({ delete_staging_files: true }), }); - if (keep) { - window.location.href = '/?vmid=' + vmid + '&source=' + encodeURIComponent(sourceFilename); - } else { - window.location.href = '/'; - } + window.location.href = '/'; } catch (err) { alert('Cleanup failed: ' + err.message); } } -async function submitReuse(e) { - e.preventDefault(); - const newVmid = document.getElementById('reuse-vmid').value; - const newVmName = document.getElementById('reuse-vmname').value.trim(); +async function cloneVM(jobId, sourceVmid) { + const newVmid = parseInt(document.getElementById('reuse-vmid').value) || 0; + const newName = document.getElementById('reuse-vmname').value.trim(); + if (!newVmid || !newName) { alert('Please fill VM Name and ID.'); return; } const status = document.getElementById('reuse-submit-status'); status.classList.remove('hidden'); - status.innerHTML = '
Submitting job...'; - - // Get original job data from the container - const container = document.getElementById('polling-container'); - const sourceFilename = container.dataset.sourceFilename; - const jobId = container.dataset.jobId; - - // Get the analysis data from the completed job's backend - let analysis; + status.innerHTML = '
Cloning VM...'; try { - const resp = await fetch('/session/status/' + jobId); - analysis = await resp.json(); + const resp = await fetch('/session/clone', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_vmid: sourceVmid, target_vmid: newVmid, target_name: newName }), + }); + const data = await resp.json(); + if (data.status === 'completed') { + status.innerHTML = '
VM ' + newVmid + ' cloned successfully!
'; + } else { + status.innerHTML = '
Clone failed: ' + (data.error_details || 'Unknown') + '
'; + } } catch (err) { - status.innerHTML = '
Failed to fetch job data.
'; - return; + status.innerHTML = '
Clone failed: ' + err.message + '
'; } +} - // Build payload using same disk settings as first VM +async function copyVM(jobId, vmid, sourceFilename) { + const newVmid = parseInt(document.getElementById('reuse-vmid').value) || 0; + const newName = document.getElementById('reuse-vmname').value.trim(); + if (!newVmid || !newName) { alert('Please fill VM Name and ID.'); return; } + const status = document.getElementById('copy-status'); + status.classList.remove('hidden'); + status.innerHTML = '
Submitting job...'; const payload = new FormData(); payload.append('vmid', newVmid); - payload.append('vm_name', newVmName); + payload.append('vm_name', newName); payload.append('source_filename', sourceFilename); - // Re-use disk format from original job or default payload.append('disk_format', 'vmdk'); - payload.append('cpu_cores', '2'); - payload.append('ram_mb', '4096'); - payload.append('target_storage', 'local-lvm'); + payload.append('cpu_cores', document.getElementById('copy-cores').value || '2'); + payload.append('ram_mb', document.getElementById('copy-ram').value || '4096'); + payload.append('target_storage', document.getElementById('copy-storage').value || 'local-lvm'); + const diskGb = document.getElementById('copy-disk').value; + if (diskGb) payload.append('target_disk_size_gb', diskGb); payload.append('auto_detect_boot', 'true'); payload.append('boot_type', 'uefi'); - try { const resp = await fetch('/session/confirm', { method: 'POST', body: payload }); const html = await resp.text(); @@ -411,6 +421,8 @@ async function submitReuse(e) { status.innerHTML = '
Failed: ' + err.message + '
'; } } + +} {% endblock %} diff --git a/frontend/templates/polling.html b/frontend/templates/polling.html index a1f05e6..2959eda 100644 --- a/frontend/templates/polling.html +++ b/frontend/templates/polling.html @@ -19,35 +19,55 @@ - +