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}
|
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."""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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> — ' + 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();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue