vm-bench/frontend/templates/index.html

349 lines
14 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>
<!-- 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('hidden', type !== 'upload');
document.getElementById('url-group').classList.toggle('hidden', type !== 'url');
}
// ── Progress helpers ──────────────────────────────────────────────
function setPhase(label, pct, isSpinner) {
const el = document.getElementById('phase-label');
const fill = document.getElementById('progress-fill');
console.log('[setPhase] el:', !!el, 'fill:', !!fill, 'label:', label, 'pct:', pct);
if (el) el.innerHTML = (isSpinner ? '<span class="spinner"></span> ' : '') + label;
}
function showError(msg) {
const status = document.getElementById('session-status');
if (status) status.innerHTML += '<div class="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');
const status = document.getElementById('session-status');
const analysis = document.getElementById('analysis-section');
const polling = document.getElementById('polling-section');
btn.disabled = true;
analysis.innerHTML = '';
polling.innerHTML = '';
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);
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);
}
btn.disabled = false;
}
async function handleUpload(formData) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/session/upload');
xhr.timeout = 7200000; // 2 hour timeout for very large uploads
xhr.upload.addEventListener('progress', (e) => {
console.log('[upload] progress', e.loaded, e.total, e.lengthComputable);
if (e.lengthComputable) {
setPhase('Uploading source file... ' + (e.loaded / (1024**3)).toFixed(1) + ' GiB', pct, false);
} else {
// Fallback: show bytes uploaded even without total
setPhase('Uploading source file... ' + (e.loaded / (1024**3)).toFixed(1) + ' GiB', 0, true);
}
});
xhr.addEventListener('loadstart', () => {
console.log('[upload] 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(formData);
});
}
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: ' + data.phase, 0, false); return; }
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>.');
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 (_) { /* keep polling */ }
}
}
// ── 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');
// Attach event listeners since innerHTML doesn't execute <script>
initConfirmForm();
} catch (err) { setPhase('Analysis failed', 0, false); showError('Failed: ' + err.message); }
}
// ── 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() {
document.getElementById('boot-type-group').style.display =
this.value === 'false' ? 'block' : 'none';
});
}
}
async function submitJob(e) {
e.preventDefault();
const form = e.target;
const btn = form.querySelector('#submit-btn');
const status = document.getElementById('submit-status');
btn.disabled = true;
status.classList.remove('hidden');
status.innerHTML = '<div class="spinner"></div> Submitting job...';
const formData = new FormData(form);
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>';
btn.disabled = false;
}
}
// ── Phase 4: Polling (attached after injection) ──────────────────
let _pollTimer = null;
function initPolling() {
const container = document.getElementById('polling-container');
if (!container) return;
const jobId = container.dataset.jobId;
const vmid = container.dataset.vmid;
const sourceFilename = container.dataset.sourceFilename;
const startTime = Date.now();
let completed = false;
// Reuse buttons
document.getElementById('btn-reuse-yes').onclick = () => reuseImage(jobId, vmid, sourceFilename, true);
document.getElementById('btn-reuse-no').onclick = () => reuseImage(jobId, vmid, sourceFilename, false);
async function poll() {
try {
const resp = await fetch('/session/status/' + jobId);
const data = await resp.json();
const pct = data.progress_percentage || 0;
const fill = document.getElementById('conversion-fill');
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';
const badge = document.getElementById('status-badge');
const map = {
'queued': ['badge-queued', 'Queued'],
'processing_conversion': ['badge-running', 'Converting...'],
'importing_storage': ['badge-running', 'Importing...'],
'completed': ['badge-completed', 'Completed'],
'failed': ['badge-failed', 'Failed'],
};
const [cls, text] = map[data.status] || ['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('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('hidden'); reuse.scrollIntoView({ behavior: 'smooth' }); }
}
return;
}
if (!completed) _pollTimer = setTimeout(poll, 2000);
} catch (err) {
const errBlock = document.getElementById('error-block');
if (errBlock) { errBlock.classList.remove('hidden'); errBlock.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: !keep }),
});
if (keep) {
window.location.href = '/?vmid=' + vmid + '&source=' + encodeURIComponent(sourceFilename);
} else {
window.location.href = '/';
}
} catch (err) { alert('Cleanup failed: ' + err.message); }
}
</script>
{% endblock %}