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:
parent
631f08fc31
commit
8eb1def62b
5 changed files with 109 additions and 5 deletions
|
|
@ -139,6 +139,44 @@ def storage_pools():
|
|||
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)
|
||||
def analyze(req: AnalyzeRequest) -> AnalyzeStatusResponse:
|
||||
"""Analyze a source file asynchronously: extraction + disk probing + OS detection."""
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ class ApiClient:
|
|||
"""GET /api/v1/storage/pools — list available Proxmox 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:
|
||||
"""POST /api/v1/jobs — submit conversion job."""
|
||||
return self._post("/api/v1/jobs", payload)
|
||||
|
|
|
|||
|
|
@ -337,6 +337,15 @@ async def session_analyze_status(analysis_id: str):
|
|||
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)
|
||||
async def session_analyze_result(
|
||||
request: Request,
|
||||
|
|
@ -344,6 +353,7 @@ async def session_analyze_result(
|
|||
vmid: str = "",
|
||||
source_filename: str = "",
|
||||
vm_name: str = "",
|
||||
session_id: str = "",
|
||||
):
|
||||
"""Render the analysis result after polling completes."""
|
||||
try:
|
||||
|
|
@ -370,7 +380,8 @@ async def session_analyze_result(
|
|||
|
||||
return render("_analysis.html", request=request,
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
<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 }}">
|
||||
<input type="hidden" name="session_id" value="{{ session_id or '' }}">
|
||||
|
||||
<div class="pve-row">
|
||||
<div class="pve-form-group">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
{% extends "base.html" %}
|
||||
{% 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-header">
|
||||
<span class="pve-panel-title">New Conversion Session</span>
|
||||
|
|
@ -53,6 +61,46 @@
|
|||
<div id="polling-section"></div>
|
||||
|
||||
<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> — ' + 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) {
|
||||
const el = document.getElementById('phase-label');
|
||||
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);
|
||||
const fd = new FormData();
|
||||
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 data = await resp.json();
|
||||
|
||||
|
|
@ -175,7 +223,8 @@ async function runAnalysis(vmid, filename, vmName) {
|
|||
|
||||
if (sdata.status === 'completed') {
|
||||
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();
|
||||
document.getElementById('analysis-section').innerHTML = html;
|
||||
document.getElementById('session-status').classList.add('pve-hidden');
|
||||
|
|
@ -223,7 +272,8 @@ async function submitJob(e) {
|
|||
status.classList.remove('pve-hidden');
|
||||
status.innerHTML = '<div class="pve-spinner"></div> Submitting job...';
|
||||
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 {
|
||||
const resp = await fetch('/session/confirm', { method: 'POST', body: formData });
|
||||
const html = await resp.text();
|
||||
|
|
|
|||
Loading…
Reference in a new issue