vm-bench/frontend/templates/index.html

431 lines
No EOL
19 KiB
HTML

{% extends "base.html" %}
{% block content %}
<div class="pve-hero">
<h1>VM Bench</h1>
<p>Convert VMware images to Proxmox VMs &mdash; automatic OS detection, disk shrink, EFI probing.</p>
</div>
<div id="resume-sessions" class="pve-panel pve-hidden">
<div class="pve-panel-header">
<span class="pve-panel-title">Resume Previous Sessions</span>
</div>
<div class="pve-panel-body" id="resume-sessions-list"></div>
</div>
<div class="landing-cards">
<div class="landing-card" id="card-download">
<div class="landing-card-icon">&#x1F4E5;</div>
<h3>Download</h3>
<p>Pull a disk image or archive from a URL via high-speed download.</p>
<button class="pve-btn pve-btn-primary" onclick="showDownload()">Start</button>
</div>
<div class="landing-card" id="card-scp">
<div class="landing-card-icon">&#x1F4E1;</div>
<h3>SCP Pull</h3>
<p>Transfer files directly from a remote server via SCP &mdash; no middle hop.</p>
<a href="/scp" class="pve-btn pve-btn-primary">Start</a>
</div>
</div>
<div id="download-section" class="pve-panel pve-hidden">
<div class="pve-panel-header">
<span class="pve-panel-title" id="download-title">New Download Session</span>
</div>
<div class="pve-panel-body">
<form id="session-form" onsubmit="startSession(event)">
<div class="pve-row">
<div class="pve-form-group">
<label>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 '' }}">
</div>
<div class="pve-form-group">
<label>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&ndash;21100. Must be unique on host.</span>
</div>
</div>
<div class="pve-form-group" id="url-group">
<label>Download URL *</label>
<input type="url" id="source-url" name="source_url" required class="pve-input"
placeholder="https://example.com/image.7z">
<span class="pve-hint">Direct URL to a disk image or archive (.vmdk, .7z, .zip, etc.)</span>
</div>
<button type="submit" id="start-btn" class="pve-btn pve-btn-primary">Download &amp; Analyse</button>
<button type="button" class="pve-btn" onclick="hideDownload()" style="margin-left:0.5rem;">Cancel</button>
</form>
<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>
<div id="analysis-section"></div>
<div id="polling-section"></div>
<script>
loadSessions();
function showDownload() {
document.getElementById('card-download').style.display = 'none';
document.getElementById('card-scp').style.display = 'none';
document.getElementById('download-section').classList.remove('pve-hidden');
document.getElementById('download-title').textContent = 'New Download Session';
document.getElementById('url-group').classList.remove('pve-hidden');
document.getElementById('start-btn').textContent = 'Download & Analyse';
document.getElementById('start-btn').onclick = null; // restore form submit
document.getElementById('source-url').required = true;
}
function hideDownload() {
document.getElementById('card-download').style.display = '';
document.getElementById('card-scp').style.display = '';
document.getElementById('download-section').classList.add('pve-hidden');
window.RESUMED_SID = null;
}
async function loadSessions() {
try {
var resp = await fetch('/session/sessions');
var data = await resp.json();
var sessions = data.sessions || [];
if (!sessions.length) return;
var panel = document.getElementById('resume-sessions');
var list = document.getElementById('resume-sessions-list');
panel.classList.remove('pve-hidden');
var html = '';
for (var i = 0; i < sessions.length; i++) {
var s = sessions[i];
var fileNames = s.files.map(function(f) { return f.name; }).join(', ');
var firstFile = s.files[0].name;
html += '<div style="display:flex;align-items:center;justify-content:space-between;padding:0.4rem 0;border-bottom:1px solid var(--pve-border);">';
html += '<div><span class="pve-code">' + s.session_id.substring(0, 8) + '</span> &mdash; ' + fileNames + ' <span class="pve-dim">(' + s.total_size_gb + ' GiB)</span>';
if (s.has_disks) html += ' <span class="pve-badge pve-badge-completed" style="font-size:0.65rem;">extracted</span>';
html += '</div>';
if (s.resumable) {
html += '<button class="pve-btn pve-btn-primary pve-btn-small" onclick="resumeSession(\'' + s.session_id + '\', \'' + firstFile.replace(/'/g, "\\'") + '\')">Resume</button>';
} else {
html += '<button class="pve-btn pve-btn-small" onclick="cleanupSession(\'' + s.session_id + '\', this)">Clean Up</button>';
}
html += '</div>';
}
list.innerHTML = html;
} catch (_) {}
}
function resumeSession(sessionId, firstFile) {
document.getElementById('card-download').style.display = 'none';
document.getElementById('card-scp').style.display = 'none';
document.getElementById('download-section').classList.remove('pve-hidden');
document.getElementById('download-title').textContent = 'Resume Session';
document.getElementById('url-group').classList.add('pve-hidden');
var btn = document.getElementById('start-btn');
btn.textContent = 'Resume Analysis';
btn.onclick = function(e) {
e.preventDefault();
var vmid = document.getElementById('vmid').value;
var vmName = document.getElementById('vm-name').value.trim();
if (!vmid) { alert('Please enter a VM ID.'); return; }
window.RESUMED_SID = sessionId;
document.getElementById('resume-sessions').classList.add('pve-hidden');
var status = document.getElementById('session-status');
status.classList.remove('pve-hidden');
setPhase('Analysing source image...', 0, true);
runAnalysis(vmid, firstFile, vmName, sessionId);
};
document.getElementById('source-url').required = false;
}
function cleanupSession(sessionId, btn) {
if (!confirm('Delete session ' + sessionId.substring(0, 8) + '?')) return;
btn.disabled = true;
btn.textContent = '...';
fetch('/session/cleanup/cleanup_' + sessionId, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_staging_files: true, session_id: sessionId })
}).then(function() { loadSessions(); });
}
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="pve-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="pve-alert pve-alert-error" style="margin-top:0.5rem;">' + msg + '</div>';
}
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
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 = '';
document.getElementById('session-error').textContent = '';
const vmid = document.getElementById('vmid').value;
const vmName = document.getElementById('vm-name').value.trim();
const url = document.getElementById('source-url').value.trim();
if (!url) { showError('Please enter a download URL.'); btn.disabled = false; return; }
const sessionId = window.VM_BENCH_SID || '';
const formData = new FormData();
formData.append('vmid', vmid);
formData.append('vm_name', vmName);
formData.append('source_type', 'url');
formData.append('source_url', url);
formData.append('session_id', sessionId);
status.classList.remove('pve-hidden');
setPhase('Starting download...', 0, true);
try { await handleDownload(formData); }
catch (err) { setPhase('Download failed', 0, false); showError(err.message); }
btn.disabled = false;
}
async function handleDownload(formData) {
let resp;
try { resp = await fetch('/session/upload', { method: 'POST', body: formData }); }
catch (err) { throw new Error('Network error: ' + err.message); }
const data = await resp.json();
if (data.phase === 'error') { throw new Error(data.error); }
if (data.phase !== 'downloading') { throw new Error('Unexpected: ' + data.phase); }
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') { throw new Error(pdata.error); }
if (pdata.phase === 'too_slow') {
throw new Error('<strong>Download too slow.</strong><br><br>' + (pdata.message || '') +
'<br><br>Try downloading to a server and using SCP Pull instead.');
}
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;
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 (_) {}
}
}
async function runAnalysis(vmid, filename, vmName, sessionOverride) {
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);
fd.append('session_id', sessionOverride || window.VM_BENCH_SID || '');
const resp = await fetch('/session/analyze', { method: 'POST', body: fd });
const data = await resp.json();
if (data.phase === 'error') {
setPhase('Analysis failed', 0, false);
showError(data.error);
return;
}
var analysisId = data.analysis_id;
if (!analysisId) {
setPhase('Analysis failed', 0, false);
showError('Backend returned invalid response — may need restart.');
return;
}
var startTime = Date.now();
var pollErrors = 0;
for (;;) {
await sleep(2000);
try {
var pr = await fetch('/session/analyze/status/' + analysisId);
var sdata = await pr.json();
var elapsed = Math.round((Date.now() - startTime) / 1000);
setPhase('Analysing... ' + (sdata.message || '') + ' (' + elapsed + 's)', sdata.status === 'completed' ? 100 : 50, sdata.status !== 'completed');
if (sdata.status === 'completed') {
await sleep(500);
var sid = window.RESUMED_SID || window.VM_BENCH_SID || '';
var rr = await fetch('/session/analyze/result/' + analysisId + '?source_filename=' + encodeURIComponent(filename) + '&vm_name=' + encodeURIComponent(vmName) + '&vmid=' + vmid + '&session_id=' + encodeURIComponent(sid));
var html = await rr.text();
document.getElementById('analysis-section').innerHTML = html;
document.getElementById('session-status').classList.add('pve-hidden');
initConfirmForm();
return;
}
if (sdata.status === 'failed') {
setPhase('Analysis failed', 0, false);
showError(sdata.error_details || 'Analysis failed');
return;
}
if (sdata.error) {
pollErrors++;
if (pollErrors > 15) {
setPhase('Analysis failed', 0, false);
showError('Backend unavailable — analysis may need restart.');
return;
}
} else {
pollErrors = 0;
}
} catch (_) {}
}
}
function initConfirmForm() {
const form = document.getElementById('confirm-form');
if (!form) return;
form.addEventListener('submit', submitJob);
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="pve-spinner"></div> Submitting job...';
const formData = new FormData(form);
var sid = formData.get('session_id') || window.VM_BENCH_SID || '';
formData.set('session_id', sid);
try {
const resp = await fetch('/session/confirm', { method: 'POST', body: formData });
const html = await resp.text();
document.getElementById('download-section').style.display = 'none';
document.getElementById('analysis-section').innerHTML = '';
document.getElementById('polling-section').innerHTML = html;
initPolling();
} catch (err) {
status.innerHTML = '<div class="pve-alert pve-alert-error">Failed: ' + err.message + '</div>';
btn.disabled = false;
}
}
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;
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);
const prevVmid = parseInt(vmid) || 21000;
document.getElementById('reuse-vmid').value = prevVmid + 1;
document.getElementById('reuse-vmname').value = (sourceFilename.split('.')[0] || 'vm') + '-2';
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 || '';
document.getElementById('elapsed').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="pve-spinner"></span> ' + text : text; }
if (data.error_details) { const eb = document.getElementById('error-block'); if (eb) { eb.classList.remove('pve-hidden'); eb.textContent = data.error_details; } }
if (data.status === 'completed' || data.status === 'failed') { completed = true; if (data.status === 'completed') { const rs = document.getElementById('reuse-section'); if (rs) { rs.classList.remove('pve-hidden'); rs.scrollIntoView({ behavior: 'smooth' }); } } return; }
if (!completed) _pollTimer = setTimeout(poll, 2000);
} catch (err) { const eb = document.getElementById('error-block'); if (eb) { eb.classList.remove('pve-hidden'); eb.textContent = 'Polling error: ' + err.message; } if (!completed) _pollTimer = setTimeout(poll, 5000); }
}
_pollTimer = setTimeout(poll, 3000);
}
async function reuseImage(jobId, vmid, sourceFilename, keep) {
await fetch('/session/cleanup/' + jobId, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ delete_staging_files: true, session_id: window.VM_BENCH_SID || '' }) });
window.location.href = '/';
}
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="pve-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();
status.innerHTML = data.status === 'completed' ? '<div class="pve-alert pve-alert-success">VM ' + newVmid + ' cloned!</div>' : '<div class="pve-alert pve-alert-error">Clone failed: ' + (data.error_details || 'Unknown') + '</div>';
} catch (err) { status.innerHTML = '<div class="pve-alert pve-alert-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="pve-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', 'legacy');
payload.append('session_id', window.VM_BENCH_SID || '');
try {
const resp = await fetch('/session/confirm', { method: 'POST', body: payload });
document.getElementById('polling-section').innerHTML = await resp.text();
initPolling();
} catch (err) { status.innerHTML = '<div class="pve-alert pve-alert-error">Failed: ' + err.message + '</div>'; }
}
</script>
{% endblock %}