Reuse SSH for WebCRT SFTP and fix folder navigation UX.

Open SFTP on the live session transport after SSH connect, pass the target path on every list refresh, and use folder/file icons in the browser panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-02 00:13:07 +08:00
parent d87bdf4be1
commit 499b4cc2a1
10 changed files with 525 additions and 131 deletions

View file

@ -298,6 +298,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
"device_type": sess.device_type,
"vendor": sess.vendor,
"cli_hop": bool(sess.cli_hop_guard),
"sftp_ready": bool(sess.sftp_ready),
}
)
@ -372,6 +373,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
"device_type": sess.device_type,
"vendor": sess.vendor,
"cli_hop": bool(sess.cli_hop_guard),
"sftp_ready": bool(sess.sftp_ready),
"connect_ms": (
int((sess.connect_finished_at - sess.connect_started_at) * 1000)
if sess.connect_finished_at

View file

@ -499,10 +499,85 @@ class WebcrtSession:
_cli_hop_seen_other_prompt: bool = field(default=False, repr=False)
_log_fh: Any = field(default=None, repr=False)
_ready_event: threading.Event = field(default_factory=threading.Event, repr=False)
# SFTP channel on the same SSH transport as the interactive shell (direct SSH only).
sftp_ready: bool = False
_sftp: Any = field(default=None, repr=False)
_sftp_lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
def touch(self) -> None:
self.last_activity = time.time()
def close_sftp(self) -> None:
with self._sftp_lock:
sftp = self._sftp
self._sftp = None
self.sftp_ready = False
if sftp is None:
return
try:
sftp.close()
except Exception:
pass
def _ensure_sftp_unlocked(self) -> Any:
"""Caller must hold ``_sftp_lock``."""
import paramiko
if self.closed or self.conn is None:
raise RuntimeError("session_closed")
if str(self.protocol or "ssh").lower() != "ssh":
raise RuntimeError("sftp_requires_ssh")
if self.cli_hop_guard:
raise RuntimeError("sftp_hop_not_supported")
if self._sftp is not None:
sock = getattr(self._sftp, "sock", None)
if sock is not None and not bool(getattr(sock, "closed", False)):
return self._sftp
try:
self._sftp.close()
except Exception:
pass
self._sftp = None
channel = getattr(self.conn, "remote_conn", None)
transport = None
if channel is not None and hasattr(channel, "get_transport"):
try:
transport = channel.get_transport()
except Exception:
transport = None
if transport is None or not bool(getattr(transport, "is_active", lambda: False)()):
raise RuntimeError("ssh_transport_unavailable")
self._sftp = paramiko.SFTPClient.from_transport(transport)
if self._sftp is None:
raise RuntimeError("sftp_open_failed")
self.sftp_ready = True
return self._sftp
def open_sftp(self) -> Any:
"""Open/reuse an SFTP client on this session's SSH transport."""
with self._sftp_lock:
return self._ensure_sftp_unlocked()
def run_sftp(self, fn: Any) -> Any:
"""Run ``fn(sftp)`` while holding the session SFTP lock."""
with self._sftp_lock:
sftp = self._ensure_sftp_unlocked()
return fn(sftp)
def try_attach_sftp(self) -> bool:
"""Best-effort SFTP channel open after SSH login (does not fail the shell)."""
if str(self.protocol or "ssh").lower() != "ssh" or self.cli_hop_guard:
self.sftp_ready = False
return False
try:
self.open_sftp()
self.sftp_ready = True
return True
except Exception:
self.sftp_ready = False
_log.debug("webcrt sftp attach skipped session=%s", self.session_id, exc_info=True)
return False
def open_session_log(self) -> None:
if not bool(getattr(settings, "webcrt_session_log_enabled", True)):
return
@ -790,6 +865,7 @@ class WebcrtSession:
self.state = "closed"
self.close_reason = reason or "closed"
self._ready_event.set()
self.close_sftp()
try:
close_netmiko_connection(self.conn)
except Exception:
@ -892,6 +968,29 @@ def get_session(session_id: str) -> WebcrtSession | None:
return sess
def find_ssh_session_for_ne(ne_id: str) -> WebcrtSession | None:
"""Prefer a ready/attached interactive SSH session for SFTP channel reuse."""
nid = str(ne_id or "").strip()
if not nid:
return None
with _sessions_lock:
candidates = [
s
for s in _sessions.values()
if (not s.closed)
and str(s.ne_id) == nid
and str(s.protocol or "ssh").lower() == "ssh"
and s.conn is not None
and s.state in ("ready", "connecting")
and not s.cli_hop_guard
]
if not candidates:
return None
# Prefer attached + ready sessions.
candidates.sort(key=lambda s: (0 if s.attached and s.state == "ready" else 1, -s.last_activity))
return candidates[0]
def wait_session_ready(session_id: str, *, timeout: float = 120.0) -> WebcrtSession:
"""Block until async connect finishes (ready or error). Used by tests and WS."""
deadline = time.time() + max(1.0, float(timeout))
@ -1067,6 +1166,8 @@ def _finish_connect(
sess.run_post_login_commands()
except Exception:
_log.debug("post_login failed session=%s", sess.session_id, exc_info=True)
# Same SSH transport: open SFTP channel when the device supports it.
sftp_ok = sess.try_attach_sftp()
sess.state = "ready"
sess.connect_finished_at = time.time()
sess._ready_event.set()
@ -1084,6 +1185,7 @@ def _finish_connect(
hop_vendor=str(creds.get("hop_vendor") or "") if creds.get("hop_enabled") else "",
cli_hop_guard=bool(hop_guard),
cli_hop_prompt=str((hop_guard or {}).get("hop_prompt") or ""),
sftp_ready=bool(sftp_ok),
client=client or "",
connect_ms=elapsed_ms,
active=active_session_count(),
@ -1226,6 +1328,7 @@ def create_session(
"state": sess.state,
"ws_path": f"/v1/webcrt/sessions/{session_id}/ws",
"cli_hop": bool(sess.cli_hop_guard),
"sftp_ready": bool(sess.sftp_ready),
}

View file

@ -1,10 +1,15 @@
"""Lightweight SFTP helpers for WebCRT (SSH targets only; separate from interactive PTY)."""
"""WebCRT SFTP helpers — prefer the live SSH session channel; pool only as fallback."""
from __future__ import annotations
import logging
import posixpath
from typing import Any
import stat as statmod
import threading
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any, Iterator
import paramiko
from fastapi import HTTPException
@ -13,21 +18,64 @@ from sqlalchemy.orm import Session
from .cli_resolve import resolve_cli_target
from .config import settings
from .ne_crypto import CredentialCryptoError
from .webcrt_service import _webcrt_creds_ready
from .webcrt_service import (
_webcrt_creds_ready,
find_ssh_session_for_ne,
)
_log = logging.getLogger("netx.webcrt.sftp")
_pool_lock = threading.Lock()
_pool: dict[str, "_PooledSftp"] = {}
_POOL_IDLE_SEC = 180
@dataclass
class _PooledSftp:
key: str
client: paramiko.SSHClient
sftp: paramiko.SFTPClient
last_used: float = field(default_factory=time.time)
lock: threading.RLock = field(default_factory=threading.RLock)
def _require_ssh_direct(creds: dict[str, Any], device: dict[str, Any]) -> None:
protocol = str(device.get("protocol") or creds.get("protocol") or "ssh").lower()
if protocol != "ssh":
raise HTTPException(status_code=400, detail="sftp_requires_ssh")
if creds.get("hop_enabled"):
# Keep v1 simple: SFTP only for direct SSH (no hop/proxy jump).
raise HTTPException(status_code=400, detail="sftp_hop_not_supported")
def _open_sftp(creds: dict[str, Any]) -> tuple[paramiko.SSHClient, paramiko.SFTPClient]:
def _pool_key(*, managed_ne_id: str | None, ume_ne_id: str | None) -> str:
mid = str(managed_ne_id or "").strip()
uid = str(ume_ne_id or "").strip()
if mid:
return f"m:{mid}"
return f"u:{uid}"
def _close_pooled(entry: _PooledSftp) -> None:
try:
entry.sftp.close()
except Exception:
pass
try:
entry.client.close()
except Exception:
pass
def _reap_pool_unlocked(now: float | None = None) -> None:
ts = float(now or time.time())
dead = [k for k, e in _pool.items() if (ts - e.last_used) > _POOL_IDLE_SEC]
for k in dead:
entry = _pool.pop(k, None)
if entry is not None:
_close_pooled(entry)
def _open_pooled_sftp(creds: dict[str, Any]) -> tuple[paramiko.SSHClient, paramiko.SFTPClient]:
timeout = int(settings.ne_connect_timeout_sec or 30)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
@ -45,12 +93,6 @@ def _open_sftp(creds: dict[str, Any]) -> tuple[paramiko.SSHClient, paramiko.SFTP
)
sftp = client.open_sftp()
return client, sftp
except HTTPException:
try:
client.close()
except Exception:
pass
raise
except Exception as exc:
try:
client.close()
@ -59,6 +101,23 @@ def _open_sftp(creds: dict[str, Any]) -> tuple[paramiko.SSHClient, paramiko.SFTP
raise HTTPException(status_code=502, detail=f"sftp_connect_failed:{exc}") from exc
def _get_pooled(key: str, creds: dict[str, Any]) -> _PooledSftp:
with _pool_lock:
_reap_pool_unlocked()
entry = _pool.get(key)
if entry is not None:
sock = getattr(entry.sftp, "sock", None)
if sock is not None and not bool(getattr(sock, "closed", False)):
entry.last_used = time.time()
return entry
_pool.pop(key, None)
_close_pooled(entry)
client, sftp = _open_pooled_sftp(creds)
entry = _PooledSftp(key=key, client=client, sftp=sftp)
_pool[key] = entry
return entry
def _resolve(db: Session, *, managed_ne_id: str | None, ume_ne_id: str | None) -> tuple[dict[str, Any], dict[str, Any]]:
try:
creds, device = resolve_cli_target(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
@ -74,6 +133,57 @@ def _resolve(db: Session, *, managed_ne_id: str | None, ume_ne_id: str | None) -
return creds, device
def _normalize_remote(path: str, *, allow_dot: bool = True) -> str:
remote = str(path or "").strip() or ("." if allow_dot else "")
if not remote:
return remote
if remote not in (".", "/"):
remote = posixpath.normpath(remote.replace("\\", "/")) or ("." if allow_dot else "")
return remote
@contextmanager
def _sftp_client(
db: Session,
*,
managed_ne_id: str | None,
ume_ne_id: str | None,
) -> Iterator[tuple[Any, dict[str, Any]]]:
"""Yield ``(sftp, device)`` — prefers live WebCRT SSH session channel."""
creds, device = _resolve(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
ne_key = str(device.get("id") or managed_ne_id or ume_ne_id or "").strip()
sess = find_ssh_session_for_ne(ne_key) if ne_key else None
if sess is not None:
opened = False
try:
with sess._sftp_lock:
sftp = sess._ensure_sftp_unlocked()
opened = True
yield sftp, device
return
except HTTPException:
raise
except Exception as exc:
if opened:
# Operation failed on an already-open session channel — don't double-yield.
raise HTTPException(status_code=502, detail=f"sftp_failed:{exc}") from exc
_log.debug("session sftp open failed ne=%s: %s — pool fallback", ne_key, exc)
key = _pool_key(managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
entry = _get_pooled(key, creds)
with entry.lock:
entry.last_used = time.time()
try:
yield entry.sftp, device
except Exception:
# Drop broken pooled socket so the next call reconnects once.
with _pool_lock:
cur = _pool.pop(key, None)
if cur is not None:
_close_pooled(cur)
raise
def sftp_list(
db: Session,
*,
@ -81,42 +191,34 @@ def sftp_list(
ume_ne_id: str | None,
path: str = ".",
) -> dict[str, Any]:
creds, device = _resolve(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
remote = str(path or ".").strip() or "."
client, sftp = _open_sftp(creds)
remote = _normalize_remote(path, allow_dot=True) or "."
try:
entries = []
for attr in sftp.listdir_attr(remote):
mode = int(getattr(attr, "st_mode", 0) or 0)
is_dir = bool(mode & 0o40000)
entries.append(
{
"name": attr.filename,
"size": int(getattr(attr, "st_size", 0) or 0),
"mtime": int(getattr(attr, "st_mtime", 0) or 0),
"is_dir": is_dir,
}
)
entries.sort(key=lambda x: (not x["is_dir"], str(x["name"]).lower()))
return {
"ne_id": str(device.get("id") or ""),
"ne_name": str(device.get("name") or ""),
"path": remote,
"items": entries,
}
with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, device):
entries = []
for attr in sftp.listdir_attr(remote):
mode = int(getattr(attr, "st_mode", 0) or 0)
name = str(attr.filename or "")
if not name or name in (".", ".."):
continue
entries.append(
{
"name": name,
"size": int(getattr(attr, "st_size", 0) or 0),
"mtime": int(getattr(attr, "st_mtime", 0) or 0),
"is_dir": bool(statmod.S_ISDIR(mode)),
}
)
entries.sort(key=lambda x: (not x["is_dir"], str(x["name"]).lower()))
return {
"ne_id": str(device.get("id") or ""),
"ne_name": str(device.get("name") or ""),
"path": remote,
"items": entries,
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"sftp_list_failed:{exc}") from exc
finally:
try:
sftp.close()
except Exception:
pass
try:
client.close()
except Exception:
pass
def sftp_download(
@ -126,14 +228,13 @@ def sftp_download(
ume_ne_id: str | None,
path: str,
) -> tuple[bytes, str]:
creds, _device = _resolve(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
remote = str(path or "").strip()
remote = _normalize_remote(path, allow_dot=False)
if not remote or remote.endswith("/"):
raise HTTPException(status_code=400, detail="sftp_path_required")
client, sftp = _open_sftp(creds)
try:
with sftp.open(remote, "rb") as fh:
data = fh.read(8 * 1024 * 1024 + 1)
with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, _device):
with sftp.open(remote, "rb") as fh:
data = fh.read(8 * 1024 * 1024 + 1)
if len(data) > 8 * 1024 * 1024:
raise HTTPException(status_code=413, detail="sftp_file_too_large")
return data, posixpath.basename(remote) or "download.bin"
@ -141,15 +242,6 @@ def sftp_download(
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"sftp_download_failed:{exc}") from exc
finally:
try:
sftp.close()
except Exception:
pass
try:
client.close()
except Exception:
pass
def sftp_upload(
@ -160,30 +252,20 @@ def sftp_upload(
remote_path: str,
data: bytes,
) -> dict[str, Any]:
creds, device = _resolve(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
remote = str(remote_path or "").strip()
remote = _normalize_remote(remote_path, allow_dot=False)
if not remote:
raise HTTPException(status_code=400, detail="sftp_path_required")
client, sftp = _open_sftp(creds)
try:
with sftp.open(remote, "wb") as fh:
fh.write(data)
return {
"ok": True,
"ne_id": str(device.get("id") or ""),
"path": remote,
"size": len(data),
}
with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, device):
with sftp.open(remote, "wb") as fh:
fh.write(data)
return {
"ok": True,
"ne_id": str(device.get("id") or ""),
"path": remote,
"size": len(data),
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"sftp_upload_failed:{exc}") from exc
finally:
try:
sftp.close()
except Exception:
pass
try:
client.close()
except Exception:
pass

View file

@ -734,6 +734,28 @@ class WebcrtServiceTests(unittest.TestCase):
# Direct SSH is allowed (no raise).
_require_ssh_direct({"protocol": "ssh", "hop_enabled": False}, {"protocol": "ssh"})
@patch.object(svc, "_audit")
def test_find_ssh_session_for_ne_prefers_attached(self, _mock_audit: MagicMock) -> None:
conn = _FakeConn()
sess = svc.WebcrtSession(
session_id="sftpne",
ne_id="ne-sftp",
ne_name="lab",
ne_ip="1.2.3.4",
protocol="ssh",
cols=80,
rows=24,
conn=conn, # type: ignore[arg-type]
)
sess.state = "ready"
sess.attached = True
with svc._sessions_lock:
svc._sessions["sftpne"] = sess
found = svc.find_ssh_session_for_ne("ne-sftp")
self.assertIs(found, sess)
self.assertIsNone(svc.find_ssh_session_for_ne("other"))
svc.close_session("sftpne", reason="test")
@patch.object(svc, "_audit")
def test_reattach_clears_detach_deadline(self, _mock_audit: MagicMock) -> None:
conn = _FakeConn()

View file

@ -69,7 +69,12 @@ type Props = {
pasteDelayMs?: number;
copyOnSelect?: boolean;
keywordHighlight?: KeywordHighlightConfig;
onStatus?: (state: string, message?: string, phase?: string) => void;
onStatus?: (
state: string,
message?: string,
phase?: string,
meta?: { sftpReady?: boolean; cliHop?: boolean },
) => void;
onReady?: () => void;
onStdout?: (data: string) => void;
};
@ -481,6 +486,8 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
state?: string;
message?: string;
phase?: string;
sftp_ready?: boolean;
cli_hop?: boolean;
};
if (msg.type === "stdout" && typeof msg.data === "string") {
writeStdout(msg.data);
@ -490,7 +497,14 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
if (msg.type === "status") {
if (!isActiveSocket()) return;
const phase = typeof msg.phase === "string" ? msg.phase : undefined;
onStatusRef.current?.(String(msg.state || ""), msg.message, phase);
const meta =
typeof msg.sftp_ready === "boolean" || typeof msg.cli_hop === "boolean"
? {
sftpReady: typeof msg.sftp_ready === "boolean" ? msg.sftp_ready : undefined,
cliHop: typeof msg.cli_hop === "boolean" ? msg.cli_hop : undefined,
}
: undefined;
onStatusRef.current?.(String(msg.state || ""), msg.message, phase, meta);
if (msg.state === "connected" || msg.state === "connecting") {
maybeFocus();
return;

View file

@ -1046,6 +1046,9 @@ const en = {
upload: "Upload",
uploaded: "Upload complete",
dir: "dir",
loading: "Loading…",
enterHint: "Double-click to open folder",
downloadHint: "Click to download",
},
term: {
findPh: "Find…",
@ -1073,6 +1076,7 @@ const en = {
sftpHop: "SFTP does not support hop devices yet (direct SSH only)",
sftpSsh: "SFTP requires SSH",
sftpNeedPassword: "SFTP needs a saved SSH password (one-shot dialog passwords are not reused for files)",
sftpUnsupported: "This SSH session has no SFTP channel (device may not offer it)",
queueDropped: "Terminal output too fast; about {{count}} chunk(s) dropped — transcript may be incomplete",
websocket: "WebSocket connection failed",
},

View file

@ -1043,6 +1043,9 @@ const zh = {
upload: "上传",
uploaded: "上传成功",
dir: "目录",
loading: "正在加载…",
enterHint: "双击进入目录",
downloadHint: "单击下载文件",
},
term: {
findPh: "查找…",
@ -1070,6 +1073,7 @@ const zh = {
sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH",
sftpSsh: "SFTP 仅支持 SSH 协议",
sftpNeedPassword: "SFTP 需要已保存的 SSH 密码(会话弹窗密码不会用于文件传输)",
sftpUnsupported: "当前 SSH 会话未提供 SFTP(设备可能未开启)",
queueDropped: "终端输出过快,已丢弃约 {{count}} 段,内容可能不完整",
websocket: "WebSocket 连接失败",
},

View file

@ -3046,6 +3046,10 @@ pre {
flex-direction: column;
}
.webcrt-sftp.is-busy {
cursor: progress;
}
.webcrt-sftp__bar {
display: flex;
gap: 4px;
@ -3073,6 +3077,19 @@ pre {
cursor: pointer;
}
.webcrt-sftp__bar button:disabled,
.webcrt-sftp__upload.is-disabled {
opacity: 0.55;
cursor: not-allowed;
}
.webcrt-sftp__status {
padding: 4px 10px;
font-size: 11px;
color: #94a3b8;
border-bottom: 1px solid #1e293b;
}
.webcrt-sftp__list {
list-style: none;
margin: 0;
@ -3083,6 +3100,9 @@ pre {
.webcrt-sftp__list button {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
text-align: left;
background: transparent;
border: 0;
@ -3091,12 +3111,43 @@ pre {
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
user-select: none;
}
.webcrt-sftp__list button:hover {
.webcrt-sftp__icon {
display: inline-flex;
flex-shrink: 0;
color: #94a3b8;
line-height: 1;
}
.webcrt-sftp__icon.is-dir {
color: #fbbf24;
}
.webcrt-sftp__name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.webcrt-sftp__size {
flex-shrink: 0;
color: #64748b;
font-variant-numeric: tabular-nums;
}
.webcrt-sftp__list button:hover:not(:disabled) {
background: #1e293b;
}
.webcrt-sftp__list button:disabled {
opacity: 0.7;
cursor: progress;
}
@media (max-width: 900px) {
.webcrt-shell {
grid-template-columns: 1fr;

View file

@ -114,6 +114,8 @@ type TermTab = {
encoding: string;
/** From session create — nested CLI hop cannot use SFTP. */
cliHop?: boolean;
/** SFTP channel attached on the live SSH transport after connect. */
sftpReady?: boolean;
};
type TabMenuState = { key: string; x: number; y: number };
@ -274,6 +276,22 @@ function ComputerIcon() {
);
}
function FolderIcon() {
return (
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden>
<path d="M3 6a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6z" />
</svg>
);
}
function FileIcon() {
return (
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden>
<path d="M6 2h8l4 4v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm7 1.5V8h4.5L13 3.5z" />
</svg>
);
}
function isSshAuthFailure(err: unknown): boolean {
const raw = String(err).toLowerCase();
return (
@ -319,26 +337,47 @@ function isDeviceClosedMessage(err: unknown): boolean {
/** SFTP is direct SSH only; returns i18n key when unavailable. */
function sftpUnavailableReason(
tab: Pick<TermTab, "target" | "cliHop">,
): "webcrt.err.sftpSsh" | "webcrt.err.sftpHop" | "webcrt.err.sftpNeedPassword" | null {
tab: Pick<TermTab, "target" | "cliHop" | "sftpReady" | "status">,
):
| "webcrt.err.sftpSsh"
| "webcrt.err.sftpHop"
| "webcrt.err.sftpNeedPassword"
| "webcrt.err.sftpUnsupported"
| null {
const proto = String(tab.target.protocol || "ssh").toLowerCase();
if (proto === "telnet") return "webcrt.err.sftpSsh";
if (tab.cliHop || tab.target.hop_enabled) return "webcrt.err.sftpHop";
// Inventory/quick-connect SFTP opens a fresh Paramiko session from saved DB creds.
if (tab.target.source !== "ume" && !tab.target.has_password) return "webcrt.err.sftpNeedPassword";
// Prefer the live SSH session channel; inventory still needs saved password for pool fallback.
if (tab.target.source !== "ume" && !tab.target.has_password && tab.sftpReady !== true) {
return "webcrt.err.sftpNeedPassword";
}
if (tab.status === "connected" && tab.sftpReady === false) return "webcrt.err.sftpUnsupported";
return null;
}
function sftpParentPath(path: string): string {
const cur = String(path || ".").replace(/\\/g, "/").replace(/\/+$/, "") || ".";
if (cur === "." || cur === "/") return cur === "/" ? "/" : ".";
const parts = cur.split("/").filter((p, i) => p || i === 0);
const parts = cur.split("/").filter((p, i) => (i === 0 && cur.startsWith("/")) || Boolean(p));
if (parts.length <= 1) return cur.startsWith("/") ? "/" : ".";
parts.pop();
const parent = parts.join("/") || (cur.startsWith("/") ? "/" : ".");
if (cur.startsWith("/")) {
return parts.length <= 1 ? "/" : parts.join("/");
}
const parent = parts.join("/");
return parent || ".";
}
function joinSftpPath(base: string, name: string): string {
const n = String(name || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
if (!n || n === ".") return String(base || ".") || ".";
if (n === "..") return sftpParentPath(base);
const b = String(base || ".").replace(/\\/g, "/").replace(/\/+$/, "") || ".";
if (b === "." || b === "") return n;
if (b === "/") return `/${n}`;
return `${b}/${n}`;
}
function connectFailedNe(err: unknown): ManagedNeItem | null {
if (!(err instanceof ApiRequestError)) return null;
const detail = err.detail;
@ -413,7 +452,10 @@ export function WebcrtPage() {
const [activeTabKey, setActiveTabKey] = useState("");
const [sftpOpen, setSftpOpen] = useState(false);
const [sftpPath, setSftpPath] = useState(".");
const [sftpBusy, setSftpBusy] = useState(false);
const [sftpItems, setSftpItems] = useState<Array<{ name: string; size: number; mtime: number; is_dir: boolean }>>([]);
const sftpPathRef = useRef(".");
sftpPathRef.current = sftpPath;
const [sessionOpts, setSessionOpts] = useState<SessionOptions>(() => loadSessionOptions());
const [optionsMenuOpen, setOptionsMenuOpen] = useState(false);
const [sessionOptsModalOpen, setSessionOptsModalOpen] = useState(false);
@ -527,7 +569,13 @@ export function WebcrtPage() {
}, []);
const attachSessionResult = useCallback(
(target: CliTargetItem, sessionId: string, encoding: string, cliHop?: boolean) => {
(
target: CliTargetItem,
sessionId: string,
encoding: string,
cliHop?: boolean,
sftpReady?: boolean,
) => {
const key = targetKey(target);
const existing = tabsRef.current.find((tab) => tab.key === key);
const pending: TermTab = {
@ -542,6 +590,7 @@ export function WebcrtPage() {
encoding,
errorMessage: undefined,
cliHop: Boolean(cliHop),
sftpReady: sftpReady === true ? true : sftpReady === false ? false : existing?.sftpReady,
};
setTabs((prev) => {
const without = prev.filter((x) => x.key !== key);
@ -650,6 +699,7 @@ export function WebcrtPage() {
connectPhase: "authenticating",
termEpoch: pending.termEpoch + 1,
cliHop: Boolean(sess.cli_hop),
sftpReady: typeof sess.sftp_ready === "boolean" ? sess.sftp_ready : undefined,
});
showOk(t("webcrt.opened", { name: deviceLabel(target) }));
} catch (err) {
@ -745,7 +795,13 @@ export function WebcrtPage() {
connect_status: result.ne.connect_status || "unknown",
cli_profile_ready: true,
};
attachSessionResult(target, result.session_id, dims.encoding, Boolean(result.cli_hop));
attachSessionResult(
target,
result.session_id,
dims.encoding,
Boolean(result.cli_hop),
result.sftp_ready,
);
setHostDialogOpen(false);
setHostForm(emptyHostForm());
setSource(listSource === "managed" ? "managed" : "webcrt");
@ -808,6 +864,7 @@ export function WebcrtPage() {
sess.session_id,
dims.encoding,
Boolean(sess.cli_hop),
sess.sftp_ready,
);
setAuthDialog(null);
setAuthForm(emptyAuthForm());
@ -842,7 +899,13 @@ export function WebcrtPage() {
connect_status: result.ne.connect_status || "unknown",
cli_profile_ready: true,
};
attachSessionResult(target, result.session_id, dims.encoding, Boolean(result.cli_hop));
attachSessionResult(
target,
result.session_id,
dims.encoding,
Boolean(result.cli_hop),
result.sftp_ready,
);
setAuthDialog(null);
setAuthForm(emptyAuthForm());
setSource("webcrt");
@ -1022,21 +1085,40 @@ export function WebcrtPage() {
}
}, [activeTabKey, showOk, showError, t]);
const refreshSftp = useCallback(async () => {
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
if (!tab) return;
try {
const body =
tab.target.source === "ume"
? { ume_ne_id: tab.target.ume_ne_id || tab.target.id, path: sftpPath }
: { ne_id: tab.target.id, path: sftpPath };
const res = await webcrtSftpList(body);
setSftpItems(res.items || []);
setSftpPath(res.path || sftpPath);
} catch (err) {
showError(webcrtErrorMessage(err, t));
}
}, [activeTabKey, showError, sftpPath, t]);
const refreshSftp = useCallback(
async (pathOverride?: string) => {
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
if (!tab) return;
const path = String(pathOverride ?? sftpPathRef.current ?? ".").trim() || ".";
setSftpBusy(true);
try {
const body =
tab.target.source === "ume"
? { ume_ne_id: tab.target.ume_ne_id || tab.target.id, path }
: { ne_id: tab.target.id, path };
const res = await webcrtSftpList(body);
setSftpItems(res.items || []);
const nextPath = String(res.path || path).trim() || path;
sftpPathRef.current = nextPath;
setSftpPath(nextPath);
} catch (err) {
showError(webcrtErrorMessage(err, t));
} finally {
setSftpBusy(false);
}
},
[activeTabKey, showError, t],
);
const navigateSftp = useCallback(
(nextPath: string) => {
const path = String(nextPath || ".").trim() || ".";
sftpPathRef.current = path;
setSftpPath(path);
void refreshSftp(path);
},
[refreshSftp],
);
// Auto-connect from /webcrt?ne_id=...
useEffect(() => {
@ -1482,12 +1564,14 @@ export function WebcrtPage() {
onReady={() => {
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
}}
onStatus={(state, message, phase) => {
onStatus={(state, message, phase, meta) => {
if (state === "connected") {
updateTab(tab.key, {
status: "connected",
connectPhase: undefined,
errorMessage: undefined,
...(typeof meta?.sftpReady === "boolean" ? { sftpReady: meta.sftpReady } : {}),
...(typeof meta?.cliHop === "boolean" ? { cliHop: meta.cliHop } : {}),
});
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
} else if (state === "open" || state === "connecting") {
@ -1498,6 +1582,8 @@ export function WebcrtPage() {
updateTab(tab.key, {
status: "connecting",
connectPhase: nextPhase,
...(typeof meta?.sftpReady === "boolean" ? { sftpReady: meta.sftpReady } : {}),
...(typeof meta?.cliHop === "boolean" ? { cliHop: meta.cliHop } : {}),
});
} else if (state === "warning") {
const m = String(message || "");
@ -1570,33 +1656,46 @@ export function WebcrtPage() {
);
})}
{sftpOpen && activeTab && sftpAllowed ? (
<div className="webcrt-sftp">
<div className={`webcrt-sftp${sftpBusy ? " is-busy" : ""}`}>
<div className="webcrt-sftp__bar">
<button
type="button"
title={t("webcrt.sftp.up")}
onClick={() => {
const parent = sftpParentPath(sftpPath);
setSftpPath(parent);
window.setTimeout(() => void refreshSftp(), 0);
}}
disabled={sftpBusy}
onClick={() => navigateSftp(sftpParentPath(sftpPathRef.current))}
>
{t("webcrt.sftp.up")}
</button>
<input value={sftpPath} onChange={(e) => setSftpPath(e.target.value)} />
<button type="button" onClick={() => void refreshSftp()}>
<input
value={sftpPath}
disabled={sftpBusy}
onChange={(e) => {
const v = e.target.value;
sftpPathRef.current = v;
setSftpPath(v);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void refreshSftp(sftpPathRef.current);
}
}}
/>
<button type="button" disabled={sftpBusy} onClick={() => void refreshSftp()}>
{t("webcrt.sftp.refresh")}
</button>
<label className="webcrt-sftp__upload">
<label className={`webcrt-sftp__upload${sftpBusy ? " is-disabled" : ""}`}>
{t("webcrt.sftp.upload")}
<input
type="file"
hidden
disabled={sftpBusy}
onChange={(e) => {
const file = e.target.files?.[0];
if (!file || !activeTab) return;
const remote = `${sftpPath.replace(/\/$/, "")}/${file.name}`.replace(/^\.\//, "");
if (!file || !activeTab || sftpBusy) return;
const remote = joinSftpPath(sftpPathRef.current, file.name);
void (async () => {
setSftpBusy(true);
try {
const body =
activeTab.target.source === "ume"
@ -1608,9 +1707,11 @@ export function WebcrtPage() {
: { ne_id: activeTab.target.id, remote_path: remote, file };
await webcrtSftpUpload(body);
showOk(t("webcrt.sftp.uploaded"));
await refreshSftp();
await refreshSftp(sftpPathRef.current);
} catch (err) {
showError(webcrtErrorMessage(err, t));
} finally {
setSftpBusy(false);
}
})();
e.target.value = "";
@ -1618,30 +1719,31 @@ export function WebcrtPage() {
/>
</label>
</div>
{sftpBusy ? (
<div className="webcrt-sftp__status" role="status">
{t("webcrt.sftp.loading")}
</div>
) : null}
<ul className="webcrt-sftp__list">
{sftpItems.map((it) => (
<li key={it.name}>
<li key={`${sftpPath}:${it.is_dir ? "d" : "f"}:${it.name}`}>
<button
type="button"
disabled={sftpBusy}
title={it.is_dir ? t("webcrt.sftp.enterHint") : t("webcrt.sftp.downloadHint")}
onClick={() => {
if (it.is_dir) {
const next = `${sftpPath.replace(/\/$/, "")}/${it.name}`.replace(/^\.\//, "");
setSftpPath(next);
window.setTimeout(() => void refreshSftp(), 0);
return;
}
if (it.is_dir || sftpBusy) return;
const remote = joinSftpPath(sftpPathRef.current, it.name);
void (async () => {
setSftpBusy(true);
try {
const body =
activeTab.target.source === "ume"
? {
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
path: `${sftpPath.replace(/\/$/, "")}/${it.name}`,
path: remote,
}
: {
ne_id: activeTab.target.id,
path: `${sftpPath.replace(/\/$/, "")}/${it.name}`,
};
: { ne_id: activeTab.target.id, path: remote };
const blob = await webcrtSftpDownload(body);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@ -1651,13 +1753,21 @@ export function WebcrtPage() {
URL.revokeObjectURL(url);
} catch (err) {
showError(webcrtErrorMessage(err, t));
} finally {
setSftpBusy(false);
}
})();
}}
onDoubleClick={() => {
if (!it.is_dir || sftpBusy) return;
navigateSftp(joinSftpPath(sftpPathRef.current, it.name));
}}
>
{it.is_dir ? `[${t("webcrt.sftp.dir")}] ` : ""}
{it.name}
{!it.is_dir ? ` (${it.size})` : ""}
<span className={`webcrt-sftp__icon${it.is_dir ? " is-dir" : ""}`}>
{it.is_dir ? <FolderIcon /> : <FileIcon />}
</span>
<span className="webcrt-sftp__name">{it.name}</span>
{!it.is_dir ? <span className="webcrt-sftp__size">({it.size})</span> : null}
</button>
</li>
))}

View file

@ -503,6 +503,8 @@ export type WebcrtSessionCreateResult = {
state?: string;
/** True when session used a vendor CLI hop (nested stelnet/telnet). */
cli_hop?: boolean;
/** True when SFTP channel opened on the same SSH transport. */
sftp_ready?: boolean;
};
export const createWebcrtSession = (body: {