feat: SCP Pull page for large files from remote servers
New /scp page with form for host, user, password, remote path.
Runs scp via sshpass in background with progress polling.
Files go to /mnt/converter/in/{session_id}/ for multi-user isolation.
- Added sshpass + openssh-client to frontend installer
- scp.html template with full form + progress bar
- POST /scp/start — launches background SCP via sshpass -e
- GET /scp/progress/{sid}/{file} — polls file size + speed
- 'SCP Pull (+10 GB)' button on start page
This commit is contained in:
parent
1183059d8b
commit
837e674195
4 changed files with 294 additions and 0 deletions
122
frontend/app.py
122
frontend/app.py
|
|
@ -455,6 +455,128 @@ async def session_cleanup(job_id: str, request: Request):
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Run
|
# Run
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
# Track active SCP pulls for progress polling
|
||||||
|
_active_scp: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SCP Pull — large file transfer from remote servers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.get("/scp", response_class=HTMLResponse)
|
||||||
|
async def scp_page(request: Request):
|
||||||
|
"""SCP pull page — fetch files directly from remote servers."""
|
||||||
|
return render("scp.html", request=request, backend_url=BACKEND_URL,
|
||||||
|
session_id=uuid.uuid4().hex)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/scp/start")
|
||||||
|
def scp_start(
|
||||||
|
request: Request,
|
||||||
|
scp_host: str = Form(...),
|
||||||
|
scp_port: int = Form(22),
|
||||||
|
scp_user: str = Form(...),
|
||||||
|
scp_pass: str = Form(...),
|
||||||
|
scp_path: str = Form(...),
|
||||||
|
session_id: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Start an SCP pull in the background. Returns JSON with phase + filename."""
|
||||||
|
remote = f"{scp_user}@{scp_host}:{scp_path}"
|
||||||
|
filename = Path(scp_path).name
|
||||||
|
if not filename:
|
||||||
|
return JSONResponse({"phase": "error", "error": "Invalid remote path."}, status_code=400)
|
||||||
|
|
||||||
|
session_dir = STAGING / session_id if session_id else STAGING
|
||||||
|
session_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = session_dir / filename
|
||||||
|
|
||||||
|
# Check disk space
|
||||||
|
usage = shutil.disk_usage(STAGING)
|
||||||
|
free_gb = usage.free / (1024**3)
|
||||||
|
if free_gb < 10:
|
||||||
|
logger.warning("Low disk: %.1f GB free — SCP pull may fail", free_gb)
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["SSHPASS"] = scp_pass
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"sshpass", "-e",
|
||||||
|
"scp",
|
||||||
|
"-o", "StrictHostKeyChecking=no",
|
||||||
|
"-o", "ConnectTimeout=10",
|
||||||
|
"-P", str(scp_port),
|
||||||
|
remote, str(dest),
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info("Starting SCP pull: %s → %s", remote, dest)
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
except Exception as exc:
|
||||||
|
return JSONResponse({"phase": "error", "error": f"Failed to start SCP: {exc}"}, status_code=500)
|
||||||
|
|
||||||
|
key = f"{session_id}/{filename}"
|
||||||
|
_active_scp[key] = {
|
||||||
|
"proc": proc,
|
||||||
|
"dest": dest,
|
||||||
|
"start_time": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"phase": "pulling",
|
||||||
|
"filename": filename,
|
||||||
|
"session_id": session_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/scp/progress/{session_id}/{filename}")
|
||||||
|
def scp_progress(session_id: str, filename: str):
|
||||||
|
"""Poll SCP progress by checking output file size."""
|
||||||
|
key = f"{session_id}/{filename}"
|
||||||
|
info = _active_scp.get(key)
|
||||||
|
if not info:
|
||||||
|
dest = STAGING / session_id / filename
|
||||||
|
if dest.exists():
|
||||||
|
return JSONResponse({
|
||||||
|
"phase": "complete",
|
||||||
|
"file_size_bytes": dest.stat().st_size,
|
||||||
|
"file_size_gb": round(dest.stat().st_size / (1024**3), 1),
|
||||||
|
})
|
||||||
|
return JSONResponse({"phase": "unknown", "error": "No active SCP pull."}, status_code=404)
|
||||||
|
|
||||||
|
proc = info["proc"]
|
||||||
|
dest = info["dest"]
|
||||||
|
current_bytes = dest.stat().st_size if dest.exists() else 0
|
||||||
|
|
||||||
|
poll = proc.poll()
|
||||||
|
if poll is not None:
|
||||||
|
_active_scp.pop(key, None)
|
||||||
|
if poll != 0:
|
||||||
|
if dest.exists():
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
return JSONResponse({
|
||||||
|
"phase": "error",
|
||||||
|
"error": f"SCP pull failed (exit code {poll}). Check credentials and remote path.",
|
||||||
|
"file_size_bytes": current_bytes,
|
||||||
|
})
|
||||||
|
final_bytes = dest.stat().st_size
|
||||||
|
logger.info("SCP pull complete: %s (%.1f GiB)", filename, final_bytes / (1024**3))
|
||||||
|
return JSONResponse({
|
||||||
|
"phase": "complete",
|
||||||
|
"file_size_bytes": final_bytes,
|
||||||
|
"file_size_gb": round(final_bytes / (1024**3), 1),
|
||||||
|
})
|
||||||
|
|
||||||
|
elapsed = max(time.time() - info.get("start_time", 0), 1)
|
||||||
|
speed_mbps = round(current_bytes / elapsed / 1_000_000, 1)
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"phase": "pulling",
|
||||||
|
"file_size_bytes": current_bytes,
|
||||||
|
"file_size_gb": round(current_bytes / (1024**3), 1),
|
||||||
|
"speed_mbps": speed_mbps,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=5000)
|
uvicorn.run(app, host="0.0.0.0", port=5000)
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ REQUIRED=(
|
||||||
python3-pip "pip3"
|
python3-pip "pip3"
|
||||||
wget "wget"
|
wget "wget"
|
||||||
curl "curl"
|
curl "curl"
|
||||||
|
openssh-client "scp"
|
||||||
|
sshpass "sshpass"
|
||||||
)
|
)
|
||||||
|
|
||||||
for ((i=0; i<${#REQUIRED[@]}; i+=2)); do
|
for ((i=0; i<${#REQUIRED[@]}; i+=2)); do
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,15 @@
|
||||||
<button type="submit" id="start-btn">Analyse Source Image</button>
|
<button type="submit" id="start-btn">Analyse Source Image</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-top:1.5rem; padding-top:1rem; border-top:1px solid var(--border);">
|
||||||
|
<p style="color:var(--muted); font-size:0.8rem; margin-bottom:0.5rem;">
|
||||||
|
For files larger than 10 GB or on remote servers — pull directly via SCP:
|
||||||
|
</p>
|
||||||
|
<a href="/scp" class="btn btn-secondary" style="width:auto; display:inline-block;">
|
||||||
|
SCP Pull (Large Files +10 GB)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Progress area (hidden until form submit) -->
|
<!-- Progress area (hidden until form submit) -->
|
||||||
<div id="session-status" class="hidden" style="margin-top:1rem;">
|
<div id="session-status" class="hidden" style="margin-top:1rem;">
|
||||||
<div id="phase-label" style="margin-bottom:0.5rem;"></div>
|
<div id="phase-label" style="margin-bottom:0.5rem;"></div>
|
||||||
|
|
|
||||||
161
frontend/templates/scp.html
Normal file
161
frontend/templates/scp.html
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="panel" id="scp-form-panel">
|
||||||
|
<h2>SCP Pull — Fetch File from Remote Server</h2>
|
||||||
|
<p class="confirm-text">
|
||||||
|
Pull a disk image directly from a customer server via SCP.
|
||||||
|
No laptop middle-hop — files land directly in the staging area.
|
||||||
|
<strong>Supports files of any size.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="scp-form" onsubmit="startScpPull(event)">
|
||||||
|
<div class="row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Remote Host</label>
|
||||||
|
<input type="text" name="scp_host" required
|
||||||
|
placeholder="10.0.0.50 or server.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SSH Port</label>
|
||||||
|
<input type="number" name="scp_port" value="22" min="1" max="65535">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Username</label>
|
||||||
|
<input type="text" name="scp_user" required placeholder="root">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" name="scp_pass" required placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Remote File Path *</label>
|
||||||
|
<input type="text" name="scp_path" required
|
||||||
|
placeholder="/mnt/vmware/exchange-server.vmdk">
|
||||||
|
<span class="hint">Full path to the disk image or archive on the remote server.</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>VM Name *</label>
|
||||||
|
<input type="text" id="scp-vm-name" name="vm_name" required
|
||||||
|
placeholder="e.g. exchange-prod">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>VM ID *</label>
|
||||||
|
<input type="number" id="scp-vmid" name="vmid" required
|
||||||
|
placeholder="e.g. 21050" min="21000" max="21100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" id="scp-start-btn">Start SCP Pull</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Progress area -->
|
||||||
|
<div id="scp-status" class="hidden" style="margin-top:1rem;">
|
||||||
|
<div id="scp-phase-label" style="margin-bottom:0.5rem;"></div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" id="scp-progress-fill" style="width:0%">0%</div>
|
||||||
|
</div>
|
||||||
|
<div id="scp-error"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Analysis result (populated after pull completes) -->
|
||||||
|
<div id="scp-analysis-section"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Session ID for multi-user isolation
|
||||||
|
const SESSION_ID = '{{ session_id }}';
|
||||||
|
|
||||||
|
async function startScpPull(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('scp-start-btn');
|
||||||
|
const status = document.getElementById('scp-status');
|
||||||
|
const analysis = document.getElementById('scp-analysis-section');
|
||||||
|
btn.disabled = true;
|
||||||
|
analysis.innerHTML = '';
|
||||||
|
status.classList.remove('hidden');
|
||||||
|
document.getElementById('scp-error').textContent = '';
|
||||||
|
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
formData.append('session_id', SESSION_ID);
|
||||||
|
|
||||||
|
setScpPhase('Connecting to remote server...', 0, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/scp/start', { method: 'POST', body: formData });
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.phase === 'error') {
|
||||||
|
setScpPhase('SCP pull failed', 0, false);
|
||||||
|
showScpError(data.error);
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll progress
|
||||||
|
const filename = data.filename;
|
||||||
|
setScpPhase('Pulling file from remote server...', 0, true);
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
try {
|
||||||
|
const pr = await fetch('/scp/progress/' + SESSION_ID + '/' + encodeURIComponent(filename));
|
||||||
|
const pdata = await pr.json();
|
||||||
|
|
||||||
|
if (pdata.phase === 'complete') {
|
||||||
|
setScpPhase('Pull complete (' + (pdata.file_size_gb || 0) + ' GiB)', 100, false);
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
// Run analysis
|
||||||
|
setScpPhase('Step 2/2: Analysing source image...', 0, true);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('vmid', document.getElementById('scp-vmid').value);
|
||||||
|
fd.append('filename', filename);
|
||||||
|
fd.append('vm_name', document.getElementById('scp-vm-name').value.trim());
|
||||||
|
fd.append('session_id', SESSION_ID);
|
||||||
|
const ar = await fetch('/session/analyze', { method: 'POST', body: fd });
|
||||||
|
document.getElementById('scp-analysis-section').innerHTML = await ar.text();
|
||||||
|
document.getElementById('scp-status').classList.add('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pdata.phase === 'error') {
|
||||||
|
setScpPhase('SCP pull failed', 0, false);
|
||||||
|
showScpError(pdata.error);
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pdata.phase === 'pulling') {
|
||||||
|
const gb = (pdata.file_size_gb || 0).toFixed(1);
|
||||||
|
let label = 'Pulling file... ' + gb + ' GiB';
|
||||||
|
if (pdata.speed_mbps) label += ' (' + pdata.speed_mbps + ' MB/s)';
|
||||||
|
setScpPhase(label, 0, true);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setScpPhase('SCP pull failed', 0, false);
|
||||||
|
showScpError('Network error: ' + err.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScpPhase(label, pct, spinner) {
|
||||||
|
const el = document.getElementById('scp-phase-label');
|
||||||
|
if (el) el.innerHTML = (spinner ? '<span class="spinner"></span> ' : '') + label;
|
||||||
|
const fill = document.getElementById('scp-progress-fill');
|
||||||
|
if (fill) { fill.style.width = pct + '%'; fill.textContent = pct > 0 ? pct + '%' : ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showScpError(msg) {
|
||||||
|
const el = document.getElementById('scp-error');
|
||||||
|
if (el) el.innerHTML += '<div class="error" style="margin-top:0.5rem;">' + msg + '</div>';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in a new issue