Added loadstart handler to show 'Uploading...' spinner as soon as upload begins, plus fallback label when lengthComputable is false (shows bytes uploaded even without percentage).
348 lines
14 KiB
HTML
348 lines
14 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>
|
||
|
||
<!-- 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');
|
||
if (el) el.innerHTML = (isSpinner ? '<span class="spinner"></span> ' : '') + label;
|
||
const fill = document.getElementById('progress-fill');
|
||
if (fill) { fill.style.width = pct + '%'; fill.textContent = pct > 0 ? pct + '%' : ''; }
|
||
}
|
||
|
||
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) => {
|
||
if (e.lengthComputable) {
|
||
const pct = Math.round((e.loaded / e.total) * 100);
|
||
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', () => {
|
||
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 %}
|