vm-bench/frontend/templates/index.html
Claus Lohmar af26da7b64 feat: two-phase upload/download with real-time progress + smart speed timeout
Split monolithic /session/start into two endpoints:
- POST /session/upload  — phase 1: file acquisition only, returns JSON
- POST /session/analyze — phase 2: backend analysis, returns HTML
- GET  /session/progress/{filename} — poll download progress

Uploads:
- Browser-native progress bar via XMLHttpRequest (real % + GiB)

Downloads:
- wget runs in background (Popen), frontend polls /session/progress
- HEAD request gets Content-Length before download starts
- Real-time speed (MB/s) and ETA displayed in the UI
- Smart timeout: after 30s, if ETA > 1 hour, kills download and
  suggests manual download to laptop + File Upload instead
- Absolute safety net at 4 hours

UI: clear phase transitions — 'Downloading... 2.3 GiB (4.5 MB/s) ~12 min'
→ 'Step 2/2: Analysing source image...' → result
2026-07-21 18:10:52 +00:00

274 lines
9.1 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block content %}
<!-- Step 1: Start New Session -->
<div class="panel" id="step1">
<h2>New Conversion Session</h2>
<form id="session-form" onsubmit="startSession(event)">
<div class="form-group">
<label for="vm-name">VM Name *</label>
<input type="text" id="vm-name" name="vm_name" required
placeholder="e.g. debian-test" value="{{ prefill_vmname or '' }}">
<span class="hint">Display name for the VM on the Proxmox host.</span>
</div>
<div class="form-group">
<label for="vmid">VM ID *</label>
<input type="number" id="vmid" name="vmid" required
placeholder="e.g. 21050" min="21000" max="21100">
<span class="hint">Range 2100021100. Must be unique on the Proxmox host.</span>
</div>
<div class="form-group">
<label for="source-type">Source Type</label>
<select id="source-type" onchange="toggleSourceInput(event)">
<option value="upload">File Upload</option>
<option value="url">Download from URL</option>
</select>
</div>
<div class="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"
accept=".vmdk,.vhd,.vhdx,.7z,.zip,.tar.gz,.tgz,.tar,.gz">
</div>
<div class="form-group hidden" id="url-group">
<label for="source-url">Download URL</label>
<input type="url" id="source-url" name="source_url"
placeholder="https://example.com/image.7z">
</div>
<button type="submit" id="start-btn">Analyse Source Image</button>
</form>
<!-- Progress area (hidden until form submit) -->
<div id="session-status" class="hidden" style="margin-top:1rem;">
<div id="phase-label" style="margin-bottom:0.5rem;"></div>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill" style="width:0%">0%</div>
</div>
</div>
</div>
<!-- Step 2: Analysis Result (populated by JavaScript) -->
<div id="analysis-section"></div>
<script>
function toggleSourceInput(e) {
const type = e.target.value;
document.getElementById('upload-group').classList.toggle('hidden', type !== 'upload');
document.getElementById('url-group').classList.toggle('hidden', type !== 'url');
}
function setPhase(label, pct, isSpinner) {
document.getElementById('phase-label').innerHTML =
(isSpinner ? '<span class="spinner"></span> ' : '') + label;
const fill = document.getElementById('progress-fill');
fill.style.width = pct + '%';
fill.textContent = pct > 0 ? pct + '%' : '';
}
async function startSession(e) {
e.preventDefault();
const btn = document.getElementById('start-btn');
const status = document.getElementById('session-status');
const analysis = document.getElementById('analysis-section');
btn.disabled = true;
analysis.innerHTML = '';
// Reset progress area (clear old errors)
status.innerHTML = `
<div id="phase-label" style="margin-bottom:0.5rem;"></div>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill" style="width:0%">0%</div>
</div>
`;
status.classList.remove('hidden');
const vmid = document.getElementById('vmid').value;
const vmName = document.getElementById('vm-name').value.trim();
const sourceType = document.getElementById('source-type').value;
const formData = new FormData();
formData.append('vmid', vmid);
formData.append('vm_name', vmName);
formData.append('source_type', sourceType);
if (sourceType === 'upload') {
const fileInput = document.getElementById('source-file');
if (fileInput.files.length === 0) {
setPhase('Please select a file.', 0, false);
status.innerHTML += '<div class="error" style="margin-top:0.5rem;">No file selected.</div>';
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);
status.innerHTML += '<div class="error" style="margin-top:0.5rem;">No URL provided.</div>';
btn.disabled = false;
return;
}
formData.append('source_url', url);
await handleDownload(formData);
}
btn.disabled = false;
}
// ── Upload (browser-native progress) ──────────────────────────────
async function handleUpload(formData) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/session/upload');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
const gb = (e.loaded / (1024**3)).toFixed(1);
setPhase('Uploading source file... ' + gb + ' GiB', pct, false);
}
});
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 {
try {
const err = JSON.parse(xhr.responseText);
setPhase('Upload failed', 0, false);
showError(err.error || 'Server error ' + xhr.status);
} catch (_) {
setPhase('Upload failed', 0, false);
showError('Server error ' + xhr.status);
}
}
resolve();
});
xhr.addEventListener('error', () => {
setPhase('Upload failed', 0, false);
showError('Network error during upload.');
resolve();
});
xhr.send(formData);
});
}
// ── Download (polled progress) ────────────────────────────────────
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;
}
const data = await resp.json();
if (data.phase === 'error') {
setPhase('Download failed', 0, false);
showError(data.error);
return;
}
if (data.phase !== 'downloading') {
setPhase('Unexpected phase: ' + data.phase, 0, false);
return;
}
// Poll for progress
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 — it would take too long.</strong><br><br>' +
(pdata.message || '') +
'<br><br>Suggested next step:' +
'<ol style="margin-top:0.5rem; padding-left:1.2rem;">' +
'<li>Stop this download and download the file manually to your local computer.</li>' +
'<li>Switch to <strong>File Upload</strong> above and upload the file.</li>' +
'</ol>'
);
return;
}
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;
setPhase(label, 0, true);
}
} catch (_) {
// Polling error — keep trying
}
}
}
// ── 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);
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('hidden');
} catch (err) {
setPhase('Analysis failed', 0, false);
showError('Failed to reach analysis endpoint: ' + err.message);
}
}
function showError(msg) {
const status = document.getElementById('session-status');
status.innerHTML += '<div class="error" style="margin-top:0.5rem;">' + msg + '</div>';
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
</script>
{% endblock %}