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:
parent
d4726acd65
commit
b000641827
5 changed files with 334 additions and 23 deletions
|
|
@ -346,8 +346,8 @@ async def session_list():
|
|||
return JSONResponse({"sessions": []})
|
||||
|
||||
|
||||
@app.get("/session/analyze/result/{analysis_id}", response_class=HTMLResponse)
|
||||
async def session_analyze_result(
|
||||
@app.get("/session/configure/{analysis_id}", response_class=HTMLResponse)
|
||||
async def session_configure(
|
||||
request: Request,
|
||||
analysis_id: str,
|
||||
vmid: str = "",
|
||||
|
|
@ -355,16 +355,18 @@ async def session_analyze_result(
|
|||
vm_name: str = "",
|
||||
session_id: str = "",
|
||||
):
|
||||
"""Render the analysis result after polling completes."""
|
||||
"""Full page with VM configuration + disk selection after analysis."""
|
||||
try:
|
||||
status = api.get_analysis(analysis_id)
|
||||
result = status.get("result")
|
||||
if not result:
|
||||
return render("_analysis.html", request=request,
|
||||
error="Analysis result not ready.")
|
||||
return render("configure.html", request=request, analysis_id=analysis_id,
|
||||
source_filename=source_filename, session_id=session_id,
|
||||
disks=[], error="Analysis result not ready.")
|
||||
except ApiError as exc:
|
||||
return render("_analysis.html", request=request,
|
||||
error=f"Failed to fetch analysis: {exc.detail}")
|
||||
return render("configure.html", request=request, analysis_id=analysis_id,
|
||||
source_filename=source_filename, session_id=session_id,
|
||||
disks=[], error=f"Failed: {exc.detail}")
|
||||
|
||||
pools = []
|
||||
try:
|
||||
|
|
@ -376,12 +378,36 @@ async def session_analyze_result(
|
|||
analysis = result
|
||||
name = vm_name.strip()
|
||||
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,
|
||||
vmid=vmid, source_filename=source_filename,
|
||||
vm_name=name, analysis=analysis, storage_pools=pools,
|
||||
session_id=session_id)
|
||||
disks = analysis.get("all_disks", [])
|
||||
if not disks:
|
||||
disks = [{"filename": analysis.get("filename", ""), "format": analysis.get("disk_format", ""), "size_gb": analysis.get("disk_size_gb", 0)}]
|
||||
|
||||
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)
|
||||
|
|
|
|||
197
frontend/templates/configure.html
Normal file
197
frontend/templates/configure.html
Normal 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 %}
|
||||
|
|
@ -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');
|
||||
|
||||
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();
|
||||
window.location.href = '/session/configure/' + analysisId + '?source_filename=' + encodeURIComponent(filename) + '&vm_name=' + encodeURIComponent(vmName) + '&vmid=' + vmid + '&session_id=' + encodeURIComponent(sid);
|
||||
return;
|
||||
}
|
||||
if (sdata.status === 'failed') {
|
||||
|
|
|
|||
97
frontend/templates/job_polling.html
Normal file
97
frontend/templates/job_polling.html
Normal 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> | {{ 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 & 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 %}
|
||||
|
|
@ -135,11 +135,7 @@ async function startScpPull(e) {
|
|||
var elapsed = Math.round((Date.now() - astart) / 1000);
|
||||
setScpPhase('Analysing... ' + (sdata.message || '') + ' (' + elapsed + 's)', sdata.status === 'completed' ? 100 : 50, sdata.status !== 'completed');
|
||||
if (sdata.status === 'completed') {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
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();
|
||||
window.location.href = '/session/configure/' + analysisId + '?source_filename=' + encodeURIComponent(filename) + '&vm_name=' + encodeURIComponent(vmName) + '&vmid=' + vmid + '&session_id=' + SESSION_ID;
|
||||
btn.disabled = false; return;
|
||||
}
|
||||
if (sdata.status === 'failed') {
|
||||
|
|
|
|||
Loading…
Reference in a new issue