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
274 lines
9.1 KiB
HTML
274 lines
9.1 KiB
HTML
{% 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 21000–21100. 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 %}
|