428 lines
18 KiB
HTML
428 lines
18 KiB
HTML
{% extends "base.html" %}
|
||
{% block content %}
|
||
|
||
<!-- Step 1: Start New Session -->
|
||
<div class="pve-panel" id="step1">
|
||
<div class="pve-panel-header">
|
||
<span class="pve-panel-title">New Conversion Session</span>
|
||
</div>
|
||
<div class="pve-panel-body">
|
||
|
||
<form id="session-form" onsubmit="startSession(event)">
|
||
<div class="pve-form-group">
|
||
<label for="vm-name">VM Name *</label>
|
||
<input type="text" id="vm-name" name="vm_name" required class="pve-input"
|
||
placeholder="e.g. debian-test" value="{{ prefill_vmname or '' }}">
|
||
<span class="pve-hint">Display name for the VM on the Proxmox host.</span>
|
||
</div>
|
||
|
||
<div class="pve-form-group">
|
||
<label for="vmid">VM ID *</label>
|
||
<input type="number" id="vmid" name="vmid" required class="pve-input"
|
||
placeholder="e.g. 21050" min="21000" max="21100">
|
||
<span class="pve-hint">Range 21000–21100. Must be unique on the Proxmox host.</span>
|
||
</div>
|
||
|
||
<div class="pve-form-group">
|
||
<label for="source-type">Source Type</label>
|
||
<select id="source-type" class="pve-select" onchange="toggleSourceInput(event)">
|
||
<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">
|
||
</div>
|
||
|
||
<button type="submit" id="start-btn" class="pve-btn pve-btn-primary">Analyse Source Image</button>
|
||
</form>
|
||
|
||
<hr class="pve-divider">
|
||
<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:
|
||
</p>
|
||
<a href="/scp" class="pve-btn">SCP Pull (Large Files +10 GB)</a>
|
||
|
||
<!-- Progress area (hidden until form submit) -->
|
||
<div id="session-status" class="pve-hidden" style="margin-top:1rem;">
|
||
<div id="phase-label" style="margin-bottom:0.5rem;"></div>
|
||
<div class="pve-progress">
|
||
<div class="pve-progress-bar" id="pve-progress-bar" style="width:0%">0%</div>
|
||
</div>
|
||
<div id="session-error"></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) {
|
||
console.log('toggleSourceInput called', e.target.value);
|
||
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) {
|
||
const el = document.getElementById('phase-label');
|
||
const fill = document.getElementById('pve-progress-bar');
|
||
if (el) el.innerHTML = (isSpinner ? '<span class="spinner"></span> ' : '') + label;
|
||
if (fill) { fill.style.width = pct + '%'; fill.textContent = pct > 0 ? pct + '%' : ''; }
|
||
}
|
||
|
||
function showError(msg) {
|
||
const el = document.getElementById('session-error');
|
||
if (el) el.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 = '';
|
||
// 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 = '';
|
||
status.classList.remove('pve-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();
|
||
const file = formData.get('source_file');
|
||
const vmid = formData.get('vmid');
|
||
const vmName = formData.get('vm_name');
|
||
|
||
// 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.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('pve-alert pve-alert-error', () => { setPhase('Upload failed', 0, false); showError('Network error.'); resolve(); });
|
||
xhr.send(file); // raw binary — no multipart overhead
|
||
});
|
||
}
|
||
|
||
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 === 'pve-alert pve-alert-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 === 'pve-alert pve-alert-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;
|
||
// Show percentage if we know total size
|
||
const totalGb = pdata.content_length_gb;
|
||
const pct = totalGb ? Math.round((pdata.file_size_gb / totalGb) * 100) : 0;
|
||
setPhase(label, pct, totalGb ? false : 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('pve-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('pve-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
|
||
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-copy').onclick = () => {
|
||
document.getElementById('copy-options').classList.remove('pve-hidden');
|
||
};
|
||
document.getElementById('btn-cleanup').onclick = () => reuseImage(jobId, vmid, sourceFilename, false);
|
||
document.getElementById('btn-copy-start').onclick = () => copyVM(jobId, vmid, sourceFilename);
|
||
|
||
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('pve-progress-bar');
|
||
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': ['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];
|
||
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('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);
|
||
} catch (err) {
|
||
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);
|
||
}
|
||
|
||
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 }),
|
||
});
|
||
window.location.href = '/';
|
||
} catch (err) { alert('Cleanup failed: ' + err.message); }
|
||
}
|
||
|
||
async function cloneVM(jobId, sourceVmid) {
|
||
const newVmid = parseInt(document.getElementById('reuse-vmid').value) || 0;
|
||
const newName = document.getElementById('reuse-vmname').value.trim();
|
||
if (!newVmid || !newName) { alert('Please fill VM Name and ID.'); return; }
|
||
const status = document.getElementById('reuse-submit-status');
|
||
status.classList.remove('pve-hidden');
|
||
status.innerHTML = '<div class="spinner"></div> Cloning VM...';
|
||
try {
|
||
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 }),
|
||
});
|
||
const data = await resp.json();
|
||
if (data.status === 'completed') {
|
||
status.innerHTML = '<div class="success">VM ' + newVmid + ' cloned successfully!</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) {
|
||
const newVmid = parseInt(document.getElementById('reuse-vmid').value) || 0;
|
||
const newName = document.getElementById('reuse-vmname').value.trim();
|
||
if (!newVmid || !newName) { alert('Please fill VM Name and ID.'); return; }
|
||
const status = document.getElementById('copy-status');
|
||
status.classList.remove('pve-hidden');
|
||
status.innerHTML = '<div class="spinner"></div> Submitting job...';
|
||
const payload = new FormData();
|
||
payload.append('vmid', newVmid);
|
||
payload.append('vm_name', newName);
|
||
payload.append('source_filename', sourceFilename);
|
||
payload.append('disk_format', 'vmdk');
|
||
payload.append('cpu_cores', document.getElementById('copy-cores').value || '2');
|
||
payload.append('ram_mb', document.getElementById('copy-ram').value || '4096');
|
||
payload.append('target_storage', document.getElementById('copy-storage').value || 'local-lvm');
|
||
const diskGb = document.getElementById('copy-disk').value;
|
||
if (diskGb) payload.append('target_disk_size_gb', diskGb);
|
||
payload.append('auto_detect_boot', 'true');
|
||
payload.append('boot_type', 'uefi');
|
||
try {
|
||
const resp = await fetch('/session/confirm', { method: 'POST', body: payload });
|
||
const html = await resp.text();
|
||
document.getElementById('polling-section').innerHTML = html;
|
||
initPolling();
|
||
} catch (err) {
|
||
status.innerHTML = '<div class="error">Failed: ' + err.message + '</div>';
|
||
}
|
||
}
|
||
</script>
|
||
|
||
{% endblock %}
|