feat: SCP probes remote file size via SSH before pull

Runs 'ssh stat -c%s' or 'ls -l' to get total bytes before starting
SCP transfer. Progress bar now shows real percentage and ETA
instead of indeterminate spinner.
This commit is contained in:
Claus Lohmar 2026-07-24 09:48:43 +00:00
parent cd5f31505a
commit a7ab9243c0
2 changed files with 37 additions and 3 deletions

View file

@ -472,8 +472,25 @@ def scp_start(
"-P", str(scp_port), "-P", str(scp_port),
remote, str(dest), remote, str(dest),
] ]
logger.info("Starting SCP pull: %s%s", remote, dest) logger.info("Starting SCP pull: %s%s", remote, dest)
# ── Probe file size via SSH ────────────────────────────────────
file_size = 0
try:
probe_cmd = [
"sshpass", "-e",
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
"-p", str(scp_port), f"{scp_user}@{scp_host}",
f"stat -c%s '{scp_path}' 2>/dev/null || ls -l '{scp_path}' 2>/dev/null | awk '{{print $5}}'"
]
probe = subprocess.run(probe_cmd, env=env, capture_output=True, text=True, timeout=15)
if probe.returncode == 0 and probe.stdout.strip().isdigit():
file_size = int(probe.stdout.strip())
logger.info("Remote file size: %.1f GiB", file_size / (1024**3))
except Exception:
logger.info("Could not probe remote file size")
# ── Start SCP ──────────────────────────────────────────────────
try: try:
proc = subprocess.Popen(cmd, env=env, stdout=subprocess.DEVNULL, proc = subprocess.Popen(cmd, env=env, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, text=True) stderr=subprocess.PIPE, text=True)
@ -485,12 +502,14 @@ def scp_start(
"proc": proc, "proc": proc,
"dest": dest, "dest": dest,
"start_time": time.time(), "start_time": time.time(),
"total_size": file_size,
} }
return JSONResponse({ return JSONResponse({
"phase": "pulling", "phase": "pulling",
"filename": filename, "filename": filename,
"session_id": session_id, "session_id": session_id,
"total_size_gb": round(file_size / (1024**3), 1) if file_size else None,
}) })
@ -540,12 +559,25 @@ def scp_progress(session_id: str, filename: str):
elapsed = max(time.time() - info.get("start_time", 0), 1) elapsed = max(time.time() - info.get("start_time", 0), 1)
speed_mbps = round(current_bytes / elapsed / 1_000_000, 1) speed_mbps = round(current_bytes / elapsed / 1_000_000, 1)
total = info.get("total_size", 0)
eta_str = None
pct = 0
if total > 0 and current_bytes > 0:
pct = min(round((current_bytes / total) * 100), 99)
remaining = total - current_bytes
eta = remaining / max(current_bytes / elapsed, 1)
if eta > 60:
eta_str = f"~{round(eta / 60)} min remaining"
return JSONResponse({ return JSONResponse({
"phase": "pulling", "phase": "pulling",
"file_size_bytes": current_bytes, "file_size_bytes": current_bytes,
"file_size_gb": round(current_bytes / (1024**3), 1), "file_size_gb": round(current_bytes / (1024**3), 1),
"speed_mbps": speed_mbps, "speed_mbps": speed_mbps,
"pct": pct,
"eta": eta_str,
"total_size_gb": round(total / (1024**3), 1) if total else None,
}) })

View file

@ -95,7 +95,8 @@ async function startScpPull(e) {
} }
const filename = data.filename; const filename = data.filename;
setScpPhase('Pulling file from remote server...', 0, true); const totalGb = data.total_size_gb;
setScpPhase('Pulling file...' + (totalGb ? ' (' + totalGb + ' GiB total)' : ''), 0, true);
for (;;) { for (;;) {
await new Promise(r => setTimeout(r, 2000)); await new Promise(r => setTimeout(r, 2000));
try { try {
@ -123,7 +124,8 @@ async function startScpPull(e) {
const gb = (pdata.file_size_gb || 0).toFixed(1); const gb = (pdata.file_size_gb || 0).toFixed(1);
let label = 'Pulling file... ' + gb + ' GiB'; let label = 'Pulling file... ' + gb + ' GiB';
if (pdata.speed_mbps) label += ' (' + pdata.speed_mbps + ' MB/s)'; if (pdata.speed_mbps) label += ' (' + pdata.speed_mbps + ' MB/s)';
setScpPhase(label, 0, true); if (pdata.eta) label += ' — ' + pdata.eta;
setScpPhase(label, pdata.pct || 0, !pdata.pct);
} }
} catch (_) {} } catch (_) {}
} }