fix: innerHTML script execution bug — confirm form and polling now work

Root cause: innerHTML does not execute <script> tags in modern browsers.
The _analysis.html and polling.html fragments had inline <script> blocks
that were silently dropped, causing:
- Confirm form's submitJob() undefined → form submitted as normal GET
  back to '/' (user saw 'New Conversion Session' instead of polling)
- Polling page's poll() never started → stuck on 'Waiting for backend...'

Fix:
- Stripped all <script> tags from _analysis.html and polling.html
- Moved all JS logic into index.html (executed on page load)
- initConfirmForm() attaches submit/change listeners after analysis HTML
  is injected via innerHTML
- initPolling() attaches poll timer + reuse button handlers after
  polling fragment is injected
- Polling fragment replaces step1/analysis content while keeping
  base.html header/footer intact
- Reuse buttons now use onclick attached via JS (not inline)
This commit is contained in:
Claus Lohmar 2026-07-21 18:43:16 +00:00
parent 267b64ec1b
commit 989807f29e
3 changed files with 167 additions and 229 deletions

View file

@ -1,7 +1,7 @@
{# Fragment returned by POST /session/start — analysis result + confirm form #}
{# Fragment — no <script> tags (injected via innerHTML) #}
{% if error %}
<div class="panel error-panel">
<div class="panel error-panel" id="analysis-result">
<h2>Analysis Failed</h2>
<div class="error">{{ error }}</div>
<a href="/" class="btn">Try Again</a>
@ -20,7 +20,7 @@
<p class="confirm-text">Is this correct? Configure the VM below and submit the conversion job.</p>
<form id="confirm-form" onsubmit="submitJob(event)">
<form id="confirm-form" data-vmid="{{ vmid }}" data-source-filename="{{ source_filename }}">
<input type="hidden" name="vmid" value="{{ vmid }}">
<input type="hidden" name="source_filename" value="{{ source_filename }}">
<input type="hidden" name="disk_format" value="{{ analysis.disk_format }}">
@ -76,30 +76,3 @@
<div id="submit-status" class="hidden" style="margin-top:1rem;"></div>
</div>
{% endif %}
<script>
document.querySelector('select[name="auto_detect_boot"]')?.addEventListener('change', function(e) {
document.getElementById('boot-type-group').style.display =
e.target.value === 'false' ? 'block' : 'none';
});
async function submitJob(e) {
e.preventDefault();
const btn = document.getElementById('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(e.target);
try {
const resp = await fetch('/session/confirm', { method: 'POST', body: formData });
const html = await resp.text();
document.body.innerHTML = html; // Replace page with polling view
} catch (err) {
status.innerHTML = `<div class="error">Failed: ${err.message}</div>`;
btn.disabled = false;
}
}
</script>

View file

@ -55,29 +55,42 @@
<!-- 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) {
document.getElementById('phase-label').innerHTML =
(isSpinner ? '<span class="spinner"></span> ' : '') + label;
const el = document.getElementById('phase-label');
if (el) el.innerHTML = (isSpinner ? '<span class="spinner"></span> ' : '') + label;
const fill = document.getElementById('progress-fill');
fill.style.width = pct + '%';
fill.textContent = pct > 0 ? pct + '%' : '';
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 = '';
// Reset progress area (clear old errors)
polling.innerHTML = '';
status.innerHTML = `
<div id="phase-label" style="margin-bottom:0.5rem;"></div>
<div class="progress-bar">
@ -89,7 +102,6 @@ async function startSession(e) {
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);
@ -99,9 +111,8 @@ async function startSession(e) {
const fileInput = document.getElementById('source-file');
if (fileInput.files.length === 0) {
setPhase('Please select a file.', 0, false);
status.innerHTML += '<div class="error" style="margin-top:0.5rem;">No file selected.</div>';
btn.disabled = false;
return;
showError('No file selected.');
btn.disabled = false; return;
}
formData.append('source_file', fileInput.files[0]);
await handleUpload(formData);
@ -109,125 +120,68 @@ async function startSession(e) {
const url = document.getElementById('source-url').value.trim();
if (!url) {
setPhase('Please enter a URL.', 0, false);
status.innerHTML += '<div class="error" style="margin-top:0.5rem;">No URL provided.</div>';
btn.disabled = false;
return;
showError('No URL provided.');
btn.disabled = false; return;
}
formData.append('source_url', url);
await handleDownload(formData);
}
btn.disabled = false;
}
// ── Upload (browser-native progress) ──────────────────────────────
async function handleUpload(formData) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/session/upload');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
const gb = (e.loaded / (1024**3)).toFixed(1);
setPhase('Uploading source file... ' + gb + ' GiB', pct, false);
setPhase('Uploading source file... ' + (e.loaded / (1024**3)).toFixed(1) + ' GiB', pct, false);
}
});
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');
}
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 {
try {
const err = JSON.parse(xhr.responseText);
setPhase('Upload failed', 0, false);
showError(err.error || 'Server error ' + xhr.status);
} catch (_) {
setPhase('Upload failed', 0, false);
showError('Server error ' + xhr.status);
}
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 during upload.');
resolve();
});
xhr.addEventListener('error', () => { setPhase('Upload failed', 0, false); showError('Network error.'); resolve(); });
xhr.send(formData);
});
}
// ── Download (polled progress) ────────────────────────────────────
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;
}
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 === 'error') { setPhase('Download failed', 0, false); showError(data.error); return; }
if (data.phase !== 'downloading') { setPhase('Unexpected: ' + data.phase, 0, false); return; }
if (data.phase !== 'downloading') {
setPhase('Unexpected phase: ' + data.phase, 0, false);
return;
}
// Poll for progress
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;
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 === '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 — it would take too long.</strong><br><br>' +
(pdata.message || '') +
'<br><br>Suggested next step:' +
'<ol style="margin-top:0.5rem; padding-left:1.2rem;">' +
'<li>Stop this download and download the file manually to your local computer.</li>' +
'<li>Switch to <strong>File Upload</strong> above and upload the file.</li>' +
'</ol>'
);
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';
@ -235,39 +189,145 @@ async function handleDownload(formData) {
if (pdata.eta) label += ' — ' + pdata.eta;
setPhase(label, 0, true);
}
} catch (_) {
// Polling error — keep trying
}
} 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);
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');
} catch (err) {
setPhase('Analysis failed', 0, false);
showError('Failed to reach analysis endpoint: ' + err.message);
// 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';
});
}
}
function showError(msg) {
const status = document.getElementById('session-status');
status.innerHTML += '<div class="error" style="margin-top:0.5rem;">' + msg + '</div>';
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;
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
// ── 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>

View file

@ -1,19 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Converting VM — VM Bench</title>
<link rel="stylesheet" href="/static/proxmox.css">
</head>
<body>
<div class="container" id="polling-container"
data-job-id="{{ job_id }}" data-vmid="{{ vmid }}"
data-source-filename="{{ source_filename }}" data-vm-name="{{ vm_name }}">
<header>
<div class="logo">VM Bench</div>
<div class="sub">Proxmox Image Conversion</div>
</header>
<div class="container">
<div class="panel poll-panel">
<h2>Converting VM {{ vmid }}</h2>
<p class="dim">Job: <code>{{ job_id }}</code> &nbsp;|&nbsp; {{ vm_name }}</p>
@ -23,7 +11,7 @@
</div>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill" style="width:0%">0%</div>
<div class="progress-fill" id="conversion-fill" style="width:0%">0%</div>
</div>
<p id="status-message" class="dim">Waiting for backend...</p>
@ -31,95 +19,12 @@
<div id="error-block" class="error hidden"></div>
</div>
<!-- Reuse section (shown after completion) -->
<div id="reuse-section" class="panel hidden">
<h2>Conversion Complete</h2>
<p>Use the same source image to create another VM?</p>
<div class="btn-row">
<button onclick="reuseImage(true)" class="btn">Yes — Create Another VM</button>
<button onclick="reuseImage(false)" class="btn btn-secondary">No — Clean Up &amp; Finish</button>
<button id="btn-reuse-yes" class="btn">Yes — Create Another VM</button>
<button id="btn-reuse-no" class="btn btn-secondary">No — Clean Up &amp; Finish</button>
</div>
</div>
</div>
<footer>
<span>vm-bench frontend</span>
<span>Job: {{ job_id }}</span>
</footer>
<script>
const jobId = "{{ job_id }}";
const vmid = "{{ vmid }}";
const startTime = Date.now();
let completed = false;
async function poll() {
try {
const resp = await fetch(`/session/status/${jobId}`);
const data = await resp.json();
const pct = data.progress_percentage || 0;
document.getElementById('progress-fill').style.width = pct + '%';
document.getElementById('progress-fill').textContent = pct + '%';
const elapsed = Math.round((Date.now() - startTime) / 1000);
document.getElementById('elapsed').textContent = `Elapsed: ${elapsed}s`;
document.getElementById('status-message').textContent = data.message || '';
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];
badge.className = 'badge ' + cls;
badge.innerHTML = (data.status === 'processing_conversion' || data.status === 'importing_storage')
? '<span class="spinner"></span> ' + text : text;
if (data.error_details) {
document.getElementById('error-block').classList.remove('hidden');
document.getElementById('error-block').textContent = data.error_details;
}
if (data.status === 'completed' || data.status === 'failed') {
completed = true;
if (data.status === 'completed') {
document.getElementById('reuse-section').classList.remove('hidden');
document.getElementById('reuse-section').scrollIntoView({ behavior: 'smooth' });
}
return;
}
if (!completed) setTimeout(poll, 2000);
} catch (err) {
document.getElementById('error-block').classList.remove('hidden');
document.getElementById('error-block').textContent = 'Polling error: ' + err.message;
if (!completed) setTimeout(poll, 5000);
}
}
async function reuseImage(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={{ source_filename }}`;
} else {
window.location.href = '/';
}
} catch (err) {
alert('Cleanup failed: ' + err.message);
}
}
// Wait 3 seconds before first poll (backend needs time to queue)
setTimeout(poll, 3000);
</script>
</body>
</html>