feat: unified configure page — VM settings + multi-disk selection with per-disk shrink

- New /session/configure/{analysis_id} page with VM config + disk table
- Each disk has radio (boot selection) + shrink checkbox + size input
- Per-disk shrink control — check to enable, set custom size
- Disks sorted smallest first, boot disk pre-selected as largest
- Submit via /session/configure/submit, then /session/status/{job_id} polling
- Landpage download/SCP flow redirects to configure after analysis completes
This commit is contained in:
Claus Lohmar 2026-07-27 10:22:39 +00:00
parent d4726acd65
commit b000641827
5 changed files with 334 additions and 23 deletions

View file

@ -346,8 +346,8 @@ async def session_list():
return JSONResponse({"sessions": []}) return JSONResponse({"sessions": []})
@app.get("/session/analyze/result/{analysis_id}", response_class=HTMLResponse) @app.get("/session/configure/{analysis_id}", response_class=HTMLResponse)
async def session_analyze_result( async def session_configure(
request: Request, request: Request,
analysis_id: str, analysis_id: str,
vmid: str = "", vmid: str = "",
@ -355,16 +355,18 @@ async def session_analyze_result(
vm_name: str = "", vm_name: str = "",
session_id: str = "", session_id: str = "",
): ):
"""Render the analysis result after polling completes.""" """Full page with VM configuration + disk selection after analysis."""
try: try:
status = api.get_analysis(analysis_id) status = api.get_analysis(analysis_id)
result = status.get("result") result = status.get("result")
if not result: if not result:
return render("_analysis.html", request=request, return render("configure.html", request=request, analysis_id=analysis_id,
error="Analysis result not ready.") source_filename=source_filename, session_id=session_id,
disks=[], error="Analysis result not ready.")
except ApiError as exc: except ApiError as exc:
return render("_analysis.html", request=request, return render("configure.html", request=request, analysis_id=analysis_id,
error=f"Failed to fetch analysis: {exc.detail}") source_filename=source_filename, session_id=session_id,
disks=[], error=f"Failed: {exc.detail}")
pools = [] pools = []
try: try:
@ -376,12 +378,36 @@ async def session_analyze_result(
analysis = result analysis = result
name = vm_name.strip() name = vm_name.strip()
if not name: if not name:
name = (analysis.get("os_type") or "vm") + f"-{vmid}" name = (analysis.get("os_type") or "vm")
return render("_analysis.html", request=request, disks = analysis.get("all_disks", [])
vmid=vmid, source_filename=source_filename, if not disks:
vm_name=name, analysis=analysis, storage_pools=pools, disks = [{"filename": analysis.get("filename", ""), "format": analysis.get("disk_format", ""), "size_gb": analysis.get("disk_size_gb", 0)}]
session_id=session_id)
suggested_vmid = vmid if vmid else ""
return render("configure.html", request=request,
analysis_id=analysis_id, source_filename=source_filename,
session_id=session_id, vm_name=name, storage_pools=pools,
disks=disks, suggested_vmid=suggested_vmid)
@app.post("/session/configure/submit")
async def configure_submit(request: Request):
"""Receive config + disk selection, submit job to backend."""
body = await request.json()
try:
result = api.create_job(body)
return result
except ApiError as exc:
return JSONResponse({"error": exc.detail}, status_code=502)
@app.get("/session/status/{job_id}", response_class=HTMLResponse)
async def session_status_page(request: Request, job_id: str):
"""Full page showing job progress with polling."""
return render("job_polling.html", request=request, job_id=job_id,
vmid=0, vm_name="", session_id="")
@app.post("/session/confirm", response_class=HTMLResponse) @app.post("/session/confirm", response_class=HTMLResponse)

View file

@ -0,0 +1,197 @@
{% extends "base.html" %}
{% block content %}
<div class="pve-hero" style="padding-bottom:0.5rem;">
<h1 style="font-size:1.1rem;">VM Configuration</h1>
<p>Source: <span class="pve-code">{{ source_filename }}</span></p>
</div>
<div class="pve-panel">
<div class="pve-panel-header">
<span class="pve-panel-title">VM Settings</span>
</div>
<div class="pve-panel-body">
<div class="pve-row">
<div class="pve-form-group">
<label>VM ID *</label>
<input type="number" id="config-vmid" class="pve-input" required
min="21000" max="21100" placeholder="e.g. 21050" value="{{ suggested_vmid }}">
</div>
<div class="pve-form-group">
<label>VM Name *</label>
<input type="text" id="config-vmname" class="pve-input" required
placeholder="e.g. my-vm" value="{{ vm_name or '' }}">
</div>
</div>
<div class="pve-row">
<div class="pve-form-group">
<label>Storage Pool</label>
{% if storage_pools %}
<select id="config-storage" class="pve-select">
{% for pool in storage_pools %}
<option value="{{ pool.name }}">{{ pool.name }} ({{ pool.type }})</option>
{% endfor %}
<option value="__custom__">Custom...</option>
</select>
<input type="text" id="config-storage-custom" class="pve-input pve-hidden"
placeholder="Enter pool name" style="margin-top:0.3rem;">
{% else %}
<input type="text" id="config-storage" class="pve-input">
{% endif %}
</div>
<div class="pve-form-group">
<label>Boot Detection</label>
<select id="config-bootdetect" class="pve-select">
<option value="auto" selected>Auto-detect (recommended)</option>
<option value="uefi">UEFI (OVMF)</option>
<option value="legacy">Legacy BIOS (SeaBIOS)</option>
</select>
</div>
</div>
<div class="pve-row">
<div class="pve-form-group">
<label>CPU Cores</label>
<input type="number" id="config-cores" class="pve-input" value="2" min="1">
</div>
<div class="pve-form-group">
<label>RAM (MB)</label>
<input type="number" id="config-ram" class="pve-input" value="4096" min="512">
</div>
</div>
</div>
</div>
<div class="pve-panel">
<div class="pve-panel-header">
<span class="pve-panel-title">Disks Found</span>
</div>
<div class="pve-panel-body">
<table class="pve-table">
<thead>
<tr style="font-size:0.7rem;color:var(--pve-muted);">
<td style="width:40px;">Boot</td>
<td>Filename</td>
<td style="width:60px;">Format</td>
<td style="width:80px;">Size</td>
<td style="width:140px;">Resize</td>
</tr>
</thead>
<tbody id="disks-tbody">
{% for d in disks %}
<tr>
<td><input type="radio" name="boot_disk" value="{{ loop.index0 }}" {% if loop.first %}checked{% endif %}></td>
<td>{{ d.filename }}</td>
<td>{{ d.format }}</td>
<td>{{ d.size_gb }} GB</td>
<td>
<label style="display:flex;align-items:center;gap:0.3rem;font-size:0.72rem;white-space:nowrap;">
<input type="checkbox" class="disk-shrink-cb" onchange="toggleShrink(this, {{ loop.index0 }})">
to <input type="number" class="pve-input disk-shrink-size" id="shrink-{{ loop.index0 }}"
value="{{ d.size_gb }}" disabled style="width:70px;padding:0.2rem 0.3rem;font-size:0.72rem;"> GB
</label>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pve-alert pve-alert-info" style="margin-top:0.75rem;">
<strong>TIP:</strong> The disk with the operating system should be the boot disk.
Data disks will be attached as additional drives.
</div>
<button id="config-submit-btn" class="pve-btn pve-btn-primary" style="margin-top:0.75rem;" onclick="submitConfig()">Start Conversion</button>
<div id="config-submit-status" class="pve-hidden" style="margin-top:0.5rem;"></div>
</div>
</div>
<script>
var _analysisId = '{{ analysis_id }}';
var _sessionId = '{{ session_id }}';
function toggleShrink(cb, idx) {
var inp = document.getElementById('shrink-' + idx);
if (cb.checked) { inp.removeAttribute('disabled'); inp.focus(); }
else { inp.setAttribute('disabled', ''); }
}
(function(){
var ss = document.getElementById('config-storage');
var sc = document.getElementById('config-storage-custom');
if (ss && sc) {
ss.addEventListener('change', function() {
if (this.value === '__custom__') {
sc.classList.remove('pve-hidden'); sc.focus();
} else {
sc.classList.add('pve-hidden');
}
});
}
})();
async function submitConfig() {
var vmid = parseInt(document.getElementById('config-vmid').value) || 0;
var vmName = document.getElementById('config-vmname').value.trim();
if (!vmid || vmid < 21000 || vmid > 21100) { alert('VM ID must be 21000-21100.'); return; }
if (!vmName) { alert('Please enter a VM name.'); return; }
var storage = document.getElementById('config-storage');
var sc = document.getElementById('config-storage-custom');
var targetStorage = sc && !sc.classList.contains('pve-hidden') ? sc.value : (storage ? storage.value : '');
var bootDetect = document.getElementById('config-bootdetect').value;
var autoBoot = bootDetect === 'auto';
var bootType = autoBoot ? 'uefi' : bootDetect;
var cores = parseInt(document.getElementById('config-cores').value) || 2;
var ram = parseInt(document.getElementById('config-ram').value) || 4096;
var bootIdx = 0;
var radios = document.getElementsByName('boot_disk');
for (var i = 0; i < radios.length; i++) {
if (radios[i].checked) { bootIdx = parseInt(radios[i].value); break; }
}
var payload = {
vmid: vmid, vm_name: vmName, cpu_cores: cores, ram_mb: ram,
target_storage: targetStorage, auto_detect_boot: autoBoot, boot_type: bootType,
session_id: _sessionId, analysis_id: _analysisId,
boot_disk: null, additional_disks: []
};
var shrinkCbs = document.getElementsByClassName('disk-shrink-cb');
var diskFiles = [{% for d in disks %}{{ d|tojson }},{% endfor %}];
for (var i = 0; i < shrinkCbs.length; i++) {
var d = diskFiles[i];
var spec = { disk_type: 'image_file', source_filename: d.filename, format: d.format };
if (shrinkCbs[i].checked) {
var si = document.getElementById('shrink-' + i);
var sz = si ? parseInt(si.value) || null : null;
if (sz) spec.target_disk_size_gb = sz;
}
if (i === bootIdx) payload.boot_disk = spec;
else payload.additional_disks.push(spec);
}
var btn = document.getElementById('config-submit-btn');
var status = document.getElementById('config-submit-status');
btn.disabled = true;
status.classList.remove('pve-hidden');
status.innerHTML = '<div class="pve-spinner"></div> Submitting job...';
try {
var resp = await fetch('/session/configure/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
var data = await resp.json();
if (data.error) { throw new Error(data.error); }
window.location.href = '/session/status/' + data.job_id;
} catch (err) {
status.innerHTML = '<div class="pve-alert pve-alert-error">Failed: ' + err.message + '</div>';
btn.disabled = false;
}
}
</script>
{% endblock %}

View file

@ -287,13 +287,8 @@ async function runAnalysis(vmid, filename, vmName, sessionOverride) {
setPhase('Analysing... ' + (sdata.message || '') + ' (' + elapsed + 's)', sdata.status === 'completed' ? 100 : 50, sdata.status !== 'completed'); setPhase('Analysing... ' + (sdata.message || '') + ' (' + elapsed + 's)', sdata.status === 'completed' ? 100 : 50, sdata.status !== 'completed');
if (sdata.status === 'completed') { if (sdata.status === 'completed') {
await sleep(500);
var sid = window.RESUMED_SID || window.VM_BENCH_SID || ''; 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)); window.location.href = '/session/configure/' + 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; return;
} }
if (sdata.status === 'failed') { if (sdata.status === 'failed') {

View file

@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block content %}
<div class="pve-panel pve-center">
<div class="pve-panel-header">
<span class="pve-panel-title">Converting VM {{ vmid or '...' }}</span>
</div>
<div class="pve-panel-body">
<p class="pve-dim">Job: <span class="pve-code">{{ job_id }}</span> &nbsp;|&nbsp; {{ vm_name }}</p>
<div id="status-badge" class="pve-badge pve-badge-queued">
<span class="pve-spinner"></span> Queued
</div>
<div class="pve-progress">
<div class="pve-progress-bar" id="pve-progress-bar" style="width:0%">0%</div>
</div>
<p id="status-message" class="pve-dim">Waiting for backend...</p>
<p id="elapsed" class="pve-dim">Elapsed: 0s</p>
<div id="error-block" class="pve-alert pve-alert-error pve-hidden"></div>
</div>
</div>
<div id="reuse-section" class="pve-panel pve-hidden">
<div class="pve-panel-header">
<span class="pve-panel-title">Conversion Complete — VM {{ vmid or '...' }} Ready</span>
</div>
<div class="pve-panel-body">
<p style="margin-bottom:1rem;">Create another VM from this disk?</p>
<button id="btn-cleanup" class="pve-btn" onclick="finishCleanup()">Clean Up &amp; Finish</button>
</div>
</div>
<script>
var jobId = '{{ job_id }}';
var startTime = Date.now();
var completed = false;
function finishCleanup() {
fetch('/session/cleanup/' + jobId, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_staging_files: true, session_id: '{{ session_id }}' }) });
window.location.href = '/';
}
async function poll() {
try {
var resp = await fetch('/session/status/' + jobId);
var data = await resp.json();
var pct = data.progress_percentage || 0;
var fill = document.getElementById('pve-progress-bar');
if (fill) { fill.style.width = pct + '%'; fill.textContent = pct + '%'; }
var elMsg = document.getElementById('status-message');
if (elMsg) elMsg.textContent = data.message || '';
document.getElementById('elapsed').textContent = 'Elapsed: ' + Math.round((Date.now() - startTime) / 1000) + 's';
var badge = document.getElementById('status-badge');
var 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']
};
var clsText = map[data.status] || ['pve-badge pve-badge-queued', data.status];
if (badge) {
badge.className = 'badge ' + clsText[0];
badge.innerHTML = (data.status === 'processing_conversion' || data.status === 'importing_storage')
? '<span class="pve-spinner"></span> ' + clsText[1] : clsText[1];
}
if (data.error_details) {
var 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') {
var rs = document.getElementById('reuse-section');
if (rs) { rs.classList.remove('pve-hidden'); rs.scrollIntoView({ behavior: 'smooth' }); }
}
return;
}
} catch (err) {
var eb = document.getElementById('error-block');
if (eb) { eb.classList.remove('pve-hidden'); eb.textContent = 'Polling error: ' + err.message; }
}
if (!completed) setTimeout(poll, 2000);
}
setTimeout(poll, 3000);
</script>
{% endblock %}

View file

@ -135,11 +135,7 @@ async function startScpPull(e) {
var elapsed = Math.round((Date.now() - astart) / 1000); var elapsed = Math.round((Date.now() - astart) / 1000);
setScpPhase('Analysing... ' + (sdata.message || '') + ' (' + elapsed + 's)', sdata.status === 'completed' ? 100 : 50, sdata.status !== 'completed'); setScpPhase('Analysing... ' + (sdata.message || '') + ' (' + elapsed + 's)', sdata.status === 'completed' ? 100 : 50, sdata.status !== 'completed');
if (sdata.status === 'completed') { if (sdata.status === 'completed') {
await new Promise(r => setTimeout(r, 500)); window.location.href = '/session/configure/' + analysisId + '?source_filename=' + encodeURIComponent(filename) + '&vm_name=' + encodeURIComponent(vmName) + '&vmid=' + vmid + '&session_id=' + SESSION_ID;
var rr = await fetch('/session/analyze/result/' + analysisId + '?source_filename=' + encodeURIComponent(filename) + '&vm_name=' + encodeURIComponent(vmName) + '&vmid=' + vmid);
document.getElementById('scp-analysis-section').innerHTML = await rr.text();
document.getElementById('scp-status').classList.add('pve-hidden');
scpInitConfirmForm();
btn.disabled = false; return; btn.disabled = false; return;
} }
if (sdata.status === 'failed') { if (sdata.status === 'failed') {