feat: Clone (qm clone) + Copy (re-convert) buttons on completion

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()
This commit is contained in:
Claus Lohmar 2026-07-23 09:02:57 +00:00
parent 7e1303a217
commit 9eb0f15261
7 changed files with 161 additions and 60 deletions

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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

View file

@ -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)

View file

@ -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
# ------------------------------------------------------------------

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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 = '<div class="spinner"></div> 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 = '<div class="spinner"></div> 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 = '<div class="success">VM ' + newVmid + ' cloned successfully!</div>';
} else {
status.innerHTML = '<div class="error">Clone failed: ' + (data.error_details || 'Unknown') + '</div>';
}
} catch (err) {
status.innerHTML = '<div class="error">Failed to fetch job data.</div>';
return;
status.innerHTML = '<div class="error">Clone failed: ' + err.message + '</div>';
}
}
// 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 = '<div class="spinner"></div> 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 = '<div class="error">Failed: ' + err.message + '</div>';
}
}
}
</script>
{% endblock %}

View file

@ -19,35 +19,55 @@
<div id="error-block" class="error hidden"></div>
</div>
<!-- Reuse: ask whether to create another VM -->
<!-- Reuse section -->
<div id="reuse-section" class="panel hidden">
<h2>Conversion Complete</h2>
<p>Use the same source image to create another VM?</p>
<div class="btn-row">
<button id="btn-reuse-yes" class="btn">Yes — Create Another VM</button>
<button id="btn-reuse-no" class="btn btn-secondary">No — Clean Up &amp; Finish</button>
<h2>Conversion Complete — VM {{ vmid }} Ready</h2>
<p style="margin-bottom:1rem;">Create another VM from this disk?</p>
<div class="row">
<div class="form-group">
<label>VM Name *</label>
<input type="text" id="reuse-vmname" name="vm_name" required
placeholder="e.g. second-vm">
</div>
<div class="form-group">
<label>VM ID *</label>
<input type="number" id="reuse-vmid" name="vmid" required
min="21000" max="21100" placeholder="21051">
</div>
</div>
<div id="reuse-form-wrap" class="hidden" style="margin-top:1rem;">
<hr style="border-color:var(--border); margin-bottom:1rem;">
<form id="reuse-form" onsubmit="submitReuse(event)">
<div class="row">
<div class="form-group">
<label>VM Name *</label>
<input type="text" id="reuse-vmname" name="vm_name" required
placeholder="e.g. second-vm">
</div>
<div class="form-group">
<label>VM ID *</label>
<input type="number" id="reuse-vmid" name="vmid" required
min="21000" max="21100" placeholder="21051">
</div>
<div class="btn-row" style="flex-wrap:wrap;">
<button id="btn-clone" class="btn" style="background:var(--green);">Clone (instant)</button>
<button id="btn-copy" class="btn">Copy (re-convert)</button>
<button id="btn-cleanup" class="btn btn-secondary">Clean Up &amp; Finish</button>
</div>
<div id="reuse-submit-status" class="hidden" style="margin-top:0.5rem;"></div>
<!-- Copy options (hidden until Copy clicked) -->
<div id="copy-options" class="hidden" style="margin-top:1rem; padding-top:1rem; border-top:1px solid var(--border);">
<p class="dim" style="margin-bottom:0.75rem;">Copy re-runs the conversion with new specs:</p>
<div class="row">
<div class="form-group">
<label>CPU Cores</label>
<input type="number" id="copy-cores" value="2" min="1">
</div>
<p class="dim" style="font-size:0.8rem;">
Same disk image, format, and boot settings as the first VM.
</p>
<button type="submit" class="btn" style="margin-top:0.5rem;">Start Conversion</button>
</form>
<div id="reuse-submit-status" class="hidden" style="margin-top:1rem;"></div>
<div class="form-group">
<label>RAM (MB)</label>
<input type="number" id="copy-ram" value="4096" min="512">
</div>
</div>
<div class="form-group">
<label>Target Disk Size (GB)</label>
<input type="number" id="copy-disk" value="30" placeholder="Leave blank for auto-shrink">
<span class="hint">Blank = auto-shrink to 30 GB.</span>
</div>
<div class="form-group">
<label>Storage Pool</label>
<input type="text" id="copy-storage" value="local-lvm">
</div>
<button id="btn-copy-start" class="btn" style="margin-top:0.5rem;">Start Re-Conversion</button>
<div id="copy-status" class="hidden" style="margin-top:0.5rem;"></div>
</div>
</div>
</div>