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:
parent
7e1303a217
commit
9eb0f15261
7 changed files with 161 additions and 60 deletions
|
|
@ -22,11 +22,12 @@ from models import (
|
||||||
CleanupResponse,
|
CleanupResponse,
|
||||||
AnalyzeRequest,
|
AnalyzeRequest,
|
||||||
AnalyzeResponse,
|
AnalyzeResponse,
|
||||||
|
CloneRequest,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
)
|
)
|
||||||
from converter import extract_if_needed, discover_disk, detect_os, detect_efi
|
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
|
# 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
|
# Run
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -110,3 +110,8 @@ class CleanupResponse(BaseModel):
|
||||||
job_id: str
|
job_id: str
|
||||||
action_taken: str # "purged" | "retained"
|
action_taken: str # "purged" | "retained"
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
class CloneRequest(BaseModel):
|
||||||
|
source_vmid: int
|
||||||
|
target_vmid: int
|
||||||
|
target_name: str
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from typing import Optional
|
||||||
from models import (
|
from models import (
|
||||||
JobSubmissionRequest,
|
JobSubmissionRequest,
|
||||||
JobStatusResponse,
|
JobStatusResponse,
|
||||||
|
CloneRequest,
|
||||||
JobStatus,
|
JobStatus,
|
||||||
DiskType,
|
DiskType,
|
||||||
BootType,
|
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:
|
def cleanup_staging(vmid: int, delete: bool) -> dict:
|
||||||
"""Remove staging files for a VM ID."""
|
"""Remove staging files for a VM ID."""
|
||||||
out_dir = STAGING_OUT / str(vmid)
|
out_dir = STAGING_OUT / str(vmid)
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,14 @@ class ApiClient:
|
||||||
"""POST /api/v1/jobs/{job_id}/cleanup — remove or keep staging files."""
|
"""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})
|
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
|
# Internal
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -500,6 +500,21 @@ async def session_cleanup(job_id: str, request: Request):
|
||||||
return JSONResponse({"error": exc.detail}, status_code=502)
|
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
|
# Run
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -295,10 +295,18 @@ function initPolling() {
|
||||||
let completed = false;
|
let completed = false;
|
||||||
|
|
||||||
// Reuse buttons
|
// Reuse buttons
|
||||||
document.getElementById('btn-reuse-yes').onclick = () => {
|
const prevVmid = parseInt(vmid) || 21000;
|
||||||
document.getElementById('reuse-form-wrap').classList.remove('hidden');
|
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() {
|
async function poll() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -356,52 +364,54 @@ async function reuseImage(jobId, vmid, sourceFilename, keep) {
|
||||||
try {
|
try {
|
||||||
await fetch('/session/cleanup/' + jobId, {
|
await fetch('/session/cleanup/' + jobId, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
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 = '/';
|
||||||
window.location.href = '/?vmid=' + vmid + '&source=' + encodeURIComponent(sourceFilename);
|
|
||||||
} else {
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
} catch (err) { alert('Cleanup failed: ' + err.message); }
|
} catch (err) { alert('Cleanup failed: ' + err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitReuse(e) {
|
async function cloneVM(jobId, sourceVmid) {
|
||||||
e.preventDefault();
|
const newVmid = parseInt(document.getElementById('reuse-vmid').value) || 0;
|
||||||
const newVmid = document.getElementById('reuse-vmid').value;
|
const newName = document.getElementById('reuse-vmname').value.trim();
|
||||||
const newVmName = document.getElementById('reuse-vmname').value.trim();
|
if (!newVmid || !newName) { alert('Please fill VM Name and ID.'); return; }
|
||||||
const status = document.getElementById('reuse-submit-status');
|
const status = document.getElementById('reuse-submit-status');
|
||||||
status.classList.remove('hidden');
|
status.classList.remove('hidden');
|
||||||
status.innerHTML = '<div class="spinner"></div> Submitting job...';
|
status.innerHTML = '<div class="spinner"></div> Cloning VM...';
|
||||||
|
|
||||||
// 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;
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/session/status/' + jobId);
|
const resp = await fetch('/session/clone', {
|
||||||
analysis = await resp.json();
|
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) {
|
} catch (err) {
|
||||||
status.innerHTML = '<div class="error">Failed to fetch job data.</div>';
|
status.innerHTML = '<div class="error">Clone failed: ' + err.message + '</div>';
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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();
|
const payload = new FormData();
|
||||||
payload.append('vmid', newVmid);
|
payload.append('vmid', newVmid);
|
||||||
payload.append('vm_name', newVmName);
|
payload.append('vm_name', newName);
|
||||||
payload.append('source_filename', sourceFilename);
|
payload.append('source_filename', sourceFilename);
|
||||||
// Re-use disk format from original job or default
|
|
||||||
payload.append('disk_format', 'vmdk');
|
payload.append('disk_format', 'vmdk');
|
||||||
payload.append('cpu_cores', '2');
|
payload.append('cpu_cores', document.getElementById('copy-cores').value || '2');
|
||||||
payload.append('ram_mb', '4096');
|
payload.append('ram_mb', document.getElementById('copy-ram').value || '4096');
|
||||||
payload.append('target_storage', 'local-lvm');
|
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('auto_detect_boot', 'true');
|
||||||
payload.append('boot_type', 'uefi');
|
payload.append('boot_type', 'uefi');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/session/confirm', { method: 'POST', body: payload });
|
const resp = await fetch('/session/confirm', { method: 'POST', body: payload });
|
||||||
const html = await resp.text();
|
const html = await resp.text();
|
||||||
|
|
@ -411,6 +421,8 @@ async function submitReuse(e) {
|
||||||
status.innerHTML = '<div class="error">Failed: ' + err.message + '</div>';
|
status.innerHTML = '<div class="error">Failed: ' + err.message + '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -19,35 +19,55 @@
|
||||||
<div id="error-block" class="error hidden"></div>
|
<div id="error-block" class="error hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reuse: ask whether to create another VM -->
|
<!-- Reuse section -->
|
||||||
<div id="reuse-section" class="panel hidden">
|
<div id="reuse-section" class="panel hidden">
|
||||||
<h2>Conversion Complete</h2>
|
<h2>Conversion Complete — VM {{ vmid }} Ready</h2>
|
||||||
<p>Use the same source image to create another VM?</p>
|
<p style="margin-bottom:1rem;">Create another VM from this disk?</p>
|
||||||
<div class="btn-row">
|
|
||||||
<button id="btn-reuse-yes" class="btn">Yes — Create Another VM</button>
|
<div class="row">
|
||||||
<button id="btn-reuse-no" class="btn btn-secondary">No — Clean Up & Finish</button>
|
<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>
|
||||||
<div id="reuse-form-wrap" class="hidden" style="margin-top:1rem;">
|
|
||||||
<hr style="border-color:var(--border); margin-bottom:1rem;">
|
<div class="btn-row" style="flex-wrap:wrap;">
|
||||||
<form id="reuse-form" onsubmit="submitReuse(event)">
|
<button id="btn-clone" class="btn" style="background:var(--green);">Clone (instant)</button>
|
||||||
<div class="row">
|
<button id="btn-copy" class="btn">Copy (re-convert)</button>
|
||||||
<div class="form-group">
|
<button id="btn-cleanup" class="btn btn-secondary">Clean Up & Finish</button>
|
||||||
<label>VM Name *</label>
|
</div>
|
||||||
<input type="text" id="reuse-vmname" name="vm_name" required
|
<div id="reuse-submit-status" class="hidden" style="margin-top:0.5rem;"></div>
|
||||||
placeholder="e.g. second-vm">
|
|
||||||
</div>
|
<!-- Copy options (hidden until Copy clicked) -->
|
||||||
<div class="form-group">
|
<div id="copy-options" class="hidden" style="margin-top:1rem; padding-top:1rem; border-top:1px solid var(--border);">
|
||||||
<label>VM ID *</label>
|
<p class="dim" style="margin-bottom:0.75rem;">Copy re-runs the conversion with new specs:</p>
|
||||||
<input type="number" id="reuse-vmid" name="vmid" required
|
<div class="row">
|
||||||
min="21000" max="21100" placeholder="21051">
|
<div class="form-group">
|
||||||
</div>
|
<label>CPU Cores</label>
|
||||||
|
<input type="number" id="copy-cores" value="2" min="1">
|
||||||
</div>
|
</div>
|
||||||
<p class="dim" style="font-size:0.8rem;">
|
<div class="form-group">
|
||||||
Same disk image, format, and boot settings as the first VM.
|
<label>RAM (MB)</label>
|
||||||
</p>
|
<input type="number" id="copy-ram" value="4096" min="512">
|
||||||
<button type="submit" class="btn" style="margin-top:0.5rem;">Start Conversion</button>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
<div id="reuse-submit-status" class="hidden" style="margin-top:1rem;"></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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue