vm-bench/frontend/templates/index.html

379 lines
17 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 %}
<div id="resume-sessions" class="pve-panel pve-hidden">
<div class="pve-panel-header">
<span class="pve-panel-title">Resume Previous Session</span>
</div>
<div class="pve-panel-body" id="resume-sessions-list">
</div>
</div>
<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 2100021100. Must be unique on the Proxmox host.</span>
</div>
<div class="pve-form-group">
<label for="source-url">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>
</form>
<hr class="pve-divider">
<p class="pve-dim" style="font-size:0.78rem; margin-bottom:0.5rem;">
Files on a remote server? Pull directly via SCP:
</p>
<a href="/scp" class="pve-btn">SCP Pull</a>
<!-- Progress area -->
<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();
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>';
html += '<button class="pve-btn pve-btn-primary pve-btn-small" onclick="resumeSession(\'' + s.session_id + '\', \'' + firstFile.replace(/'/g, "\\'") + '\')">Resume</button>';
html += '</div>';
}
list.innerHTML = html;
} catch (_) {}
}
function resumeSession(sessionId, firstFile) {
var vmid = document.getElementById('vmid').value;
var vmName = document.getElementById('vm-name').value.trim();
if (!vmid) { alert('Please enter a VM ID first.'); return; }
window.RESUMED_SID = sessionId;
var ses = document.getElementById('resume-sessions');
ses.classList.add('pve-hidden');
document.getElementById('step1').style.display = 'none';
var status = document.getElementById('session-status');
status.classList.remove('pve-hidden');
setPhase('Analysing source image...', 0, true);
runAnalysis(vmid, firstFile, vmName, sessionId);
}
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('step1').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 %}