diff --git a/netx_api/cli_schemas.py b/netx_api/cli_schemas.py index 8ca4151..bb71c49 100644 --- a/netx_api/cli_schemas.py +++ b/netx_api/cli_schemas.py @@ -121,5 +121,6 @@ class CliTargetOut(BaseModel): protocol: str = "" username: str = "" has_password: bool = False + hop_enabled: bool = False connect_status: str = "unknown" cli_profile_ready: bool = False diff --git a/netx_api/cli_service.py b/netx_api/cli_service.py index b7fe63e..4b3ae54 100644 --- a/netx_api/cli_service.py +++ b/netx_api/cli_service.py @@ -269,6 +269,10 @@ def list_cli_targets( offset = (page - 1) * page_size kw = str(keyword or "").strip() + default_profile = get_default_profile(db) + default_hop = bool(getattr(default_profile, "hop_enabled", False)) if default_profile else False + default_proto = str(getattr(default_profile, "protocol", "") or "ssh") if default_profile else "ssh" + def _managed_item(row: Any, *, list_source: str = "managed") -> dict[str, Any]: return CliTargetOut( source=list_source, @@ -281,11 +285,20 @@ def list_cli_targets( protocol=str(getattr(row, "protocol", "") or ""), username=str(getattr(row, "username", "") or ""), has_password=bool(str(getattr(row, "password_enc", "") or "").strip()), + hop_enabled=bool(getattr(row, "hop_enabled", False)), connect_status=str(row.connect_status), cli_profile_ready=True, ).model_dump() def _ume_item(inv: UmeInventoryNE, ov: UmeCliOverride | None) -> dict[str, Any]: + # UME hop/protocol come from the selected/default CLI profile (SFTP gating). + hop = default_hop + proto = default_proto + if ov and ov.profile_id: + pref = db.get(CliConnectProfile, str(ov.profile_id)) + if pref is not None: + hop = bool(pref.hop_enabled) + proto = str(pref.protocol or proto or "ssh") return CliTargetOut( source="ume", id=str(inv.ne_id), @@ -294,6 +307,8 @@ def list_cli_targets( ip_address=str(inv.ip_address or ""), ne_type=str(inv.ne_type or ""), vendor=str(inv.vendor or ""), + protocol=proto, + hop_enabled=hop, connect_status=str(ov.connect_status if ov else "unknown"), cli_profile_ready=ready, ).model_dump() diff --git a/netx_api/config.py b/netx_api/config.py index e68fbe9..4aacfef 100644 --- a/netx_api/config.py +++ b/netx_api/config.py @@ -87,6 +87,8 @@ class Settings(BaseSettings): webcrt_idle_timeout_sec: int = 1800 webcrt_connect_timeout_sec: int = 90 webcrt_attach_timeout_sec: int = 60 + # Keep device PTY after WS drop so the UI can re-attach (reconnect / remount). + webcrt_detach_grace_sec: int = 120 webcrt_data_dir: str = "data/webcrt" # SSH transport keepalive interval (seconds); 0 disables (default off). webcrt_keepalive_sec: int = 0 diff --git a/netx_api/webcrt_router.py b/netx_api/webcrt_router.py index 576c2dd..76960ef 100644 --- a/netx_api/webcrt_router.py +++ b/netx_api/webcrt_router.py @@ -22,8 +22,10 @@ from .webcrt_service import ( get_session, list_sessions, mark_attached, + read_session_log_tail, wait_session_ready, _decode_bytes, + _encode_text, _normalize_encoding, ) @@ -378,20 +380,32 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: } ) - # Replay full login transcript (kept for StrictMode remount / brief reconnect). - bootstrap = bytes(sess.bootstrap_output or b"") - if bootstrap: - try: + # First attach: login bootstrap. Later attaches (tab focus / StrictMode): session log tail. + first_attach = not bool(sess.bootstrap_replayed) + replay_bytes = b"" + replay_text = "" + if first_attach: + raw_boot = bytes(sess.bootstrap_output or b"") + if raw_boot: if _normalize_encoding(sess.encoding) != "utf-8": - bootstrap = _decode_bytes(bootstrap, sess.encoding).encode("utf-8", errors="replace") - await websocket.send_bytes(bootstrap) + replay_text = _decode_bytes(raw_boot, sess.encoding) + replay_bytes = replay_text.encode("utf-8", errors="replace") + else: + replay_bytes = raw_boot + replay_text = _decode_bytes(raw_boot, "utf-8") + sess.bootstrap_replayed = True + else: + replay_text = read_session_log_tail(session_id, max_bytes=49152) + if replay_text: + replay_bytes = _encode_text(replay_text, "utf-8") + if replay_bytes: + try: + await websocket.send_bytes(replay_bytes) except Exception: try: - await websocket.send_json( - {"type": "stdout", "data": _decode_bytes(bytes(sess.bootstrap_output or b""), sess.encoding)} - ) + await websocket.send_json({"type": "stdout", "data": replay_text or ""}) except Exception: - _log.debug("webcrt bootstrap send failed session=%s", session_id, exc_info=True) + _log.debug("webcrt bootstrap/replay send failed session=%s", session_id, exc_info=True) stop = asyncio.Event() stdin_buf: list[str] = [] @@ -438,9 +452,29 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: except Exception: return False + async def _notify_queue_drops() -> None: + try: + delta = int(sess.out_queue.take_drop_delta() or 0) + except Exception: + delta = 0 + if delta <= 0: + return + try: + await websocket.send_json( + { + "type": "status", + "state": "warning", + "message": f"queue_dropped:{delta}", + "dropped": delta, + } + ) + except Exception: + pass + while not stop.is_set(): + # Longer block is cheap now (Condition wait); cuts executor churn when idle. chunk = await loop.run_in_executor( - None, lambda: sess.take_stdout(attach_gen, timeout=0.05) + None, lambda: sess.take_stdout(attach_gen, timeout=0.2) ) if chunk == "stale": break @@ -449,9 +483,11 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: if not await _flush_pending(): stop.set() break + await _notify_queue_drops() continue if chunk is None: await _flush_pending() + await _notify_queue_drops() stop.set() try: await websocket.send_json( @@ -469,6 +505,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: if not await _flush_pending(): stop.set() break + await _notify_queue_drops() reader_task = asyncio.create_task(pump_stdout()) if sess.needs_live_prompt: @@ -552,11 +589,12 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: await reader_task except Exception: pass - # Keep device session briefly so React remount / blip can re-attach. + # Keep device session so UI reconnect / remount can re-attach. if get_session(session_id) is not None: + grace = float(getattr(settings, "webcrt_detach_grace_sec", 120) or 120) detach_session( session_id, - grace_sec=8.0, + grace_sec=max(8.0, grace), client=_client_label(websocket=websocket), attach_gen=attach_gen, ) diff --git a/netx_api/webcrt_service.py b/netx_api/webcrt_service.py index f262aa2..fd5de49 100644 --- a/netx_api/webcrt_service.py +++ b/netx_api/webcrt_service.py @@ -34,8 +34,6 @@ _log = logging.getLogger("netx.webcrt") _sessions_lock = threading.Lock() _sessions: dict[str, "WebcrtSession"] = {} _reaper_started = False -# Sentinel for take_stdout timeout (distinct from device EOF None). -_STDOUT_MISSING = object() # Network device CLI key rewrites (SecureCRT-like). # - Backspace: DEL(0x7f) -> BS(0x08) @@ -347,11 +345,12 @@ class _BoundedByteQueue: def __init__(self, maxsize: int = 2000) -> None: self._q: queue.Queue[bytes | None] = queue.Queue() self._max = max(8, int(maxsize or 2000)) - self._lock = threading.Lock() + self._cond = threading.Condition() self.dropped = 0 + self._reported = 0 def put(self, item: bytes | None) -> None: - with self._lock: + with self._cond: while self._q.qsize() >= self._max: try: self._q.get_nowait() @@ -359,15 +358,38 @@ class _BoundedByteQueue: except queue.Empty: break self._q.put(item) + self._cond.notify() def put_nowait(self, item: bytes | None) -> None: self.put(item) def get_nowait(self) -> bytes | None: - return self._q.get_nowait() + with self._cond: + return self._q.get_nowait() + + def get(self, timeout: float = 0.25) -> bytes | None: + """Block until a chunk is available or timeout (raises queue.Empty).""" + deadline = time.time() + max(0.0, float(timeout)) + with self._cond: + while self._q.empty(): + remaining = deadline - time.time() + if remaining <= 0: + raise queue.Empty + self._cond.wait(timeout=remaining) + return self._q.get_nowait() def qsize(self) -> int: - return self._q.qsize() + with self._cond: + return self._q.qsize() + + def take_drop_delta(self) -> int: + """Return newly dropped chunk count since last call (for client notice).""" + with self._cond: + delta = int(self.dropped) - int(self._reported) + if delta <= 0: + return 0 + self._reported = int(self.dropped) + return delta def _utc_now() -> datetime: @@ -390,6 +412,33 @@ def _session_log_path(session_id: str) -> Path: return folder / f"{session_id}.log" +def read_session_log_tail(session_id: str, *, max_bytes: int = 49152) -> str: + """Best-effort UTF-8 tail of the on-disk session transcript (for WS re-attach).""" + path = _session_log_path(session_id) + try: + if not path.is_file(): + return "" + size = path.stat().st_size + take = max(1024, min(int(max_bytes or 49152), 256 * 1024)) + with path.open("rb") as fh: + if size > take: + fh.seek(size - take) + raw = fh.read() + # Drop partial first line after seek. + nl = raw.find(b"\n") + if 0 <= nl < len(raw) - 1: + raw = raw[nl + 1 :] + else: + raw = fh.read() + text = raw.decode("utf-8", errors="replace") + # Strip header comment lines from the visible replay. + lines = [ln for ln in text.splitlines(keepends=True) if not ln.startswith("# session=")] + return "".join(lines) + except Exception: + _log.debug("webcrt session log tail failed session=%s", session_id, exc_info=True) + return "" + + def _audit(event: str, **fields: Any) -> None: record = {"ts": _utc_iso(), "event": event, **fields} try: @@ -428,6 +477,8 @@ class WebcrtSession: connect_started_at: float = field(default_factory=time.time) connect_finished_at: float | None = None bootstrap_output: bytes = b"" + # First WS attach gets login bootstrap; later attaches prefer session-log tail. + bootstrap_replayed: bool = False needs_live_prompt: bool = True # React StrictMode remounts open a second WS before the first fully tears down. # Only the newest attach_gen may consume out_queue / mark detach. @@ -499,19 +550,20 @@ class WebcrtSession: with self._stdout_lock: if attach_gen != self.attach_gen: return "stale" - try: - chunk = self.out_queue.get_nowait() - except queue.Empty: - chunk = _STDOUT_MISSING - if chunk is not _STDOUT_MISSING: - if attach_gen != self.attach_gen: - # Put back including EOF sentinel so the new owner still sees close. - self.out_queue.put(chunk) - return "stale" - return chunk # bytes | None - if time.time() >= deadline: + remaining = deadline - time.time() + if remaining <= 0: return "empty" - time.sleep(0.005) + # Slice waits so we can notice attach_gen bumps without busy-spinning. + try: + chunk = self.out_queue.get(timeout=min(0.05, remaining)) + except queue.Empty: + continue + with self._stdout_lock: + if attach_gen != self.attach_gen: + # Put back including EOF sentinel so the new owner still sees close. + self.out_queue.put(chunk) + return "stale" + return chunk # bytes | None def write_stdin(self, data: str) -> None: if self.closed or self.conn is None: @@ -803,10 +855,14 @@ def _reap_sessions() -> None: if sess.detach_deadline is not None: if now >= sess.detach_deadline: to_close.append((sess, "detach_timeout")) - elif (now - sess.created_at) > attach: - to_close.append((sess, "attach_timeout")) - elif (now - sess.last_activity) > idle: - to_close.append((sess, "idle_timeout")) + else: + # Start attach clock after connect finishes (not HTTP create time), + # so slow auth + UI mount does not race attach_timeout. + anchor = float(sess.connect_finished_at or sess.created_at or now) + if (now - anchor) > attach: + to_close.append((sess, "attach_timeout")) + elif (now - sess.last_activity) > idle: + to_close.append((sess, "idle_timeout")) for sess in to_nudge: try: # Touch without changing visible prompt when payload is empty/null-ish. diff --git a/tests/test_managed_ne.py b/tests/test_managed_ne.py index 73c4d0f..8a219c9 100644 --- a/tests/test_managed_ne.py +++ b/tests/test_managed_ne.py @@ -436,11 +436,15 @@ class WebcrtUpsertAndTargetsTests(unittest.TestCase): self.assertEqual(webcrt["items"][0]["source"], "webcrt") self.assertEqual(webcrt["items"][0]["ip_address"], "10.7.7.7") self.assertTrue(webcrt["items"][0]["has_password"]) + self.assertIn("hop_enabled", webcrt["items"][0]) + self.assertFalse(webcrt["items"][0]["hop_enabled"]) managed = list_cli_targets(self.db, source="managed", page=1, page_size=50) ips = {x["ip_address"] for x in managed["items"]} self.assertIn("10.7.7.8", ips) self.assertNotIn("10.7.7.7", ips) + zte = next(x for x in managed["items"] if x["ip_address"] == "10.7.7.8") + self.assertIn("hop_enabled", zte) def test_upsert_session_host_telnet_no_password(self): out, action = upsert_webcrt_session_host( diff --git a/tests/test_webcrt.py b/tests/test_webcrt.py index 27f35dd..5f752b4 100644 --- a/tests/test_webcrt.py +++ b/tests/test_webcrt.py @@ -490,6 +490,38 @@ class WebcrtServiceTests(unittest.TestCase): self.assertFalse(sess.attached) svc.close_session("race", reason="test") + @patch.object(svc, "_audit") + def test_detach_grace_keeps_session_until_deadline(self, _mock_audit: MagicMock) -> None: + conn = _FakeConn() + sess = svc.WebcrtSession( + session_id="grace", + ne_id="ne1", + ne_name="lab", + ne_ip="1.2.3.4", + protocol="ssh", + cols=80, + rows=24, + conn=conn, # type: ignore[arg-type] + ) + sess.attached = True + with svc._sessions_lock: + svc._sessions["grace"] = sess + svc.detach_session("grace", grace_sec=120.0, attach_gen=0) + self.assertIsNotNone(svc.get_session("grace")) + self.assertFalse(sess.attached) + self.assertIsNotNone(sess.detach_deadline) + # Still within grace — reaper must not close. + with patch.object(svc.settings, "webcrt_attach_timeout_sec", 99999): + with patch.object(svc.settings, "webcrt_idle_timeout_sec", 99999): + svc._reap_sessions() + self.assertIsNotNone(svc.get_session("grace")) + # Expire grace. + sess.detach_deadline = time.time() - 1 + with patch.object(svc.settings, "webcrt_attach_timeout_sec", 99999): + with patch.object(svc.settings, "webcrt_idle_timeout_sec", 99999): + svc._reap_sessions() + self.assertIsNone(svc.get_session("grace")) + @patch.object(svc, "_audit") def test_attach_timeout_reaper(self, _mock_audit: MagicMock) -> None: conn = _FakeConn() @@ -511,6 +543,47 @@ class WebcrtServiceTests(unittest.TestCase): svc._reap_sessions() self.assertIsNone(svc.get_session("stale")) + @patch.object(svc, "_audit") + def test_attach_timeout_uses_connect_finished_at(self, _mock_audit: MagicMock) -> None: + """Slow connect should not burn the attach window from HTTP create time.""" + conn = _FakeConn() + sess = svc.WebcrtSession( + session_id="late", + ne_id="ne1", + 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.created_at = time.time() - 120 + sess.connect_finished_at = time.time() - 5 + with svc._sessions_lock: + svc._sessions["late"] = sess + with patch.object(svc.settings, "webcrt_attach_timeout_sec", 30): + with patch.object(svc.settings, "webcrt_idle_timeout_sec", 99999): + svc._reap_sessions() + self.assertIsNotNone(svc.get_session("late")) + svc.close_session("late", reason="test") + + def test_session_log_tail_strips_header(self) -> None: + sid = "tailtest" + path = svc._session_log_path(sid) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("# session=tailtest ne=x ip=1.2.3.4 ts=now\nR2#\nshow ver\n", encoding="utf-8") + try: + text = svc.read_session_log_tail(sid, max_bytes=4096) + self.assertNotIn("# session=", text) + self.assertIn("R2#", text) + self.assertIn("show ver", text) + finally: + try: + path.unlink() + except OSError: + pass + @patch.object(svc, "_audit") def test_idle_timeout_reaper(self, _mock_audit: MagicMock) -> None: conn = _FakeConn() @@ -615,12 +688,77 @@ class WebcrtServiceTests(unittest.TestCase): self.assertGreaterEqual(q.dropped, 2) first = q.get_nowait() self.assertEqual(first, b"2") + delta = q.take_drop_delta() + self.assertGreaterEqual(delta, 2) + self.assertEqual(q.take_drop_delta(), 0) + # Further drops report only the new delta. + for i in range(20): + q.put(str(i).encode()) + self.assertGreater(q.take_drop_delta(), 0) + self.assertEqual(q.take_drop_delta(), 0) + + def test_bounded_queue_blocking_get(self) -> None: + import threading + + q = svc._BoundedByteQueue(maxsize=8) + box: dict[str, bytes | None] = {"v": None} + + def _reader() -> None: + box["v"] = q.get(timeout=1.0) + + t = threading.Thread(target=_reader, daemon=True) + t.start() + time.sleep(0.05) + q.put(b"wake") + t.join(timeout=1.0) + self.assertEqual(box["v"], b"wake") def test_normalize_encoding(self) -> None: self.assertEqual(svc._normalize_encoding("GBK"), "gbk") self.assertEqual(svc._normalize_encoding("utf8"), "utf-8") self.assertEqual(svc._encode_text("测", "gbk")[:1], b"\xb2") + def test_sftp_requires_direct_ssh(self) -> None: + from netx_api.webcrt_sftp import _require_ssh_direct + + with self.assertRaises(HTTPException) as telnet_cm: + _require_ssh_direct({"protocol": "telnet"}, {"protocol": "telnet"}) + self.assertEqual(telnet_cm.exception.status_code, 400) + self.assertEqual(telnet_cm.exception.detail, "sftp_requires_ssh") + + with self.assertRaises(HTTPException) as hop_cm: + _require_ssh_direct({"protocol": "ssh", "hop_enabled": True}, {"protocol": "ssh"}) + self.assertEqual(hop_cm.exception.status_code, 400) + self.assertEqual(hop_cm.exception.detail, "sftp_hop_not_supported") + + # Direct SSH is allowed (no raise). + _require_ssh_direct({"protocol": "ssh", "hop_enabled": False}, {"protocol": "ssh"}) + + @patch.object(svc, "_audit") + def test_reattach_clears_detach_deadline(self, _mock_audit: MagicMock) -> None: + conn = _FakeConn() + sess = svc.WebcrtSession( + session_id="rejoin", + ne_id="ne1", + ne_name="lab", + ne_ip="1.2.3.4", + protocol="ssh", + cols=80, + rows=24, + conn=conn, # type: ignore[arg-type] + ) + with svc._sessions_lock: + svc._sessions["rejoin"] = sess + _, gen1 = svc.mark_attached("rejoin") + out = svc.detach_session("rejoin", grace_sec=120.0, attach_gen=gen1) + self.assertTrue(out.get("detached")) + self.assertIsNotNone(sess.detach_deadline) + _, gen2 = svc.mark_attached("rejoin") + self.assertEqual(gen2, gen1 + 1) + self.assertTrue(sess.attached) + self.assertIsNone(sess.detach_deadline) + svc.close_session("rejoin", reason="test") + def test_linux_telnet_maps_to_generic_telnet(self) -> None: from netx_api.ne_netmiko import normalize_netmiko_device_type from netx_api.ne_session_factory import _netmiko_driver_class diff --git a/web/src/components/WebTerminal.tsx b/web/src/components/WebTerminal.tsx index 364308b..9710b96 100644 --- a/web/src/components/WebTerminal.tsx +++ b/web/src/components/WebTerminal.tsx @@ -2,6 +2,7 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "re import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; +import { useI18n } from "../i18n"; import { applyKeywordHighlight, type KeywordHighlightConfig, @@ -84,6 +85,28 @@ function serializeTerminal(term: Terminal): string { return lines.join("\n").replace(/\s+$/g, ""); } +/** Line-wise search — avoids serializing the full 10k scrollback on every Find. */ +function findBufferLine( + term: Terminal, + query: string, + startLine: number, + direction: 1 | -1, +): number { + const needle = String(query || "").toLowerCase(); + if (!needle) return -1; + const buf = term.buffer.active; + const len = buf.length; + if (len <= 0) return -1; + const start = ((Math.trunc(startLine) % len) + len) % len; + for (let step = 0; step < len; step += 1) { + const i = direction > 0 ? (start + step) % len : (start - step + len) % len; + const line = buf.getLine(i); + if (!line) continue; + if (line.translateToString(true).toLowerCase().includes(needle)) return i; + } + return -1; +} + function isSidebarSearchTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") { @@ -159,6 +182,9 @@ export const WebTerminal = forwardRef(function WebTerm }, ref, ) { + const { t } = useI18n(); + const tRef = useRef(t); + tRef.current = t; const resolvedColors: TermColors = termColors || (themeName === "light" ? { background: "#ffffff", foreground: "#000000" } @@ -167,7 +193,7 @@ export const WebTerminal = forwardRef(function WebTerm const termRef = useRef(null); const fitRef = useRef(null); const wsRef = useRef(null); - const findIndexRef = useRef(0); + const findLineRef = useRef(-1); const onStatusRef = useRef(onStatus); const onReadyRef = useRef(onReady); const onStdoutRef = useRef(onStdout); @@ -346,29 +372,19 @@ export const WebTerminal = forwardRef(function WebTerm findNext: (q: string) => { const term = termRef.current; if (!term || !q) return; - const text = serializeTerminal(term).toLowerCase(); - const needle = q.toLowerCase(); - let idx = text.indexOf(needle, findIndexRef.current + 1); - if (idx < 0) idx = text.indexOf(needle); - if (idx >= 0) { - findIndexRef.current = idx; - // Approximate scroll: each buffer line ~1 row. - const line = text.slice(0, idx).split("\n").length - 1; - term.scrollToLine(Math.max(0, line - 2)); + const hit = findBufferLine(term, q, findLineRef.current + 1, 1); + if (hit >= 0) { + findLineRef.current = hit; + term.scrollToLine(Math.max(0, hit - 2)); } }, findPrevious: (q: string) => { const term = termRef.current; if (!term || !q) return; - const text = serializeTerminal(term).toLowerCase(); - const needle = q.toLowerCase(); - const before = text.slice(0, Math.max(0, findIndexRef.current)); - let idx = before.lastIndexOf(needle); - if (idx < 0) idx = text.lastIndexOf(needle); - if (idx >= 0) { - findIndexRef.current = idx; - const line = text.slice(0, idx).split("\n").length - 1; - term.scrollToLine(Math.max(0, line - 2)); + const hit = findBufferLine(term, q, Math.max(0, findLineRef.current) - 1, -1); + if (hit >= 0) { + findLineRef.current = hit; + term.scrollToLine(Math.max(0, hit - 2)); } }, })); @@ -479,9 +495,24 @@ export const WebTerminal = forwardRef(function WebTerm maybeFocus(); return; } + if (msg.state === "warning") { + const m = String(msg.message || ""); + const dropMatch = /^queue_dropped:(\d+)/i.exec(m); + if (dropMatch) { + term.writeln( + `\r\n\x1b[33m${tRef.current("webcrt.term.outputTruncated", { count: dropMatch[1] })}\x1b[0m`, + ); + } + return; + } if (msg.state === "closed" || msg.state === "error") { const detail = msg.message ? `: ${msg.message}` : ""; - term.writeln(`\r\n\x1b[33m[session ${msg.state}${detail}]\x1b[0m`); + term.writeln( + `\r\n\x1b[33m${tRef.current("webcrt.term.sessionStatus", { + state: String(msg.state || ""), + detail, + })}\x1b[0m`, + ); } return; } @@ -494,7 +525,7 @@ export const WebTerminal = forwardRef(function WebTerm ws.onerror = () => { if (!isActiveSocket()) return; onStatusRef.current?.("error", "websocket_error"); - term.writeln("\r\n\x1b[31m[websocket error]\x1b[0m"); + term.writeln(`\r\n\x1b[31m${tRef.current("webcrt.term.wsError")}\x1b[0m`); }; ws.onclose = (ev) => { @@ -502,7 +533,9 @@ export const WebTerminal = forwardRef(function WebTerm if (!isActiveSocket()) return; onStatusRef.current?.("closed", `websocket_closed:${ev.code}`); if (!ev.wasClean) { - term.writeln(`\r\n\x1b[33m[websocket closed code=${ev.code}]\x1b[0m`); + term.writeln( + `\r\n\x1b[33m${tRef.current("webcrt.term.wsClosed", { code: ev.code })}\x1b[0m`, + ); } }; @@ -637,7 +670,7 @@ export const WebTerminal = forwardRef(function WebTerm
{pasteStatus ? (
- 粘贴中 {pasteStatus.done}/{pasteStatus.total} 行… + {t("webcrt.term.pasting", { done: pasteStatus.done, total: pasteStatus.total })}
) : null} {findOpen ? ( @@ -646,30 +679,26 @@ export const WebTerminal = forwardRef(function WebTerm ref={findInputRef} type="search" value={findQuery} - placeholder="Find…" - onChange={(e) => setFindQuery(e.target.value)} + placeholder={t("webcrt.term.findPh")} + aria-label={t("webcrt.term.findPh")} + onChange={(e) => { + setFindQuery(e.target.value); + findLineRef.current = -1; + }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); const term = termRef.current; if (!term || !findQuery) return; - const text = serializeTerminal(term).toLowerCase(); - const needle = findQuery.toLowerCase(); - if (e.shiftKey) { - const before = text.slice(0, Math.max(0, findIndexRef.current)); - let idx = before.lastIndexOf(needle); - if (idx < 0) idx = text.lastIndexOf(needle); - if (idx >= 0) { - findIndexRef.current = idx; - term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3)); - } - } else { - let idx = text.indexOf(needle, findIndexRef.current + 1); - if (idx < 0) idx = text.indexOf(needle); - if (idx >= 0) { - findIndexRef.current = idx; - term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3)); - } + const hit = findBufferLine( + term, + findQuery, + e.shiftKey ? Math.max(0, findLineRef.current) - 1 : findLineRef.current + 1, + e.shiftKey ? -1 : 1, + ); + if (hit >= 0) { + findLineRef.current = hit; + term.scrollToLine(Math.max(0, hit - 2)); } } else if (e.key === "Escape") { setFindOpen(false); @@ -679,17 +708,15 @@ export const WebTerminal = forwardRef(function WebTerm />
) : null} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 7c4bfa7..62c195a 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1040,9 +1040,26 @@ const en = { postLoginPrompt: "Post-login commands (one per line, this device)", postLoginSaved: "Post-login commands saved", sftp: { + title: "SFTP", refresh: "Refresh", + up: "Up", upload: "Upload", uploaded: "Upload complete", + dir: "dir", + }, + term: { + findPh: "Find…", + findPrev: "Previous", + findNext: "Next", + findClose: "Close find", + copy: "Copy", + paste: "Paste", + clear: "Clear", + pasting: "Pasting {{done}}/{{total}} lines…", + wsError: "[WebSocket error]", + wsClosed: "[WebSocket closed code={{code}}]", + sessionStatus: "[session {{state}}{{detail}}]", + outputTruncated: "[output truncated: {{count}} chunk(s) dropped]", }, err: { deviceType: "Unsupported device type", @@ -1055,6 +1072,9 @@ const en = { connectFailed: "Device login failed: {{detail}}", 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)", + queueDropped: "Terminal output too fast; about {{count}} chunk(s) dropped — transcript may be incomplete", + websocket: "WebSocket connection failed", }, }, topology: { diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 4a4bc8b..46498c8 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1037,9 +1037,26 @@ const zh = { postLoginPrompt: "登录后自动执行(每行一条,仅本设备)", postLoginSaved: "登录后命令已保存", sftp: { + title: "SFTP", refresh: "刷新", + up: "上级", upload: "上传", uploaded: "上传成功", + dir: "目录", + }, + term: { + findPh: "查找…", + findPrev: "上一个", + findNext: "下一个", + findClose: "关闭查找", + copy: "复制", + paste: "粘贴", + clear: "清空", + pasting: "粘贴中 {{done}}/{{total}} 行…", + wsError: "[WebSocket 错误]", + wsClosed: "[WebSocket 已关闭 code={{code}}]", + sessionStatus: "[会话 {{state}}{{detail}}]", + outputTruncated: "[输出过快,已丢弃 {{count}} 段]", }, err: { deviceType: "不支持的设备类型", @@ -1052,6 +1069,9 @@ const zh = { connectFailed: "登录设备失败:{{detail}}", sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH", sftpSsh: "SFTP 仅支持 SSH 协议", + sftpNeedPassword: "SFTP 需要已保存的 SSH 密码(会话弹窗密码不会用于文件传输)", + queueDropped: "终端输出过快,已丢弃约 {{count}} 段,内容可能不完整", + websocket: "WebSocket 连接失败", }, }, topology: { diff --git a/web/src/index.css b/web/src/index.css index 94dd359..f7b320e 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1907,10 +1907,10 @@ pre { align-items: center; justify-content: center; height: 28px; - padding: 0 10px; - border: 1px solid #b8c4d2; - border-radius: 6px; - background: #fff; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; color: var(--nm-brand); font-size: 12px; font-weight: 650; @@ -1920,16 +1920,17 @@ pre { } .webcrt-sidebar__new-btn:hover { - background: #e8f0fe; - border-color: #9eb0c6; + background: transparent; color: var(--nm-accent); + text-decoration: underline; } .app-main .webcrt-sidebar__new-btn { - border: 1px solid #b8c4d2; + border: 0; box-shadow: none; height: 28px; - border-radius: 6px; + border-radius: 0; + background: transparent; } .webcrt-menubar__item { @@ -1941,9 +1942,9 @@ pre { display: inline-flex; align-items: center; height: 28px; - padding: 0 8px; + padding: 0; border: 0; - border-radius: 6px; + border-radius: 0; background: transparent; color: #475569; font-size: 12px; @@ -1955,15 +1956,17 @@ pre { .webcrt-menubar__btn:hover, .webcrt-menubar__btn.is-open { - background: #dbe4f0; + background: transparent; color: var(--nm-brand); + text-decoration: underline; } .app-main .webcrt-menubar__btn { border: 0; box-shadow: none; height: 28px; - border-radius: 6px; + border-radius: 0; + background: transparent; } .webcrt-menubar__menu { diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index 2b4f1bf..1f526e3 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -36,6 +36,9 @@ const ENCODING_OPTIONS = ["utf-8", "gbk", "gb2312", "gb18030"] as const; const FONT_SIZE_OPTIONS = [12, 13, 14, 16, 18, 20] as const; const PASTE_DELAY_OPTIONS = [0, 20, 40, 60, 100, 150] as const; const KEEPALIVE_OPTIONS = [0, 15, 30, 60, 120] as const; +/** Compact recording chunks before they grow unbounded (join + trim). */ +const LOG_COMPACT_CHUNKS = 1500; +const LOG_MAX_CHARS = 8 * 1024 * 1024; type ColorSchemeId = "dark" | "blackWhite" | "whiteBlack" | "greenBlack" | "amberBlack" | "custom"; @@ -109,6 +112,8 @@ type TermTab = { errorMessage?: string; recording: boolean; encoding: string; + /** From session create — nested CLI hop cannot use SFTP. */ + cliHop?: boolean; }; type TabMenuState = { key: string; x: number; y: number }; @@ -282,6 +287,58 @@ function isSshAuthFailure(err: unknown): boolean { ); } +/** Inventory SSH (managed / webcrt Quick Connect). UME uses shared CLI profile — no per-NE popup. */ +function isInventorySsh(target: Pick): boolean { + const src = String(target.source || "").toLowerCase(); + if (src !== "webcrt" && src !== "managed") return false; + return String(target.protocol || "ssh").toLowerCase() !== "telnet"; +} + +function isSessionGoneError(err: unknown): boolean { + const raw = String(err).toLowerCase(); + return ( + raw.includes("webcrt_session_not_found") || + raw.includes("session_not_found") || + raw.includes("4404") + ); +} + +function isDeviceClosedMessage(err: unknown): boolean { + const raw = String(err).toLowerCase(); + return ( + raw.includes("device_closed") || + raw.includes("client_close") || + raw.includes("client_delete") || + raw.includes("cli_hop_return") || + raw.includes("idle_timeout") || + raw.includes("detach_timeout") || + raw.includes("attach_timeout") || + raw.includes("session closed") + ); +} + +/** SFTP is direct SSH only; returns i18n key when unavailable. */ +function sftpUnavailableReason( + tab: Pick, +): "webcrt.err.sftpSsh" | "webcrt.err.sftpHop" | "webcrt.err.sftpNeedPassword" | 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"; + 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); + if (parts.length <= 1) return cur.startsWith("/") ? "/" : "."; + parts.pop(); + const parent = parts.join("/") || (cur.startsWith("/") ? "/" : "."); + return parent || "."; +} + function connectFailedNe(err: unknown): ManagedNeItem | null { if (!(err instanceof ApiRequestError)) return null; const detail = err.detail; @@ -310,6 +367,7 @@ function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record([]); tabsRef.current = tabs; const termRefs = useRef>(new Map()); - const logBuffersRef = useRef>(new Map()); + /** Chunk lists avoid O(n²) string append while recording. */ + const logBuffersRef = useRef>(new Map()); + const lastQueueDropToastAtRef = useRef(0); const optionsMenuRef = useRef(null); const tabMenuRef = useRef(null); const treeMenuRef = useRef(null); @@ -467,7 +527,7 @@ export function WebcrtPage() { }, []); const attachSessionResult = useCallback( - (target: CliTargetItem, sessionId: string, encoding: string) => { + (target: CliTargetItem, sessionId: string, encoding: string, cliHop?: boolean) => { const key = targetKey(target); const existing = tabsRef.current.find((tab) => tab.key === key); const pending: TermTab = { @@ -481,6 +541,7 @@ export function WebcrtPage() { recording: existing?.recording || false, encoding, errorMessage: undefined, + cliHop: Boolean(cliHop), }; setTabs((prev) => { const without = prev.filter((x) => x.key !== key); @@ -520,19 +581,19 @@ export function WebcrtPage() { } if (connectingKeysRef.current.has(key)) return; - // WebCRT SSH without saved password → credential popup. - const isWebcrtSsh = - target.source === "webcrt" && String(target.protocol || "ssh").toLowerCase() !== "telnet"; - if (isWebcrtSsh && !target.has_password && !opts?.force) { + // Managed / WebCRT SSH without saved password → credential popup (UME uses shared profile). + if (isInventorySsh(target) && !target.has_password && !opts?.force) { openAuthForTarget(target); return; } connectingKeysRef.current.add(key); - if (opts?.force && existing?.sessionId) { + // force = brand-new device session; drop any prior PTY first. + const prior = tabsRef.current.find((tab) => tab.key === key); + if (opts?.force && prior?.sessionId) { try { - await closeWebcrtSession(existing.sessionId); + await closeWebcrtSession(prior.sessionId); } catch { /* ignore */ } @@ -545,11 +606,11 @@ export function WebcrtPage() { key, sessionId: "", wsUrl: "", - termEpoch: (existing?.termEpoch || 0) + (opts?.force ? 1 : 0), + termEpoch: (prior?.termEpoch || existing?.termEpoch || 0) + (opts?.force ? 1 : 0), target, status: "connecting", connectPhase: "creating", - recording: existing?.recording || false, + recording: prior?.recording || existing?.recording || false, encoding, errorMessage: undefined, }; @@ -588,13 +649,13 @@ export function WebcrtPage() { status: "connecting", connectPhase: "authenticating", termEpoch: pending.termEpoch + 1, + cliHop: Boolean(sess.cli_hop), }); showOk(t("webcrt.opened", { name: deviceLabel(target) })); } catch (err) { const message = webcrtErrorMessage(err, t); const needAuth = - target.source === "webcrt" && - String(target.protocol || "ssh").toLowerCase() !== "telnet" && + isInventorySsh(target) && (String(err).includes("credentials_incomplete") || String(err).includes("connect_failed") || isSshAuthFailure(err)); @@ -611,6 +672,23 @@ export function WebcrtPage() { [openAuthForTarget, showOk, showError, t, updateTab], ); + /** Re-open WS to an existing backend session (within detach grace). */ + const reattachTab = useCallback( + (tab: TermTab) => { + if (!tab.sessionId) return false; + updateTab(tab.key, { + status: "connecting", + connectPhase: "authenticating", + termEpoch: tab.termEpoch + 1, + wsUrl: webcrtWsUrl(tab.sessionId), + errorMessage: undefined, + }); + setActiveTabKey(tab.key); + return true; + }, + [updateTab], + ); + const openTargetRef = useRef(openTarget); openTargetRef.current = openTarget; @@ -667,7 +745,7 @@ export function WebcrtPage() { connect_status: result.ne.connect_status || "unknown", cli_profile_ready: true, }; - attachSessionResult(target, result.session_id, dims.encoding); + attachSessionResult(target, result.session_id, dims.encoding, Boolean(result.cli_hop)); setHostDialogOpen(false); setHostForm(emptyHostForm()); setSource(listSource === "managed" ? "managed" : "webcrt"); @@ -703,7 +781,8 @@ export function WebcrtPage() { try { if (authDialog.mode === "retry" && authDialog.target) { const existing = authDialog.target; - if (authForm.savePassword && existing.source === "webcrt") { + // Persist to ManagedNE for inventory + Quick Connect; UME never uses this dialog. + if (authForm.savePassword && existing.source !== "ume") { try { await updateManagedNe(existing.id, { username, password: authForm.password }); } catch { @@ -728,6 +807,7 @@ export function WebcrtPage() { }, sess.session_id, dims.encoding, + Boolean(sess.cli_hop), ); setAuthDialog(null); setAuthForm(emptyAuthForm()); @@ -762,7 +842,7 @@ export function WebcrtPage() { connect_status: result.ne.connect_status || "unknown", cli_profile_ready: true, }; - attachSessionResult(target, result.session_id, dims.encoding); + attachSessionResult(target, result.session_id, dims.encoding, Boolean(result.cli_hop)); setAuthDialog(null); setAuthForm(emptyAuthForm()); setSource("webcrt"); @@ -881,11 +961,24 @@ export function WebcrtPage() { [showError, showOk, t], ); + const reconnectTab = useCallback( + async (tab: TermTab) => { + // Prefer re-attach while the device PTY may still be in detach grace. + const canReattach = + Boolean(tab.sessionId) && + !isDeviceClosedMessage(tab.errorMessage) && + !isSshAuthFailure(tab.errorMessage); + if (canReattach && reattachTab(tab)) return; + await openTarget(tab.target, { force: true }); + }, + [openTarget, reattachTab], + ); + const reconnectActive = useCallback(async () => { const tab = tabsRef.current.find((x) => x.key === activeTabKey); if (!tab) return; - await openTarget(tab.target, { force: true }); - }, [activeTabKey, openTarget]); + await reconnectTab(tab); + }, [activeTabKey, reconnectTab]); const toggleRecording = useCallback(() => { const tab = tabsRef.current.find((x) => x.key === activeTabKey); @@ -893,10 +986,13 @@ export function WebcrtPage() { const next = !tab.recording; if (next) { const seed = termRefs.current.get(tab.key)?.getText() || ""; - logBuffersRef.current.set(tab.key, seed ? `${seed}\n` : ""); + logBuffersRef.current.set(tab.key, seed ? [`${seed}\n`] : []); showOk(t("webcrt.actions.recordingOn")); } else { - const body = logBuffersRef.current.get(tab.key) || termRefs.current.get(tab.key)?.getText() || ""; + const chunks = logBuffersRef.current.get(tab.key); + const body = chunks?.length + ? chunks.join("") + : termRefs.current.get(tab.key)?.getText() || ""; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const name = `${deviceLabel(tab.target) || "session"}-${stamp}.log`; downloadText(name, body); @@ -997,6 +1093,12 @@ export function WebcrtPage() { }, [presetNeId, presetSource, showError, t, setSearchParams]); const activeTab = tabs.find((x) => x.key === activeTabKey) || null; + const sftpBlockedKey = activeTab ? sftpUnavailableReason(activeTab) : "webcrt.err.sftpSsh"; + const sftpAllowed = Boolean(activeTab && !sftpBlockedKey); + + useEffect(() => { + if (sftpOpen && !sftpAllowed) setSftpOpen(false); + }, [sftpOpen, sftpAllowed]); const renameWebcrtSession = useCallback( async (target: CliTargetItem, nextName: string) => { @@ -1271,21 +1373,31 @@ export function WebcrtPage() { ) : null}
- {tabs.map((tab) => ( + {tabs.map((tab) => { + const isActive = activeTabKey === tab.key; + // Only the active tab mounts xterm + WS; background tabs detach and re-attach on focus. + const mountTerminal = Boolean(isActive && tab.wsUrl); + return ( - ))} - {sftpOpen && activeTab ? ( + ); + })} + {sftpOpen && activeTab && sftpAllowed ? (
+ setSftpPath(e.target.value)} /> diff --git a/web/src/types.ts b/web/src/types.ts index a37ddf7..d6ad4c6 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -260,6 +260,7 @@ export type CliTargetItem = { protocol?: string; username?: string; has_password?: boolean; + hop_enabled?: boolean; connect_status: string; cli_profile_ready?: boolean; };