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: {