From 499b4cc2a14df56544e139a8319697ea098fc29a Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 2 Aug 2026 00:13:07 +0800 Subject: [PATCH] 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 --- netx_api/webcrt_router.py | 2 + netx_api/webcrt_service.py | 103 +++++++++++++ netx_api/webcrt_sftp.py | 234 +++++++++++++++++++---------- tests/test_webcrt.py | 22 +++ web/src/components/WebTerminal.tsx | 18 ++- web/src/i18n/en.ts | 4 + web/src/i18n/zh.ts | 4 + web/src/index.css | 53 ++++++- web/src/pages/WebcrtPage.tsx | 214 +++++++++++++++++++------- web/src/services/api.ts | 2 + 10 files changed, 525 insertions(+), 131 deletions(-) diff --git a/netx_api/webcrt_router.py b/netx_api/webcrt_router.py index 76960ef..82f6360 100644 --- a/netx_api/webcrt_router.py +++ b/netx_api/webcrt_router.py @@ -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 diff --git a/netx_api/webcrt_service.py b/netx_api/webcrt_service.py index fd5de49..85f4cfc 100644 --- a/netx_api/webcrt_service.py +++ b/netx_api/webcrt_service.py @@ -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), } diff --git a/netx_api/webcrt_sftp.py b/netx_api/webcrt_sftp.py index 8a44448..544be0b 100644 --- a/netx_api/webcrt_sftp.py +++ b/netx_api/webcrt_sftp.py @@ -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 diff --git a/tests/test_webcrt.py b/tests/test_webcrt.py index 5f752b4..b61f334 100644 --- a/tests/test_webcrt.py +++ b/tests/test_webcrt.py @@ -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() diff --git a/web/src/components/WebTerminal.tsx b/web/src/components/WebTerminal.tsx index 9710b96..18d7a8f 100644 --- a/web/src/components/WebTerminal.tsx +++ b/web/src/components/WebTerminal.tsx @@ -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(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(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; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 62c195a..a8f2e46 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", }, diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 46498c8..1aa9dbe 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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 连接失败", }, diff --git a/web/src/index.css b/web/src/index.css index f7b320e..684e1fe 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index 1f526e3..6cee764 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -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 ( + + + + ); +} + +function FileIcon() { + return ( + + + + ); +} + 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, -): "webcrt.err.sftpSsh" | "webcrt.err.sftpHop" | "webcrt.err.sftpNeedPassword" | null { + tab: Pick, +): + | "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>([]); + const sftpPathRef = useRef("."); + sftpPathRef.current = sftpPath; const [sessionOpts, setSessionOpts] = useState(() => 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 ? ( -
+
- setSftpPath(e.target.value)} /> - -
+ {sftpBusy ? ( +
+ {t("webcrt.sftp.loading")} +
+ ) : null}
    {sftpItems.map((it) => ( -
  • +
  • ))} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index af2acc4..4b3d2e6 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -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: {