feat: session resume — detect leftover staging dirs and offer to resume analysis

- GET /api/v1/sessions scans tmp/ for session dirs with staged files
- Index page shows resumable sessions with filenames and sizes
- Resume button skips download and starts analysis with stored session_id
- session_id carried through analysis → confirm → job for correct file paths
This commit is contained in:
Claus Lohmar 2026-07-27 07:38:00 +00:00
parent 631f08fc31
commit 8eb1def62b
5 changed files with 109 additions and 5 deletions

View file

@ -139,6 +139,44 @@ def storage_pools():
return {"pools": result} return {"pools": result}
@app.get(f"{API_PREFIX}/sessions")
def list_sessions():
"""Return existing session directories with staged files that can be resumed."""
tmp = Path("/mnt/converter/tmp") if Path("/mnt/converter/tmp").exists() else Path(__file__).resolve().parent.parent / "tmp"
sessions = []
try:
for entry in sorted(tmp.iterdir(), reverse=True):
if not entry.is_dir():
continue
in_dir = entry / "in"
if not in_dir.is_dir():
continue
session_files = []
total_bytes = 0
has_disks = False
for f in sorted(in_dir.iterdir()):
if f.is_file():
sz = f.stat().st_size
session_files.append({"name": f.name, "size_bytes": sz, "size_gb": round(sz / (1024**3), 1)})
total_bytes += sz
elif f.is_dir():
for df in f.rglob("*"):
if df.is_file() and df.suffix.lower() in {".vmdk", ".qcow2", ".img", ".raw", ".vhd", ".vhdx"}:
has_disks = True
break
if not session_files:
continue
sessions.append({
"session_id": entry.name,
"files": session_files,
"total_size_gb": round(total_bytes / (1024**3), 1),
"has_disks": has_disks,
})
except Exception:
pass
return {"sessions": sessions}
@app.post(f"{API_PREFIX}/analyze", response_model=AnalyzeStatusResponse, status_code=202) @app.post(f"{API_PREFIX}/analyze", response_model=AnalyzeStatusResponse, status_code=202)
def analyze(req: AnalyzeRequest) -> AnalyzeStatusResponse: def analyze(req: AnalyzeRequest) -> AnalyzeStatusResponse:
"""Analyze a source file asynchronously: extraction + disk probing + OS detection.""" """Analyze a source file asynchronously: extraction + disk probing + OS detection."""

View file

@ -58,6 +58,10 @@ class ApiClient:
"""GET /api/v1/storage/pools — list available Proxmox storage pools.""" """GET /api/v1/storage/pools — list available Proxmox storage pools."""
return self._get("/api/v1/storage/pools") return self._get("/api/v1/storage/pools")
def get_sessions(self) -> dict:
"""GET /api/v1/sessions — list resumable staging sessions."""
return self._get("/api/v1/sessions")
def create_job(self, payload: dict) -> dict: def create_job(self, payload: dict) -> dict:
"""POST /api/v1/jobs — submit conversion job.""" """POST /api/v1/jobs — submit conversion job."""
return self._post("/api/v1/jobs", payload) return self._post("/api/v1/jobs", payload)

View file

@ -337,6 +337,15 @@ async def session_analyze_status(analysis_id: str):
return JSONResponse({"error": exc.detail}, status_code=502) return JSONResponse({"error": exc.detail}, status_code=502)
@app.get("/session/sessions")
async def session_list():
"""Proxy to backend to list resumable sessions."""
try:
return api.get_sessions()
except ApiError as exc:
return JSONResponse({"sessions": []})
@app.get("/session/analyze/result/{analysis_id}", response_class=HTMLResponse) @app.get("/session/analyze/result/{analysis_id}", response_class=HTMLResponse)
async def session_analyze_result( async def session_analyze_result(
request: Request, request: Request,
@ -344,6 +353,7 @@ async def session_analyze_result(
vmid: str = "", vmid: str = "",
source_filename: str = "", source_filename: str = "",
vm_name: str = "", vm_name: str = "",
session_id: str = "",
): ):
"""Render the analysis result after polling completes.""" """Render the analysis result after polling completes."""
try: try:
@ -370,7 +380,8 @@ async def session_analyze_result(
return render("_analysis.html", request=request, return render("_analysis.html", request=request,
vmid=vmid, source_filename=source_filename, vmid=vmid, source_filename=source_filename,
vm_name=name, analysis=analysis, storage_pools=pools) vm_name=name, analysis=analysis, storage_pools=pools,
session_id=session_id)
@app.post("/session/confirm", response_class=HTMLResponse) @app.post("/session/confirm", response_class=HTMLResponse)

View file

@ -31,6 +31,7 @@
<input type="hidden" name="vmid" value="{{ vmid }}"> <input type="hidden" name="vmid" value="{{ vmid }}">
<input type="hidden" name="source_filename" value="{{ source_filename }}"> <input type="hidden" name="source_filename" value="{{ source_filename }}">
<input type="hidden" name="disk_format" value="{{ analysis.disk_format }}"> <input type="hidden" name="disk_format" value="{{ analysis.disk_format }}">
<input type="hidden" name="session_id" value="{{ session_id or '' }}">
<div class="pve-row"> <div class="pve-row">
<div class="pve-form-group"> <div class="pve-form-group">

View file

@ -1,6 +1,14 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% 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" id="step1">
<div class="pve-panel-header"> <div class="pve-panel-header">
<span class="pve-panel-title">New Conversion Session</span> <span class="pve-panel-title">New Conversion Session</span>
@ -53,6 +61,46 @@
<div id="polling-section"></div> <div id="polling-section"></div>
<script> <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(', ');
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 + '\', \'' + fileNames.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) { function setPhase(label, pct, isSpinner) {
const el = document.getElementById('phase-label'); const el = document.getElementById('phase-label');
const fill = document.getElementById('pve-progress-bar'); const fill = document.getElementById('pve-progress-bar');
@ -141,11 +189,11 @@ async function handleDownload(formData) {
} }
} }
async function runAnalysis(vmid, filename, vmName) { async function runAnalysis(vmid, filename, vmName, sessionOverride) {
setPhase('Step 2/2: Analysing source image...', 0, true); setPhase('Step 2/2: Analysing source image...', 0, true);
const fd = new FormData(); 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);
fd.append('session_id', window.VM_BENCH_SID || ''); fd.append('session_id', sessionOverride || window.VM_BENCH_SID || '');
const resp = await fetch('/session/analyze', { method: 'POST', body: fd }); const resp = await fetch('/session/analyze', { method: 'POST', body: fd });
const data = await resp.json(); const data = await resp.json();
@ -175,7 +223,8 @@ async function runAnalysis(vmid, filename, vmName) {
if (sdata.status === 'completed') { if (sdata.status === 'completed') {
await sleep(500); await sleep(500);
var rr = await fetch('/session/analyze/result/' + analysisId + '?source_filename=' + encodeURIComponent(filename) + '&vm_name=' + encodeURIComponent(vmName) + '&vmid=' + vmid); 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(); var html = await rr.text();
document.getElementById('analysis-section').innerHTML = html; document.getElementById('analysis-section').innerHTML = html;
document.getElementById('session-status').classList.add('pve-hidden'); document.getElementById('session-status').classList.add('pve-hidden');
@ -223,7 +272,8 @@ async function submitJob(e) {
status.classList.remove('pve-hidden'); status.classList.remove('pve-hidden');
status.innerHTML = '<div class="pve-spinner"></div> Submitting job...'; status.innerHTML = '<div class="pve-spinner"></div> Submitting job...';
const formData = new FormData(form); const formData = new FormData(form);
formData.append('session_id', window.VM_BENCH_SID || ''); var sid = formData.get('session_id') || window.VM_BENCH_SID || '';
formData.set('session_id', sid);
try { try {
const resp = await fetch('/session/confirm', { method: 'POST', body: formData }); const resp = await fetch('/session/confirm', { method: 'POST', body: formData });
const html = await resp.text(); const html = await resp.text();