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:
parent
267b64ec1b
commit
989807f29e
3 changed files with 167 additions and 229 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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> | {{ 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 & 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 & 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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue