diff --git a/frontend/app.py b/frontend/app.py index 6197fe6..eca4f7d 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -28,7 +28,7 @@ from typing import Optional import requests as http_requests -from fastapi import FastAPI, Form, Request, UploadFile, File +from fastapi import FastAPI, Form, Request from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles @@ -99,11 +99,9 @@ logger.info("Frontend starting — log file: %s", LOG_DIR / "vm-bench.log") VM_ID_MIN = 21000 VM_ID_MAX = 21100 DEFAULT_STORAGE = "local-lvm" -MIN_FREE_DISK_GB = 2 # keep 2 GB headroom DOWNLOAD_TIMEOUT = 14400 # 4 hours absolute max (safety net) SPEED_CHECK_AFTER = 30 # wait N seconds before judging speed MAX_ETA_SECONDS = 3600 # kill download if ETA > 1 hour -UPLOAD_PROGRESS_INTERVAL = 1024**3 # log every 1 GiB during upload # Track active background downloads for progress polling _active_downloads: dict[str, dict] = {} @@ -113,19 +111,6 @@ def _validate_vmid(vmid: int) -> Optional[str]: return f"VM ID must be between {VM_ID_MIN} and {VM_ID_MAX}." return None -def _check_disk_space(path: Path, needed_gb: int) -> Optional[str]: - """Return an error message if has less than + headroom free.""" - usage = shutil.disk_usage(path.parent if path.is_file() or not path.exists() else path) - free_gb = usage.free / (1024**3) - required = needed_gb + MIN_FREE_DISK_GB - if free_gb < required: - return ( - f"Insufficient disk space on {path.parent}: " - f"{free_gb:.1f} GB free, {required:.1f} GB needed " - f"({needed_gb} GB file + {MIN_FREE_DISK_GB} GB headroom). " - f"Free up space or use a smaller file." - ) - return None def render(name: str, status: int = 200, **ctx) -> HTMLResponse: tpl = _jinja.get_template(name) @@ -149,13 +134,10 @@ def session_upload( request: Request, vmid: int = Form(...), vm_name: str = Form(""), - source_type: str = Form("upload"), - source_file: Optional[UploadFile] = File(None), source_url: Optional[str] = Form(None), session_id: str = Form(""), ): - """Phase 1 — acquire the source file. Returns JSON so the frontend can - show progress, then call /session/analyze separately.""" + """Download a source file via aria2c. Returns JSON with phase + filename.""" sdir = _staging(session_id) / "in" sdir.mkdir(parents=True, exist_ok=True) err = _validate_vmid(vmid) @@ -164,154 +146,49 @@ def session_upload( vm_name = vm_name.strip() - # ── Upload ────────────────────────────────────────────────────── - if source_type == "upload": - if not source_file or not source_file.filename: - return JSONResponse({"phase": "error", "error": "No file uploaded."}, status_code=400) + url = (source_url or "").strip() + if not url: + return JSONResponse({"phase": "error", "error": "No URL provided."}, status_code=400) - filename = source_file.filename - dest = sdir / filename - - content_length = request.headers.get("content-length") - if content_length: - estimated_gb = int(content_length) / (1024**3) - err = _check_disk_space(dest, estimated_gb) - if err: - return JSONResponse({"phase": "error", "error": err}, status_code=400) - - try: - logger.info("Receiving upload: %s (%s bytes)", filename, content_length or "unknown") - written = 0 - with dest.open("wb") as f: - while True: - chunk = source_file.file.read(8 * 1024 * 1024) - if not chunk: - break - f.write(chunk) - written += len(chunk) - if written % UPLOAD_PROGRESS_INTERVAL < len(chunk): - logger.info("Upload progress: %s — %.1f GiB", filename, written / (1024**3)) - file_size_gb = round(written / (1024**3), 1) - logger.info("Upload complete: %s (%.1f GiB)", filename, file_size_gb) - except OSError as exc: - if dest.exists(): - dest.unlink(missing_ok=True) - return JSONResponse({"phase": "error", "error": f"Upload failed (disk full?): {exc}"}, status_code=500) - except Exception as exc: - if dest.exists(): - dest.unlink(missing_ok=True) - return JSONResponse({"phase": "error", "error": f"Upload failed: {exc}"}, status_code=500) - - return JSONResponse({ - "phase": "staged", - "filename": filename, - "vmid": vmid, - "vm_name": vm_name, - "file_size_gb": file_size_gb, - }) - - else: - # ── Download ──────────────────────────────────────────────── - url = (source_url or "").strip() - if not url: - return JSONResponse({"phase": "error", "error": "No URL provided."}, status_code=400) - - filename = Path(url).name or f"download_{uuid.uuid4().hex[:8]}" - dest = sdir / filename - - _active_downloads.pop(filename, None) - - usage = shutil.disk_usage(_staging(session_id)) - free_gb = usage.free / (1024**3) - if free_gb < 50: - logger.warning("Low disk: %.1f GB free — download may fail", free_gb) - - logger.info("Starting background download: %s → %s", url, dest) - try: - proc = subprocess.Popen( - ["aria2c", "-x8", "-s8", "-d", str(dest.parent), "-o", dest.name, url], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - ) - except Exception as exc: - return JSONResponse({"phase": "error", "error": f"Failed to start download: {exc}"}, status_code=500) - - _active_downloads[filename] = { - "proc": proc, "dest": dest, "vmid": vmid, "vm_name": vm_name, - "start_time": time.time(), "content_length": 0, "_last_logged_bytes": 0, - } - - content_length = 0 - def _fetch_cl(): - nonlocal content_length - try: - hr = http_requests.head(url, timeout=5, allow_redirects=True) - cl = hr.headers.get("Content-Length") - if cl: - content_length = int(cl) - if filename in _active_downloads: - _active_downloads[filename]["content_length"] = int(cl) - logger.info("Download size: %.1f GiB", int(cl) / (1024**3)) - except Exception: - pass - threading.Thread(target=_fetch_cl, daemon=True).start() - - return JSONResponse({ - "phase": "downloading", - "filename": filename, "vmid": vmid, "vm_name": vm_name, - "content_length_gb": round(content_length / (1024**3), 1) if content_length else None, - }) - - -@app.post("/session/upload-raw") -async def upload_raw(request: Request): - """Raw streaming upload — bypasses multipart parsing for large files.""" - filename = request.headers.get("X-Filename", "upload.bin") - vmid_str = request.headers.get("X-VMID", "") - vm_name = request.headers.get("X-VM-Name", "") - session_id = request.headers.get("X-Session-ID", "") - - try: - vmid = int(vmid_str) - except ValueError: - return JSONResponse({"phase": "error", "error": "Invalid VM ID."}, status_code=400) - - err = _validate_vmid(vmid) - if err: - return JSONResponse({"phase": "error", "error": err}, status_code=400) - - sdir = _staging(session_id) / "in" - sdir.mkdir(parents=True, exist_ok=True) + filename = Path(url).name or f"download_{uuid.uuid4().hex[:8]}" dest = sdir / filename - content_length = request.headers.get("content-length") - if content_length: - estimated_gb = int(content_length) / (1024**3) - err = _check_disk_space(dest, estimated_gb) - if err: - return JSONResponse({"phase": "error", "error": err}, status_code=400) - logger.info("Raw upload: %s (%s bytes)", filename, content_length or "unknown") - written = 0 + _active_downloads.pop(filename, None) + + usage = shutil.disk_usage(_staging(session_id)) + free_gb = usage.free / (1024**3) + if free_gb < 50: + logger.warning("Low disk: %.1f GB free — download may fail", free_gb) + + logger.info("Starting download: %s → %s", url, dest) try: - with dest.open("wb") as f: - async for chunk in request.stream(): - f.write(chunk) - written += len(chunk) - if written % UPLOAD_PROGRESS_INTERVAL < len(chunk): - logger.info("Upload progress: %s — %.1f GiB", filename, written / (1024**3)) - file_size_gb = round(written / (1024**3), 1) - logger.info("Raw upload complete: %s (%.1f GiB)", filename, file_size_gb) + proc = subprocess.Popen( + ["aria2c", "-x8", "-s8", "-d", str(dest.parent), "-o", dest.name, url], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) except Exception as exc: - if dest.exists(): - dest.unlink(missing_ok=True) - logger.exception("Raw upload failed: %s", filename) - return JSONResponse({"phase": "error", "error": f"Upload failed: {exc}"}, status_code=500) + return JSONResponse({"phase": "error", "error": f"Failed to start download: {exc}"}, status_code=500) + + _active_downloads[filename] = { + "proc": proc, "dest": dest, "vmid": vmid, "vm_name": vm_name, + "start_time": time.time(), "content_length": 0, "_last_logged_bytes": 0, + } + + def _fetch_cl(): + try: + hr = http_requests.head(url, timeout=5, allow_redirects=True) + cl = hr.headers.get("Content-Length") + if cl and filename in _active_downloads: + _active_downloads[filename]["content_length"] = int(cl) + logger.info("Download size: %.1f GiB", int(cl) / (1024**3)) + except Exception: + pass + threading.Thread(target=_fetch_cl, daemon=True).start() return JSONResponse({ - "phase": "staged", - "filename": filename, - "vmid": vmid, - "vm_name": vm_name, - "file_size_gb": file_size_gb, + "phase": "downloading", + "filename": filename, "vmid": vmid, "vm_name": vm_name, + "content_length_gb": None, }) diff --git a/frontend/templates/index.html b/frontend/templates/index.html index 79146c2..c1fcd80 100644 --- a/frontend/templates/index.html +++ b/frontend/templates/index.html @@ -1,7 +1,6 @@ {% extends "base.html" %} {% block content %} -
New Conversion Session @@ -24,35 +23,22 @@
- - -
- -
- - -
- -
- - Download URL * + + Direct URL to a disk image or archive (.vmdk, .7z, .zip, etc.)
- +

- For files larger than 10 GB or on remote servers — pull directly via SCP: + Files on a remote server? Pull directly via SCP:

- SCP Pull (Large Files +10 GB) + SCP Pull - +
@@ -63,36 +49,24 @@
-
- -