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 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.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles 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_MIN = 21000
VM_ID_MAX = 21100 VM_ID_MAX = 21100
DEFAULT_STORAGE = "local-lvm" DEFAULT_STORAGE = "local-lvm"
MIN_FREE_DISK_GB = 2 # keep 2 GB headroom
DOWNLOAD_TIMEOUT = 14400 # 4 hours absolute max (safety net) DOWNLOAD_TIMEOUT = 14400 # 4 hours absolute max (safety net)
SPEED_CHECK_AFTER = 30 # wait N seconds before judging speed SPEED_CHECK_AFTER = 30 # wait N seconds before judging speed
MAX_ETA_SECONDS = 3600 # kill download if ETA > 1 hour 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 # Track active background downloads for progress polling
_active_downloads: dict[str, dict] = {} _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 f"VM ID must be between {VM_ID_MIN} and {VM_ID_MAX}."
return None 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: def render(name: str, status: int = 200, **ctx) -> HTMLResponse:
tpl = _jinja.get_template(name) tpl = _jinja.get_template(name)
@ -149,13 +134,10 @@ def session_upload(
request: Request, request: Request,
vmid: int = Form(...), vmid: int = Form(...),
vm_name: str = Form(""), vm_name: str = Form(""),
source_type: str = Form("upload"),
source_file: Optional[UploadFile] = File(None),
source_url: Optional[str] = Form(None), source_url: Optional[str] = Form(None),
session_id: str = Form(""), session_id: str = Form(""),
): ):
"""Phase 1 — acquire the source file. Returns JSON so the frontend can """Download a source file via aria2c. Returns JSON with phase + filename."""
show progress, then call /session/analyze separately."""
sdir = _staging(session_id) / "in" sdir = _staging(session_id) / "in"
sdir.mkdir(parents=True, exist_ok=True) sdir.mkdir(parents=True, exist_ok=True)
err = _validate_vmid(vmid) err = _validate_vmid(vmid)
@ -164,54 +146,6 @@ def session_upload(
vm_name = vm_name.strip() 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)
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() url = (source_url or "").strip()
if not url: if not url:
return JSONResponse({"phase": "error", "error": "No URL provided."}, status_code=400) return JSONResponse({"phase": "error", "error": "No URL provided."}, status_code=400)
@ -226,7 +160,7 @@ def session_upload(
if free_gb < 50: if free_gb < 50:
logger.warning("Low disk: %.1f GB free — download may fail", free_gb) logger.warning("Low disk: %.1f GB free — download may fail", free_gb)
logger.info("Starting background download: %s%s", url, dest) logger.info("Starting download: %s%s", url, dest)
try: try:
proc = subprocess.Popen( proc = subprocess.Popen(
["aria2c", "-x8", "-s8", "-d", str(dest.parent), "-o", dest.name, url], ["aria2c", "-x8", "-s8", "-d", str(dest.parent), "-o", dest.name, url],
@ -240,15 +174,11 @@ def session_upload(
"start_time": time.time(), "content_length": 0, "_last_logged_bytes": 0, "start_time": time.time(), "content_length": 0, "_last_logged_bytes": 0,
} }
content_length = 0
def _fetch_cl(): def _fetch_cl():
nonlocal content_length
try: try:
hr = http_requests.head(url, timeout=5, allow_redirects=True) hr = http_requests.head(url, timeout=5, allow_redirects=True)
cl = hr.headers.get("Content-Length") cl = hr.headers.get("Content-Length")
if cl: if cl and filename in _active_downloads:
content_length = int(cl)
if filename in _active_downloads:
_active_downloads[filename]["content_length"] = int(cl) _active_downloads[filename]["content_length"] = int(cl)
logger.info("Download size: %.1f GiB", int(cl) / (1024**3)) logger.info("Download size: %.1f GiB", int(cl) / (1024**3))
except Exception: except Exception:
@ -258,60 +188,7 @@ def session_upload(
return JSONResponse({ return JSONResponse({
"phase": "downloading", "phase": "downloading",
"filename": filename, "vmid": vmid, "vm_name": vm_name, "filename": filename, "vmid": vmid, "vm_name": vm_name,
"content_length_gb": round(content_length / (1024**3), 1) if content_length else None, "content_length_gb": 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)
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
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)
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": "staged",
"filename": filename,
"vmid": vmid,
"vm_name": vm_name,
"file_size_gb": file_size_gb,
}) })

View file

@ -1,7 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<!-- Step 1: Start New Session -->
<div class="pve-panel" id="step1"> <div class="pve-panel" id="step1">
<div class="pve-panel-header"> <div class="pve-panel-header">
<span class="pve-panel-title">New Conversion Session</span> <span class="pve-panel-title">New Conversion Session</span>
@ -24,35 +23,22 @@
</div> </div>
<div class="pve-form-group"> <div class="pve-form-group">
<label for="source-type">Source Type</label> <label for="source-url">Download URL *</label>
<select id="source-type" class="pve-select" onchange="toggleSourceInput(event)"> <input type="url" id="source-url" name="source_url" required class="pve-input"
<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"
placeholder="https://example.com/image.7z"> placeholder="https://example.com/image.7z">
<span class="pve-hint">Direct URL to a disk image or archive (.vmdk, .7z, .zip, etc.)</span>
</div> </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> </form>
<hr class="pve-divider"> <hr class="pve-divider">
<p class="pve-dim" style="font-size:0.78rem; margin-bottom:0.5rem;"> <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> </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="session-status" class="pve-hidden" style="margin-top:1rem;">
<div id="phase-label" style="margin-bottom:0.5rem;"></div> <div id="phase-label" style="margin-bottom:0.5rem;"></div>
<div class="pve-progress"> <div class="pve-progress">
@ -63,36 +49,24 @@
</div> </div>
</div> </div>
<!-- Step 2: Analysis Result (populated by JavaScript) -->
<div id="analysis-section"></div> <div id="analysis-section"></div>
<!-- Step 3: Polling view (replaces above when conversion starts) -->
<div id="polling-section"></div> <div id="polling-section"></div>
<script> <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) { function setPhase(label, pct, isSpinner) {
const el = document.getElementById('phase-label'); const el = document.getElementById('phase-label');
const fill = document.getElementById('pve-progress-bar'); 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 + '%' : ''; } if (fill) { fill.style.width = pct + '%'; fill.textContent = pct > 0 ? pct + '%' : ''; }
} }
function showError(msg) { function showError(msg) {
const el = document.getElementById('session-error'); 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)); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
// ── Phase 1: Upload / Download ────────────────────────────────────
async function startSession(e) { async function startSession(e) {
e.preventDefault(); e.preventDefault();
const btn = document.getElementById('start-btn'); const btn = document.getElementById('start-btn');
@ -102,162 +76,87 @@ async function startSession(e) {
btn.disabled = true; btn.disabled = true;
analysis.innerHTML = ''; analysis.innerHTML = '';
polling.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 = ''; document.getElementById('session-error').textContent = '';
status.classList.remove('pve-hidden');
const vmid = document.getElementById('vmid').value; const vmid = document.getElementById('vmid').value;
const vmName = document.getElementById('vm-name').value.trim(); 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 sessionId = window.VM_BENCH_SID || '';
const formData = new FormData(); const formData = new FormData();
formData.append('vmid', vmid); formData.append('vmid', vmid);
formData.append('vm_name', vmName); 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); formData.append('session_id', sessionId);
if (sourceType === 'upload') { status.classList.remove('pve-hidden');
const fileInput = document.getElementById('source-file'); setPhase('Starting download...', 0, true);
if (fileInput.files.length === 0) {
setPhase('Please select a file.', 0, false); try { await handleDownload(formData); }
showError('No file selected.'); catch (err) { setPhase('Download failed', 0, false); showError(err.message); }
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);
}
btn.disabled = false; 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) { async function handleDownload(formData) {
setPhase('Starting download...', 0, false);
let resp; let resp;
try { resp = await fetch('/session/upload', { method: 'POST', body: formData }); } 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(); const data = await resp.json();
if (data.phase === 'error') { setPhase('Download failed', 0, false); showError(data.error); return; } if (data.phase === 'error') { throw new Error(data.error); }
if (data.phase !== 'downloading') { setPhase('Unexpected: ' + data.phase, 0, false); return; } if (data.phase !== 'downloading') { throw new Error('Unexpected: ' + data.phase); }
const filename = data.filename; const filename = data.filename;
setPhase('Downloading source file...', 0, true); setPhase('Downloading source file...', 0, true);
for (;;) { for (;;) {
await sleep(2000); await sleep(2000);
try { try {
const pr = await fetch('/session/progress/' + encodeURIComponent(filename)); const pr = await fetch('/session/progress/' + encodeURIComponent(filename));
const pdata = await pr.json(); const pdata = await pr.json();
if (pdata.phase === 'complete') { if (pdata.phase === 'complete') {
setPhase('Download complete (' + (pdata.file_size_gb || 0) + ' GiB)', 100, false); setPhase('Download complete (' + (pdata.file_size_gb || 0) + ' GiB)', 100, false);
await sleep(500); await runAnalysis(data.vmid, filename, data.vm_name); return; await sleep(500);
} await runAnalysis(data.vmid, filename, data.vm_name);
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>.');
return; 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') { if (pdata.phase === 'downloading') {
const gb = (pdata.file_size_gb || 0).toFixed(1); const gb = (pdata.file_size_gb || 0).toFixed(1);
let label = 'Downloading source file... ' + gb + ' GiB'; let label = 'Downloading source file... ' + gb + ' GiB';
if (pdata.speed_mbps) label += ' (' + pdata.speed_mbps + ' MB/s)'; if (pdata.speed_mbps) label += ' (' + pdata.speed_mbps + ' MB/s)';
if (pdata.eta) label += ' — ' + pdata.eta; if (pdata.eta) label += ' — ' + pdata.eta;
// Show percentage if we know total size
const totalGb = pdata.content_length_gb; const totalGb = pdata.content_length_gb;
const pct = totalGb ? Math.round((pdata.file_size_gb / totalGb) * 100) : 0; const pct = totalGb ? Math.round((pdata.file_size_gb / totalGb) * 100) : 0;
setPhase(label, pct, totalGb ? false : true); setPhase(label, pct, totalGb ? false : true);
} }
} catch (_) { /* keep polling */ } } catch (_) {}
} }
} }
// ── Phase 2: Analysis ─────────────────────────────────────────────
async function runAnalysis(vmid, filename, vmName) { async function runAnalysis(vmid, filename, vmName) {
setPhase('Step 2/2: Analysing source image...', 0, true); setPhase('Step 2/2: Analysing source image...', 0, true);
const fd = new FormData(); const fd = new FormData();
fd.append('vmid', vmid); fd.append('filename', filename); fd.append('vm_name', vmName); fd.append('vmid', vmid); fd.append('filename', filename); fd.append('vm_name', vmName);
fd.append('session_id', window.VM_BENCH_SID || ''); fd.append('session_id', window.VM_BENCH_SID || '');
try {
const resp = await fetch('/session/analyze', { method: 'POST', body: fd }); const resp = await fetch('/session/analyze', { method: 'POST', body: fd });
const html = await resp.text(); const html = await resp.text();
document.getElementById('analysis-section').innerHTML = html; document.getElementById('analysis-section').innerHTML = html;
document.getElementById('session-status').classList.add('pve-hidden'); document.getElementById('session-status').classList.add('pve-hidden');
// Attach event listeners since innerHTML doesn't execute <script>
initConfirmForm(); initConfirmForm();
} catch (err) { setPhase('Analysis failed', 0, false); showError('Failed: ' + err.message); }
} }
// ── Phase 3: Confirm form (attached after injection) ─────────────
function initConfirmForm() { function initConfirmForm() {
const form = document.getElementById('confirm-form'); const form = document.getElementById('confirm-form');
if (!form) return; if (!form) return;
form.addEventListener('submit', submitJob); form.addEventListener('submit', submitJob);
// Boot type toggle
const bootSel = form.querySelector('select[name="auto_detect_boot"]'); const bootSel = form.querySelector('select[name="auto_detect_boot"]');
if (bootSel) { if (bootSel) {
bootSel.addEventListener('change', function() { bootSel.addEventListener('change', function() {
@ -274,28 +173,23 @@ async function submitJob(e) {
const status = document.getElementById('submit-status'); const status = document.getElementById('submit-status');
btn.disabled = true; btn.disabled = true;
status.classList.remove('pve-hidden'); 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); const formData = new FormData(form);
formData.append('session_id', window.VM_BENCH_SID || ''); formData.append('session_id', window.VM_BENCH_SID || '');
try { try {
const resp = await fetch('/session/confirm', { method: 'POST', body: formData }); const resp = await fetch('/session/confirm', { method: 'POST', body: formData });
const html = await resp.text(); const html = await resp.text();
// Hide form + analysis, show polling
document.getElementById('step1').style.display = 'none'; document.getElementById('step1').style.display = 'none';
document.getElementById('analysis-section').innerHTML = ''; document.getElementById('analysis-section').innerHTML = '';
document.getElementById('polling-section').innerHTML = html; document.getElementById('polling-section').innerHTML = html;
// Attach handlers for the injected polling fragment
initPolling(); initPolling();
} catch (err) { } 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; btn.disabled = false;
} }
} }
// ── Phase 4: Polling (attached after injection) ──────────────────
let _pollTimer = null; let _pollTimer = null;
function initPolling() { function initPolling() {
const container = document.getElementById('polling-container'); const container = document.getElementById('polling-container');
if (!container) return; if (!container) return;
@ -305,19 +199,15 @@ function initPolling() {
const startTime = Date.now(); const startTime = Date.now();
let completed = false; 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-clone').onclick = () => cloneVM(jobId, vmid);
document.getElementById('btn-copy').onclick = () => { document.getElementById('btn-copy').onclick = () => {
document.getElementById('copy-options').classList.remove('pve-hidden'); document.getElementById('copy-options').classList.remove('pve-hidden');
}; };
document.getElementById('btn-cleanup').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); 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() { async function poll() {
try { try {
@ -328,57 +218,22 @@ function initPolling() {
if (fill) { fill.style.width = pct + '%'; fill.textContent = pct + '%'; } if (fill) { fill.style.width = pct + '%'; fill.textContent = pct + '%'; }
const elMsg = document.getElementById('status-message'); const elMsg = document.getElementById('status-message');
if (elMsg) elMsg.textContent = data.message || ''; if (elMsg) elMsg.textContent = data.message || '';
const elElapsed = document.getElementById('elapsed'); document.getElementById('elapsed').textContent = 'Elapsed: ' + Math.round((Date.now() - startTime) / 1000) + 's';
if (elElapsed) elElapsed.textContent = 'Elapsed: ' + Math.round((Date.now() - startTime) / 1000) + 's';
const badge = document.getElementById('status-badge'); const badge = document.getElementById('status-badge');
const map = { 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'] };
'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]; const [cls, text] = map[data.status] || ['pve-badge pve-badge-queued', data.status];
if (badge) { if (badge) { badge.className = 'badge ' + cls; badge.innerHTML = (data.status === 'processing_conversion' || data.status === 'importing_storage') ? '<span class="pve-spinner"></span> ' + text : text; }
badge.className = 'badge ' + cls; if (data.error_details) { const eb = document.getElementById('error-block'); if (eb) { eb.classList.remove('pve-hidden'); eb.textContent = data.error_details; } }
badge.innerHTML = (data.status === 'processing_conversion' || data.status === 'importing_storage') 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; }
? '<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 (!completed) _pollTimer = setTimeout(poll, 2000); if (!completed) _pollTimer = setTimeout(poll, 2000);
} catch (err) { } 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); }
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);
} }
}
// Wait 3s then start polling
_pollTimer = setTimeout(poll, 3000); _pollTimer = setTimeout(poll, 3000);
} }
async function reuseImage(jobId, vmid, sourceFilename, keep) { 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 || '' }) });
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 = '/'; window.location.href = '/';
} catch (err) { alert('Cleanup failed: ' + err.message); }
} }
async function cloneVM(jobId, sourceVmid) { 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; } 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('pve-hidden'); status.classList.remove('pve-hidden');
status.innerHTML = '<div class="spinner"></div> Cloning VM...'; status.innerHTML = '<div class="pve-spinner"></div> Cloning VM...';
try { try {
const resp = await fetch('/session/clone', { 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 }) });
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_vmid: sourceVmid, target_vmid: newVmid, target_name: newName }),
});
const data = await resp.json(); const data = await resp.json();
if (data.status === 'completed') { 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>';
status.innerHTML = '<div class="success">VM ' + newVmid + ' cloned successfully!</div>'; } catch (err) { status.innerHTML = '<div class="pve-alert pve-alert-error">Clone failed: ' + err.message + '</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>';
}
} }
async function copyVM(jobId, vmid, sourceFilename) { 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; } if (!newVmid || !newName) { alert('Please fill VM Name and ID.'); return; }
const status = document.getElementById('copy-status'); const status = document.getElementById('copy-status');
status.classList.remove('pve-hidden'); 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(); const payload = new FormData();
payload.append('vmid', newVmid); payload.append('vmid', newVmid); payload.append('vm_name', newName);
payload.append('vm_name', newName);
payload.append('source_filename', sourceFilename); payload.append('source_filename', sourceFilename);
payload.append('disk_format', 'vmdk'); payload.append('disk_format', 'vmdk');
payload.append('cpu_cores', document.getElementById('copy-cores').value || '2'); 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 || ''); payload.append('session_id', window.VM_BENCH_SID || '');
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(); document.getElementById('polling-section').innerHTML = await resp.text();
document.getElementById('polling-section').innerHTML = html;
initPolling(); initPolling();
} catch (err) { } catch (err) { status.innerHTML = '<div class="pve-alert pve-alert-error">Failed: ' + err.message + '</div>'; }
status.innerHTML = '<div class="error">Failed: ' + err.message + '</div>';
}
} }
</script> </script>