refactor: remove browser file upload — download + SCP only

- Removed UploadFile handling, upload-raw endpoint, source type selector
- index.html: single URL input, 'Download & Analyse' button
- session_upload simplified to download-only (aria2c)
- Removed dead code: _check_disk_space, UPLOAD_PROGRESS_INTERVAL,
  MIN_FREE_DISK_GB, UploadFile/File imports
- SCP Pull remains as separate /scp page
This commit is contained in:
Claus Lohmar 2026-07-23 12:33:23 +00:00
parent 2d347692db
commit 778e929985
2 changed files with 97 additions and 378 deletions

View file

@ -28,7 +28,7 @@ from typing import Optional
import requests as http_requests
from fastapi import FastAPI, Form, Request, UploadFile, File
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
@ -99,11 +99,9 @@ logger.info("Frontend starting — log file: %s", LOG_DIR / "vm-bench.log")
VM_ID_MIN = 21000
VM_ID_MAX = 21100
DEFAULT_STORAGE = "local-lvm"
MIN_FREE_DISK_GB = 2 # keep 2 GB headroom
DOWNLOAD_TIMEOUT = 14400 # 4 hours absolute max (safety net)
SPEED_CHECK_AFTER = 30 # wait N seconds before judging speed
MAX_ETA_SECONDS = 3600 # kill download if ETA > 1 hour
UPLOAD_PROGRESS_INTERVAL = 1024**3 # log every 1 GiB during upload
# Track active background downloads for progress polling
_active_downloads: dict[str, dict] = {}
@ -113,19 +111,6 @@ def _validate_vmid(vmid: int) -> Optional[str]:
return f"VM ID must be between {VM_ID_MIN} and {VM_ID_MAX}."
return None
def _check_disk_space(path: Path, needed_gb: int) -> Optional[str]:
"""Return an error message if <path> has less than <needed_gb> + headroom free."""
usage = shutil.disk_usage(path.parent if path.is_file() or not path.exists() else path)
free_gb = usage.free / (1024**3)
required = needed_gb + MIN_FREE_DISK_GB
if free_gb < required:
return (
f"Insufficient disk space on {path.parent}: "
f"{free_gb:.1f} GB free, {required:.1f} GB needed "
f"({needed_gb} GB file + {MIN_FREE_DISK_GB} GB headroom). "
f"Free up space or use a smaller file."
)
return None
def render(name: str, status: int = 200, **ctx) -> HTMLResponse:
tpl = _jinja.get_template(name)
@ -149,13 +134,10 @@ def session_upload(
request: Request,
vmid: int = Form(...),
vm_name: str = Form(""),
source_type: str = Form("upload"),
source_file: Optional[UploadFile] = File(None),
source_url: Optional[str] = Form(None),
session_id: str = Form(""),
):
"""Phase 1 — acquire the source file. Returns JSON so the frontend can
show progress, then call /session/analyze separately."""
"""Download a source file via aria2c. Returns JSON with phase + filename."""
sdir = _staging(session_id) / "in"
sdir.mkdir(parents=True, exist_ok=True)
err = _validate_vmid(vmid)
@ -164,154 +146,49 @@ def session_upload(
vm_name = vm_name.strip()
# ── Upload ──────────────────────────────────────────────────────
if source_type == "upload":
if not source_file or not source_file.filename:
return JSONResponse({"phase": "error", "error": "No file uploaded."}, status_code=400)
url = (source_url or "").strip()
if not url:
return JSONResponse({"phase": "error", "error": "No URL provided."}, status_code=400)
filename = source_file.filename
dest = sdir / filename
content_length = request.headers.get("content-length")
if content_length:
estimated_gb = int(content_length) / (1024**3)
err = _check_disk_space(dest, estimated_gb)
if err:
return JSONResponse({"phase": "error", "error": err}, status_code=400)
try:
logger.info("Receiving upload: %s (%s bytes)", filename, content_length or "unknown")
written = 0
with dest.open("wb") as f:
while True:
chunk = source_file.file.read(8 * 1024 * 1024)
if not chunk:
break
f.write(chunk)
written += len(chunk)
if written % UPLOAD_PROGRESS_INTERVAL < len(chunk):
logger.info("Upload progress: %s%.1f GiB", filename, written / (1024**3))
file_size_gb = round(written / (1024**3), 1)
logger.info("Upload complete: %s (%.1f GiB)", filename, file_size_gb)
except OSError as exc:
if dest.exists():
dest.unlink(missing_ok=True)
return JSONResponse({"phase": "error", "error": f"Upload failed (disk full?): {exc}"}, status_code=500)
except Exception as exc:
if dest.exists():
dest.unlink(missing_ok=True)
return JSONResponse({"phase": "error", "error": f"Upload failed: {exc}"}, status_code=500)
return JSONResponse({
"phase": "staged",
"filename": filename,
"vmid": vmid,
"vm_name": vm_name,
"file_size_gb": file_size_gb,
})
else:
# ── Download ────────────────────────────────────────────────
url = (source_url or "").strip()
if not url:
return JSONResponse({"phase": "error", "error": "No URL provided."}, status_code=400)
filename = Path(url).name or f"download_{uuid.uuid4().hex[:8]}"
dest = sdir / filename
_active_downloads.pop(filename, None)
usage = shutil.disk_usage(_staging(session_id))
free_gb = usage.free / (1024**3)
if free_gb < 50:
logger.warning("Low disk: %.1f GB free — download may fail", free_gb)
logger.info("Starting background download: %s%s", url, dest)
try:
proc = subprocess.Popen(
["aria2c", "-x8", "-s8", "-d", str(dest.parent), "-o", dest.name, url],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except Exception as exc:
return JSONResponse({"phase": "error", "error": f"Failed to start download: {exc}"}, status_code=500)
_active_downloads[filename] = {
"proc": proc, "dest": dest, "vmid": vmid, "vm_name": vm_name,
"start_time": time.time(), "content_length": 0, "_last_logged_bytes": 0,
}
content_length = 0
def _fetch_cl():
nonlocal content_length
try:
hr = http_requests.head(url, timeout=5, allow_redirects=True)
cl = hr.headers.get("Content-Length")
if cl:
content_length = int(cl)
if filename in _active_downloads:
_active_downloads[filename]["content_length"] = int(cl)
logger.info("Download size: %.1f GiB", int(cl) / (1024**3))
except Exception:
pass
threading.Thread(target=_fetch_cl, daemon=True).start()
return JSONResponse({
"phase": "downloading",
"filename": filename, "vmid": vmid, "vm_name": vm_name,
"content_length_gb": round(content_length / (1024**3), 1) if content_length else None,
})
@app.post("/session/upload-raw")
async def upload_raw(request: Request):
"""Raw streaming upload — bypasses multipart parsing for large files."""
filename = request.headers.get("X-Filename", "upload.bin")
vmid_str = request.headers.get("X-VMID", "")
vm_name = request.headers.get("X-VM-Name", "")
session_id = request.headers.get("X-Session-ID", "")
try:
vmid = int(vmid_str)
except ValueError:
return JSONResponse({"phase": "error", "error": "Invalid VM ID."}, status_code=400)
err = _validate_vmid(vmid)
if err:
return JSONResponse({"phase": "error", "error": err}, status_code=400)
sdir = _staging(session_id) / "in"
sdir.mkdir(parents=True, exist_ok=True)
filename = Path(url).name or f"download_{uuid.uuid4().hex[:8]}"
dest = sdir / filename
content_length = request.headers.get("content-length")
if content_length:
estimated_gb = int(content_length) / (1024**3)
err = _check_disk_space(dest, estimated_gb)
if err:
return JSONResponse({"phase": "error", "error": err}, status_code=400)
logger.info("Raw upload: %s (%s bytes)", filename, content_length or "unknown")
written = 0
_active_downloads.pop(filename, None)
usage = shutil.disk_usage(_staging(session_id))
free_gb = usage.free / (1024**3)
if free_gb < 50:
logger.warning("Low disk: %.1f GB free — download may fail", free_gb)
logger.info("Starting download: %s%s", url, dest)
try:
with dest.open("wb") as f:
async for chunk in request.stream():
f.write(chunk)
written += len(chunk)
if written % UPLOAD_PROGRESS_INTERVAL < len(chunk):
logger.info("Upload progress: %s%.1f GiB", filename, written / (1024**3))
file_size_gb = round(written / (1024**3), 1)
logger.info("Raw upload complete: %s (%.1f GiB)", filename, file_size_gb)
proc = subprocess.Popen(
["aria2c", "-x8", "-s8", "-d", str(dest.parent), "-o", dest.name, url],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except Exception as exc:
if dest.exists():
dest.unlink(missing_ok=True)
logger.exception("Raw upload failed: %s", filename)
return JSONResponse({"phase": "error", "error": f"Upload failed: {exc}"}, status_code=500)
return JSONResponse({"phase": "error", "error": f"Failed to start download: {exc}"}, status_code=500)
_active_downloads[filename] = {
"proc": proc, "dest": dest, "vmid": vmid, "vm_name": vm_name,
"start_time": time.time(), "content_length": 0, "_last_logged_bytes": 0,
}
def _fetch_cl():
try:
hr = http_requests.head(url, timeout=5, allow_redirects=True)
cl = hr.headers.get("Content-Length")
if cl and filename in _active_downloads:
_active_downloads[filename]["content_length"] = int(cl)
logger.info("Download size: %.1f GiB", int(cl) / (1024**3))
except Exception:
pass
threading.Thread(target=_fetch_cl, daemon=True).start()
return JSONResponse({
"phase": "staged",
"filename": filename,
"vmid": vmid,
"vm_name": vm_name,
"file_size_gb": file_size_gb,
"phase": "downloading",
"filename": filename, "vmid": vmid, "vm_name": vm_name,
"content_length_gb": None,
})

View file

@ -1,7 +1,6 @@
{% extends "base.html" %}
{% block content %}
<!-- Step 1: Start New Session -->
<div class="pve-panel" id="step1">
<div class="pve-panel-header">
<span class="pve-panel-title">New Conversion Session</span>
@ -24,35 +23,22 @@
</div>
<div class="pve-form-group">
<label for="source-type">Source Type</label>
<select id="source-type" class="pve-select" onchange="toggleSourceInput(event)">
<option value="upload">File Upload</option>
<option value="url">Download from URL</option>
</select>
</div>
<div class="pve-form-group" id="upload-group">
<label for="source-file">Source File (.vmdk / .7z / .zip / .tar.gz)</label>
<input type="file" id="source-file" name="source_file" class="pve-input"
accept=".vmdk,.vhd,.vhdx,.7z,.zip,.tar.gz,.tgz,.tar,.gz">
</div>
<div class="pve-form-group pve-hidden" id="url-group">
<label for="source-url">Download URL</label>
<input type="url" id="source-url" name="source_url" class="pve-input"
<label for="source-url">Download URL *</label>
<input type="url" id="source-url" name="source_url" required class="pve-input"
placeholder="https://example.com/image.7z">
<span class="pve-hint">Direct URL to a disk image or archive (.vmdk, .7z, .zip, etc.)</span>
</div>
<button type="submit" id="start-btn" class="pve-btn pve-btn-primary">Analyse Source Image</button>
<button type="submit" id="start-btn" class="pve-btn pve-btn-primary">Download &amp; Analyse</button>
</form>
<hr class="pve-divider">
<p class="pve-dim" style="font-size:0.78rem; margin-bottom:0.5rem;">
For files larger than 10 GB or on remote servers — pull directly via SCP:
Files on a remote server? Pull directly via SCP:
</p>
<a href="/scp" class="pve-btn">SCP Pull (Large Files +10 GB)</a>
<a href="/scp" class="pve-btn">SCP Pull</a>
<!-- Progress area (hidden until form submit) -->
<!-- Progress area -->
<div id="session-status" class="pve-hidden" style="margin-top:1rem;">
<div id="phase-label" style="margin-bottom:0.5rem;"></div>
<div class="pve-progress">
@ -63,36 +49,24 @@
</div>
</div>
<!-- Step 2: Analysis Result (populated by JavaScript) -->
<div id="analysis-section"></div>
<!-- Step 3: Polling view (replaces above when conversion starts) -->
<div id="polling-section"></div>
<script>
// ── Source type toggle ────────────────────────────────────────────
function toggleSourceInput(e) {
const type = e.target.value;
document.getElementById('upload-group').classList.toggle('pve-hidden', type !== 'upload');
document.getElementById('url-group').classList.toggle('pve-hidden', type !== 'url');
}
// ── Progress helpers ──────────────────────────────────────────────
function setPhase(label, pct, isSpinner) {
const el = document.getElementById('phase-label');
const fill = document.getElementById('pve-progress-bar');
if (el) el.innerHTML = (isSpinner ? '<span class="spinner"></span> ' : '') + label;
if (el) el.innerHTML = (isSpinner ? '<span class="pve-spinner"></span> ' : '') + label;
if (fill) { fill.style.width = pct + '%'; fill.textContent = pct > 0 ? pct + '%' : ''; }
}
function showError(msg) {
const el = document.getElementById('session-error');
if (el) el.innerHTML += '<div class="error" style="margin-top:0.5rem;">' + msg + '</div>';
if (el) el.innerHTML += '<div class="pve-alert pve-alert-error" style="margin-top:0.5rem;">' + msg + '</div>';
}
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
// ── Phase 1: Upload / Download ────────────────────────────────────
async function startSession(e) {
e.preventDefault();
const btn = document.getElementById('start-btn');
@ -102,162 +76,87 @@ async function startSession(e) {
btn.disabled = true;
analysis.innerHTML = '';
polling.innerHTML = '';
// Reset permanent elements (no innerHTML — keeps DOM references alive)
document.getElementById('phase-label').textContent = '';
var pf = document.getElementById('pve-progress-bar');
pf.style.width = '0%'; pf.textContent = '0%';
document.getElementById('session-error').textContent = '';
status.classList.remove('pve-hidden');
const vmid = document.getElementById('vmid').value;
const vmName = document.getElementById('vm-name').value.trim();
const sourceType = document.getElementById('source-type').value;
const url = document.getElementById('source-url').value.trim();
if (!url) { showError('Please enter a download URL.'); btn.disabled = false; return; }
const sessionId = window.VM_BENCH_SID || '';
const formData = new FormData();
formData.append('vmid', vmid);
formData.append('vm_name', vmName);
formData.append('source_type', sourceType);
formData.append('source_type', 'url');
formData.append('source_url', url);
formData.append('session_id', sessionId);
if (sourceType === 'upload') {
const fileInput = document.getElementById('source-file');
if (fileInput.files.length === 0) {
setPhase('Please select a file.', 0, false);
showError('No file selected.');
btn.disabled = false; return;
}
formData.append('source_file', fileInput.files[0]);
await handleUpload(formData);
} else {
const url = document.getElementById('source-url').value.trim();
if (!url) {
setPhase('Please enter a URL.', 0, false);
showError('No URL provided.');
btn.disabled = false; return;
}
formData.append('source_url', url);
await handleDownload(formData);
}
status.classList.remove('pve-hidden');
setPhase('Starting download...', 0, true);
try { await handleDownload(formData); }
catch (err) { setPhase('Download failed', 0, false); showError(err.message); }
btn.disabled = false;
}
async function handleUpload(formData) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
const file = formData.get('source_file');
const vmid = formData.get('vmid');
const vmName = formData.get('vm_name');
const sessionId = formData.get('session_id') || '';
// Raw streaming upload — bypasses multipart parsing
xhr.open('POST', '/session/upload-raw');
xhr.timeout = 7200000;
xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name));
xhr.setRequestHeader('X-VMID', vmid);
xhr.setRequestHeader('X-VM-Name', vmName);
xhr.setRequestHeader('X-Session-ID', sessionId);
xhr.upload.addEventListener('progress', (e) => {
console.log('upload', e.loaded, e.total, e.lengthComputable);
if (e.lengthComputable) {
const pct = Math.max(1, Math.round((e.loaded / e.total) * 100));
const mb = (e.loaded / (1024**2)).toFixed(0);
const gb = (e.loaded / (1024**3)).toFixed(1);
const size = e.loaded > 100*1024*1024 ? gb + ' GiB' : mb + ' MB';
setPhase('Uploading source file... ' + size, pct, pct < 5);
} else {
const mb = (e.loaded / (1024**2)).toFixed(0);
setPhase('Uploading source file... ' + mb + ' MB', 0, true);
}
});
xhr.addEventListener('loadstart', () => {
setPhase('Uploading source file...', 0, true);
});
xhr.addEventListener('timeout', () => {
setPhase('Upload timed out', 0, false);
showError('Upload timed out after 2 hours. The file may be too large for your connection speed. Try downloading the file directly on the server instead.');
resolve();
});
xhr.addEventListener('load', async () => {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
if (data.phase === 'staged') await runAnalysis(data.vmid, data.filename, data.vm_name);
else { setPhase('Upload failed', 0, false); showError(data.error || 'Unknown error'); }
} else {
setPhase('Upload failed', 0, false);
try { showError(JSON.parse(xhr.responseText).error); } catch (_) { showError('Server error'); }
}
resolve();
});
xhr.addEventListener('error', () => { setPhase('Upload failed', 0, false); showError('Network error.'); resolve(); });
xhr.send(file); // raw binary — no multipart overhead
});
}
async function handleDownload(formData) {
setPhase('Starting download...', 0, false);
let resp;
try { resp = await fetch('/session/upload', { method: 'POST', body: formData }); }
catch (err) { setPhase('Download failed', 0, false); showError('Network error: ' + err.message); return; }
catch (err) { throw new Error('Network error: ' + err.message); }
const data = await resp.json();
if (data.phase === 'error') { setPhase('Download failed', 0, false); showError(data.error); return; }
if (data.phase !== 'downloading') { setPhase('Unexpected: ' + data.phase, 0, false); return; }
if (data.phase === 'error') { throw new Error(data.error); }
if (data.phase !== 'downloading') { throw new Error('Unexpected: ' + data.phase); }
const filename = data.filename;
setPhase('Downloading source file...', 0, true);
for (;;) {
await sleep(2000);
try {
const pr = await fetch('/session/progress/' + encodeURIComponent(filename));
const pdata = await pr.json();
if (pdata.phase === 'complete') {
setPhase('Download complete (' + (pdata.file_size_gb || 0) + ' GiB)', 100, false);
await sleep(500); await runAnalysis(data.vmid, filename, data.vm_name); return;
}
if (pdata.phase === 'error') { setPhase('Download failed', 0, false); showError(pdata.error); return; }
if (pdata.phase === 'too_slow') {
setPhase('Download too slow', 0, false);
showError('<strong>Download cancelled — too slow.</strong><br><br>' + (pdata.message || '') +
'<br><br>Download the file manually to your computer, then use <strong>File Upload</strong>.');
await sleep(500);
await runAnalysis(data.vmid, filename, data.vm_name);
return;
}
if (pdata.phase === 'error') { throw new Error(pdata.error); }
if (pdata.phase === 'too_slow') {
throw new Error('<strong>Download too slow.</strong><br><br>' + (pdata.message || '') +
'<br><br>Try downloading to a server and using SCP Pull instead.');
}
if (pdata.phase === 'downloading') {
const gb = (pdata.file_size_gb || 0).toFixed(1);
let label = 'Downloading source file... ' + gb + ' GiB';
if (pdata.speed_mbps) label += ' (' + pdata.speed_mbps + ' MB/s)';
if (pdata.eta) label += ' — ' + pdata.eta;
// Show percentage if we know total size
const totalGb = pdata.content_length_gb;
const pct = totalGb ? Math.round((pdata.file_size_gb / totalGb) * 100) : 0;
setPhase(label, pct, totalGb ? false : true);
}
} catch (_) { /* keep polling */ }
} catch (_) {}
}
}
// ── Phase 2: Analysis ─────────────────────────────────────────────
async function runAnalysis(vmid, filename, vmName) {
setPhase('Step 2/2: Analysing source image...', 0, true);
const fd = new FormData();
fd.append('vmid', vmid); fd.append('filename', filename); fd.append('vm_name', vmName);
fd.append('session_id', window.VM_BENCH_SID || '');
try {
const resp = await fetch('/session/analyze', { method: 'POST', body: fd });
const html = await resp.text();
document.getElementById('analysis-section').innerHTML = html;
document.getElementById('session-status').classList.add('pve-hidden');
// Attach event listeners since innerHTML doesn't execute <script>
initConfirmForm();
} catch (err) { setPhase('Analysis failed', 0, false); showError('Failed: ' + err.message); }
const resp = await fetch('/session/analyze', { method: 'POST', body: fd });
const html = await resp.text();
document.getElementById('analysis-section').innerHTML = html;
document.getElementById('session-status').classList.add('pve-hidden');
initConfirmForm();
}
// ── Phase 3: Confirm form (attached after injection) ─────────────
function initConfirmForm() {
const form = document.getElementById('confirm-form');
if (!form) return;
form.addEventListener('submit', submitJob);
// Boot type toggle
const bootSel = form.querySelector('select[name="auto_detect_boot"]');
if (bootSel) {
bootSel.addEventListener('change', function() {
@ -274,28 +173,23 @@ async function submitJob(e) {
const status = document.getElementById('submit-status');
btn.disabled = true;
status.classList.remove('pve-hidden');
status.innerHTML = '<div class="spinner"></div> Submitting job...';
status.innerHTML = '<div class="pve-spinner"></div> Submitting job...';
const formData = new FormData(form);
formData.append('session_id', window.VM_BENCH_SID || '');
try {
const resp = await fetch('/session/confirm', { method: 'POST', body: formData });
const html = await resp.text();
// Hide form + analysis, show polling
document.getElementById('step1').style.display = 'none';
document.getElementById('analysis-section').innerHTML = '';
document.getElementById('polling-section').innerHTML = html;
// Attach handlers for the injected polling fragment
initPolling();
} catch (err) {
status.innerHTML = '<div class="error">Failed: ' + err.message + '</div>';
status.innerHTML = '<div class="pve-alert pve-alert-error">Failed: ' + err.message + '</div>';
btn.disabled = false;
}
}
// ── Phase 4: Polling (attached after injection) ──────────────────
let _pollTimer = null;
function initPolling() {
const container = document.getElementById('polling-container');
if (!container) return;
@ -305,19 +199,15 @@ function initPolling() {
const startTime = Date.now();
let completed = false;
// Reuse buttons
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('pve-hidden');
};
document.getElementById('btn-cleanup').onclick = () => reuseImage(jobId, vmid, sourceFilename, false);
document.getElementById('btn-copy-start').onclick = () => copyVM(jobId, vmid, sourceFilename);
const prevVmid = parseInt(vmid) || 21000;
document.getElementById('reuse-vmid').value = prevVmid + 1;
document.getElementById('reuse-vmname').value = (sourceFilename.split('.')[0] || 'vm') + '-2';
async function poll() {
try {
@ -328,57 +218,22 @@ function initPolling() {
if (fill) { fill.style.width = pct + '%'; fill.textContent = pct + '%'; }
const elMsg = document.getElementById('status-message');
if (elMsg) elMsg.textContent = data.message || '';
const elElapsed = document.getElementById('elapsed');
if (elElapsed) elElapsed.textContent = 'Elapsed: ' + Math.round((Date.now() - startTime) / 1000) + 's';
document.getElementById('elapsed').textContent = 'Elapsed: ' + Math.round((Date.now() - startTime) / 1000) + 's';
const badge = document.getElementById('status-badge');
const map = {
'queued': ['pve-badge pve-badge-queued', 'Queued'],
'processing_conversion': ['pve-badge pve-badge-running', 'Converting...'],
'importing_storage': ['pve-badge pve-badge-running', 'Importing...'],
'completed': ['pve-badge pve-badge-completed', 'Completed'],
'failed': ['pve-badge pve-badge-failed', 'Failed'],
};
const map = { 'queued': ['pve-badge pve-badge-queued', 'Queued'], 'processing_conversion': ['pve-badge pve-badge-running', 'Converting...'], 'importing_storage': ['pve-badge pve-badge-running', 'Importing...'], 'completed': ['pve-badge pve-badge-completed', 'Completed'], 'failed': ['pve-badge pve-badge-failed', 'Failed'] };
const [cls, text] = map[data.status] || ['pve-badge pve-badge-queued', data.status];
if (badge) {
badge.className = 'badge ' + cls;
badge.innerHTML = (data.status === 'processing_conversion' || data.status === 'importing_storage')
? '<span class="spinner"></span> ' + text : text;
}
if (data.error_details) {
const errBlock = document.getElementById('error-block');
if (errBlock) { errBlock.classList.remove('pve-hidden'); errBlock.textContent = data.error_details; }
}
if (data.status === 'completed' || data.status === 'failed') {
completed = true;
if (data.status === 'completed') {
const reuse = document.getElementById('reuse-section');
if (reuse) { reuse.classList.remove('pve-hidden'); reuse.scrollIntoView({ behavior: 'smooth' }); }
}
return;
}
if (badge) { badge.className = 'badge ' + cls; badge.innerHTML = (data.status === 'processing_conversion' || data.status === 'importing_storage') ? '<span class="pve-spinner"></span> ' + text : text; }
if (data.error_details) { const eb = document.getElementById('error-block'); if (eb) { eb.classList.remove('pve-hidden'); eb.textContent = data.error_details; } }
if (data.status === 'completed' || data.status === 'failed') { completed = true; if (data.status === 'completed') { const rs = document.getElementById('reuse-section'); if (rs) { rs.classList.remove('pve-hidden'); rs.scrollIntoView({ behavior: 'smooth' }); } } return; }
if (!completed) _pollTimer = setTimeout(poll, 2000);
} catch (err) {
const errBlock = document.getElementById('error-block');
if (errBlock) { errBlock.classList.remove('pve-hidden'); errBlock.textContent = 'Polling error: ' + err.message; }
if (!completed) _pollTimer = setTimeout(poll, 5000);
}
} catch (err) { const eb = document.getElementById('error-block'); if (eb) { eb.classList.remove('pve-hidden'); eb.textContent = 'Polling error: ' + err.message; } if (!completed) _pollTimer = setTimeout(poll, 5000); }
}
// Wait 3s then start polling
_pollTimer = setTimeout(poll, 3000);
}
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: true, session_id: window.VM_BENCH_SID || '' }),
});
window.location.href = '/';
} catch (err) { alert('Cleanup failed: ' + err.message); }
await fetch('/session/cleanup/' + jobId, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ delete_staging_files: true, session_id: window.VM_BENCH_SID || '' }) });
window.location.href = '/';
}
async function cloneVM(jobId, sourceVmid) {
@ -387,21 +242,12 @@ async function cloneVM(jobId, sourceVmid) {
if (!newVmid || !newName) { alert('Please fill VM Name and ID.'); return; }
const status = document.getElementById('reuse-submit-status');
status.classList.remove('pve-hidden');
status.innerHTML = '<div class="spinner"></div> Cloning VM...';
status.innerHTML = '<div class="pve-spinner"></div> Cloning VM...';
try {
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 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">Clone failed: ' + err.message + '</div>';
}
status.innerHTML = data.status === 'completed' ? '<div class="pve-alert pve-alert-success">VM ' + newVmid + ' cloned!</div>' : '<div class="pve-alert pve-alert-error">Clone failed: ' + (data.error_details || 'Unknown') + '</div>';
} catch (err) { status.innerHTML = '<div class="pve-alert pve-alert-error">Clone failed: ' + err.message + '</div>'; }
}
async function copyVM(jobId, vmid, sourceFilename) {
@ -410,10 +256,9 @@ async function copyVM(jobId, vmid, sourceFilename) {
if (!newVmid || !newName) { alert('Please fill VM Name and ID.'); return; }
const status = document.getElementById('copy-status');
status.classList.remove('pve-hidden');
status.innerHTML = '<div class="spinner"></div> Submitting job...';
status.innerHTML = '<div class="pve-spinner"></div> Submitting job...';
const payload = new FormData();
payload.append('vmid', newVmid);
payload.append('vm_name', newName);
payload.append('vmid', newVmid); payload.append('vm_name', newName);
payload.append('source_filename', sourceFilename);
payload.append('disk_format', 'vmdk');
payload.append('cpu_cores', document.getElementById('copy-cores').value || '2');
@ -426,12 +271,9 @@ async function copyVM(jobId, vmid, sourceFilename) {
payload.append('session_id', window.VM_BENCH_SID || '');
try {
const resp = await fetch('/session/confirm', { method: 'POST', body: payload });
const html = await resp.text();
document.getElementById('polling-section').innerHTML = html;
document.getElementById('polling-section').innerHTML = await resp.text();
initPolling();
} catch (err) {
status.innerHTML = '<div class="error">Failed: ' + err.message + '</div>';
}
} catch (err) { status.innerHTML = '<div class="pve-alert pve-alert-error">Failed: ' + err.message + '</div>'; }
}
</script>