diff --git a/netx_api/ne_session_factory.py b/netx_api/ne_session_factory.py index d4dc3e6..b990f70 100644 --- a/netx_api/ne_session_factory.py +++ b/netx_api/ne_session_factory.py @@ -252,6 +252,7 @@ def _netmiko_over_ssh_client( password: str, enable_secret: str, session_timeout: int | None, + session_log: Any = None, ) -> ConnectHandler: """Netmiko session over an already-authenticated SSH client (bastion protocol proxy).""" base_cls = _netmiko_driver_class(device_type) @@ -278,6 +279,7 @@ def _netmiko_over_ssh_client( password=password, enable_secret=enable_secret, session_timeout=session_timeout, + session_log=session_log, ) return _PreauthSession(**dev) @@ -291,6 +293,7 @@ def _base_connect_kwargs( password: str, enable_secret: str, session_timeout: int | None = None, + session_log: Any = None, ) -> dict[str, Any]: timeout = int(settings.ne_connect_timeout_sec or 30) dev: dict[str, Any] = { @@ -308,10 +311,17 @@ def _base_connect_kwargs( secret = str(enable_secret or "").strip() if secret: dev["secret"] = secret + if session_log is not None: + dev["session_log"] = session_log return dev -def _connect_direct(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler: +def _connect_direct( + creds: dict[str, Any], + *, + session_timeout: int | None = None, + session_log: Any = None, +) -> ConnectHandler: device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"]) dev = _base_connect_kwargs( device_type=device_type, @@ -321,6 +331,7 @@ def _connect_direct(creds: dict[str, Any], *, session_timeout: int | None = None password=str(creds["password"]), enable_secret=str(creds.get("enable_secret") or ""), session_timeout=session_timeout, + session_log=session_log, ) return ConnectHandler(**dev) @@ -391,7 +402,12 @@ def _hop_netmiko_device_type(vendor: str, hop_protocol: str) -> str: return normalize_netmiko_device_type(base, hop_protocol) -def _connect_via_cli_hop(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler: +def _connect_via_cli_hop( + creds: dict[str, Any], + *, + session_timeout: int | None = None, + session_log: Any = None, +) -> ConnectHandler: """Login to ZTE/Huawei/Cisco hop NE, run CLI jump command, then target secondary auth.""" hop_host = str(creds.get("hop_host") or "").strip() hop_user = str(creds.get("hop_username") or "").strip() @@ -409,6 +425,7 @@ def _connect_via_cli_hop(creds: dict[str, Any], *, session_timeout: int | None = password=hop_pass, enable_secret="", session_timeout=session_timeout or 180, + session_log=session_log, ) conn = ConnectHandler(**hop_dev) try: @@ -425,7 +442,12 @@ def _connect_via_cli_hop(creds: dict[str, Any], *, session_timeout: int | None = raise -def _connect_via_bastion(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler: +def _connect_via_bastion( + creds: dict[str, Any], + *, + session_timeout: int | None = None, + session_log: Any = None, +) -> ConnectHandler: """SSH to bastion with composite username; bastion proxies to target (protocol proxy).""" hop_host = str(creds.get("hop_host") or "").strip() hop_user = str(creds.get("hop_username") or "").strip() @@ -456,6 +478,7 @@ def _connect_via_bastion(creds: dict[str, Any], *, session_timeout: int | None = password=hop_pass, enable_secret=str(creds.get("enable_secret") or ""), session_timeout=session_timeout or 180, + session_log=session_log, ) except Exception: if ssh_client is not None: @@ -481,7 +504,12 @@ def _connect_via_bastion(creds: dict[str, Any], *, session_timeout: int | None = return conn -def _connect_via_linux_hop(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler: +def _connect_via_linux_hop( + creds: dict[str, Any], + *, + session_timeout: int | None = None, + session_log: Any = None, +) -> ConnectHandler: """SSH to Linux bastion, then direct-tcpip tunnel to target (classic ProxyJump-style).""" hop_host = str(creds.get("hop_host") or "").strip() hop_user = str(creds.get("hop_username") or "").strip() @@ -533,6 +561,7 @@ def _connect_via_linux_hop(creds: dict[str, Any], *, session_timeout: int | None password=str(creds["password"]), enable_secret=str(creds.get("enable_secret") or ""), session_timeout=session_timeout, + session_log=session_log, ) dev["sock"] = channel conn = ConnectHandler(**dev) @@ -556,13 +585,18 @@ def close_netmiko_connection(conn: ConnectHandler | None) -> None: pass -def open_netmiko_connection(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler: +def open_netmiko_connection( + creds: dict[str, Any], + *, + session_timeout: int | None = None, + session_log: Any = None, +) -> ConnectHandler: """Open a Netmiko connection to the target NE (direct or via configured hop).""" if creds.get("hop_enabled"): vendor = _hop_vendor(creds) if vendor == "linux": - return _connect_via_linux_hop(creds, session_timeout=session_timeout) + return _connect_via_linux_hop(creds, session_timeout=session_timeout, session_log=session_log) if vendor == "bastion": - return _connect_via_bastion(creds, session_timeout=session_timeout) - return _connect_via_cli_hop(creds, session_timeout=session_timeout) - return _connect_direct(creds, session_timeout=session_timeout) + return _connect_via_bastion(creds, session_timeout=session_timeout, session_log=session_log) + return _connect_via_cli_hop(creds, session_timeout=session_timeout, session_log=session_log) + return _connect_direct(creds, session_timeout=session_timeout, session_log=session_log) diff --git a/netx_api/webcrt_router.py b/netx_api/webcrt_router.py index 83b3af4..a30fd0b 100644 --- a/netx_api/webcrt_router.py +++ b/netx_api/webcrt_router.py @@ -98,7 +98,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: } ) - # Replay post-login banner/prompt so the UI is not blank until the user presses Enter. + # Replay login transcript once, then draw a live prompt so first keystrokes match later lines. bootstrap = bytes(sess.bootstrap_output or b"") if bootstrap: try: @@ -107,12 +107,8 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: ) except Exception: _log.debug("webcrt bootstrap send failed session=%s", session_id, exc_info=True) - else: - # Last resort: ask the device to redraw the prompt into the live reader. - try: - await asyncio.get_running_loop().run_in_executor(None, sess.write_stdin, "\r") - except Exception: - pass + # Only replay login banner on the first attach (StrictMode remount / blip). + sess.bootstrap_output = b"" stop = asyncio.Event() @@ -141,6 +137,12 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: break reader_task = asyncio.create_task(pump_stdout()) + if sess.needs_live_prompt: + sess.needs_live_prompt = False + try: + await asyncio.get_running_loop().run_in_executor(None, sess.write_stdin, "\r") + except Exception: + _log.debug("webcrt live prompt sync failed session=%s", session_id, exc_info=True) try: while not stop.is_set(): raw = await websocket.receive_text() diff --git a/netx_api/webcrt_service.py b/netx_api/webcrt_service.py index 4e9f2a9..0b2620e 100644 --- a/netx_api/webcrt_service.py +++ b/netx_api/webcrt_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import io import json import logging import queue @@ -77,6 +78,29 @@ def map_network_cli_keys(data: str) -> str: i += 1 return "".join(out) + +def channel_return(conn: ConnectHandler | None) -> str: + """Netmiko line ending for this session (SSH usually \\n, Telnet often \\r\\n).""" + if conn is None: + return "\n" + ret = getattr(conn, "RETURN", None) + if isinstance(ret, str) and ret: + return ret + return "\n" + + +def map_network_cli_enter(data: str, conn: ConnectHandler | None) -> str: + """Map xterm Enter (\\r) to the device's Netmiko RETURN.""" + text = str(data or "") + if not text: + return text + ret = channel_return(conn) + if ret == "\r": + return text + # Prefer replacing CRLF first so Telnet RETURN \\r\\n does not double-expand. + return text.replace("\r\n", ret).replace("\r", ret) + + def _drain_channel(conn: ConnectHandler, *, rounds: int = 10, wait: float = 0.12) -> str: """Read whatever is already sitting on the channel after login.""" chunks: list[str] = [] @@ -97,6 +121,19 @@ def _drain_channel(conn: ConnectHandler, *, rounds: int = 10, wait: float = 0.12 return "".join(chunks) +def _session_log_text(buf: io.BytesIO | None) -> str: + """Decode Netmiko session_log buffer into display text.""" + if buf is None: + return "" + try: + raw = buf.getvalue() + except Exception: + return "" + if isinstance(raw, bytes): + return raw.decode("utf-8", errors="replace") + return str(raw or "") + + def _looks_like_cli_prompt(text: str) -> bool: s = str(text or "").rstrip() if not s: @@ -105,6 +142,61 @@ def _looks_like_cli_prompt(text: str) -> bool: return bool(re.search(r"(?:[>\]]|#)\s*$", s)) or bool(re.search(r"<[^>\r\n]+>\s*$", s)) +# Cisco/Netmiko often yields "R2#R2#" when a sync Enter is appended without a newline. +_GLUED_PROMPT_RE = re.compile(r"(?<=[#>])(?=(?:[A-Za-z0-9][\w.\-:]{0,62})[#>])") + + +def normalize_cli_transcript(text: str) -> str: + """Normalize login transcript for xterm (convertEol) and un-glue prompts.""" + s = str(text or "").replace("\r\n", "\n").replace("\r", "\n") + s = _GLUED_PROMPT_RE.sub("\n", s) + lines = s.split("\n") + while lines and not str(lines[-1]).strip(): + lines.pop() + while len(lines) >= 2 and lines[-1] == lines[-2] and _looks_like_cli_prompt(lines[-1]): + lines.pop() + return "\n".join(lines) + + +def strip_trailing_prompt_lines(text: str) -> str: + """Remove final prompt line(s) so a live RETURN can paint the interactive prompt.""" + lines = str(text or "").split("\n") + while lines and not str(lines[-1]).strip(): + lines.pop() + while lines and _looks_like_cli_prompt(lines[-1]): + lines.pop() + while lines and not str(lines[-1]).strip(): + lines.pop() + return "\n".join(lines) + + +def prepare_bootstrap_output(text: str) -> str: + """Login transcript for UI replay; ends with newline, without the final prompt.""" + body = strip_trailing_prompt_lines(normalize_cli_transcript(text)) + if not body: + return "" + return body if body.endswith("\n") else body + "\n" + + +def _prime_interactive_channel(conn: ConnectHandler) -> None: + """Send one RETURN after Netmiko login so the interactive channel is fully ready.""" + try: + _drain_channel(conn, rounds=4, wait=0.05) + except Exception: + pass + try: + conn.write_channel(channel_return(conn)) + except Exception: + try: + conn.write_channel("\n") + except Exception: + return + try: + _drain_channel(conn, rounds=8, wait=0.1) + except Exception: + pass + + def _utc_now() -> datetime: return datetime.now(timezone.utc) @@ -151,6 +243,7 @@ class WebcrtSession: closed: bool = False close_reason: str = "" bootstrap_output: bytes = b"" + needs_live_prompt: bool = True out_queue: queue.Queue[bytes | None] = field(default_factory=queue.Queue) _reader: threading.Thread | None = field(default=None, repr=False) _write_lock: threading.Lock = field(default_factory=threading.Lock, repr=False) @@ -166,26 +259,26 @@ class WebcrtSession: return if self.cli_keymap: text = map_network_cli_keys(text) + text = map_network_cli_enter(text, self.conn) if not text: return with self._write_lock: - # Prefer raw channel write so control bytes are not altered. - channel = getattr(self.conn, "remote_conn", None) - try: - if channel is not None and hasattr(channel, "send") and callable(channel.send): - payload = text.encode(getattr(self.conn, "encoding", None) or "utf-8", errors="replace") - channel.send(payload) - elif channel is not None and hasattr(channel, "write") and callable(channel.write): - encoding = getattr(self.conn, "encoding", None) or "utf-8" - if isinstance(text, str): - channel.write(text.encode(encoding, errors="replace")) - else: - channel.write(text) - else: - self.conn.write_channel(text) - except Exception: - # Fallback to netmiko helper. + # Network CLIs: use Netmiko write_channel so RETURN/encoding match the driver. + if self.cli_keymap: self.conn.write_channel(text) + else: + channel = getattr(self.conn, "remote_conn", None) + try: + if channel is not None and hasattr(channel, "send") and callable(channel.send): + payload = text.encode(getattr(self.conn, "encoding", None) or "utf-8", errors="replace") + channel.send(payload) + elif channel is not None and hasattr(channel, "write") and callable(channel.write): + encoding = getattr(self.conn, "encoding", None) or "utf-8" + channel.write(text.encode(encoding, errors="replace") if isinstance(text, str) else text) + else: + self.conn.write_channel(text) + except Exception: + self.conn.write_channel(text) self.touch() def resize(self, cols: int, rows: int) -> None: if self.closed or self.conn is None: @@ -367,9 +460,14 @@ def create_session( vendor = str(device.get("vendor") or creds.get("vendor") or "") cli_keymap = uses_network_cli_keymap(device_type, vendor) + # Capture real Telnet/SSH login I/O (banner, Username/Password, prompts). + log_buf = io.BytesIO() try: - conn = open_netmiko_connection(creds, session_timeout=connect_timeout) + conn = open_netmiko_connection( + creds, session_timeout=connect_timeout, session_log=log_buf + ) except Exception as exc: + partial = _session_log_text(log_buf).strip() _audit( "session_open_failed", session_id=session_id, @@ -378,8 +476,13 @@ def create_session( source=str(device.get("source") or ""), client=client or "", error=str(exc)[:500], + transcript_len=len(partial), ) - raise HTTPException(status_code=502, detail=f"connect_failed:{exc}") from exc + detail = f"connect_failed:{exc}" + if partial: + # Keep detail bounded; UI surfaces this on open failure. + detail = f"{detail}\n--- device transcript ---\n{partial[-4000:]}" + raise HTTPException(status_code=502, detail=detail) from exc channel = getattr(conn, "remote_conn", None) if channel is not None and hasattr(channel, "resize_pty"): @@ -388,20 +491,23 @@ def create_session( except Exception: pass + # Prefer session_log (full login transcript). Prime channel, then strip the final + # prompt so WebSocket attach can paint a live interactive prompt (first keystrokes + # then behave like subsequent lines). + _prime_interactive_channel(conn) + bootstrap = prepare_bootstrap_output(_session_log_text(log_buf)) + if not bootstrap.strip(): + try: + more = _drain_channel(conn, rounds=6, wait=0.1) + except Exception: + more = "" + if more: + bootstrap = prepare_bootstrap_output(more) + # Discard any unread bytes so the live reader starts clean. try: - leftover = _drain_channel(conn, rounds=8, wait=0.1) + _drain_channel(conn, rounds=3, wait=0.05) except Exception: - leftover = "" - # Netmiko often consumes the login banner; nudge Enter once to surface the prompt. - if not _looks_like_cli_prompt(leftover): - try: - conn.write_channel("\r") - except Exception: - pass - try: - leftover = (leftover or "") + _drain_channel(conn, rounds=8, wait=0.12) - except Exception: - pass + pass sess = WebcrtSession( session_id=session_id, @@ -415,7 +521,8 @@ def create_session( vendor=vendor, cli_keymap=cli_keymap, conn=conn, - bootstrap_output=str(leftover or "").encode("utf-8", errors="replace"), + bootstrap_output=str(bootstrap or "").encode("utf-8", errors="replace"), + needs_live_prompt=True, ) # Keep bootstrap for WS attach replay; do not rely solely on out_queue (StrictMode remount). sess.start_reader() diff --git a/tests/test_webcrt.py b/tests/test_webcrt.py index 9484838..a701f33 100644 --- a/tests/test_webcrt.py +++ b/tests/test_webcrt.py @@ -14,6 +14,7 @@ from netx_api import webcrt_service as svc class _FakeConn: def __init__(self) -> None: self.written: list[str] = [] + self.RETURN = "\n" self.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty"]) self.remote_conn.recv_ready.return_value = False self.remote_conn.exit_status_ready.return_value = False @@ -54,6 +55,9 @@ class WebcrtServiceTests(unittest.TestCase): ) sess.write_stdin("show ver\n") self.assertEqual(conn.written, ["show ver\n"]) + # xterm Enter (\r) -> Netmiko RETURN (\n for SSH) + sess.write_stdin("\r") + self.assertEqual(conn.written[-1], "\n") # xterm DEL / arrows -> network CLI controls sess.write_stdin("\x7f") self.assertEqual(conn.written[-1], "\x08") @@ -69,6 +73,15 @@ class WebcrtServiceTests(unittest.TestCase): self.assertEqual(svc.map_network_cli_keys("\x1b[D"), "\x02") self.assertTrue(svc.uses_network_cli_keymap("huawei", "Huawei")) self.assertFalse(svc.uses_network_cli_keymap("linux", "bastion")) + self.assertEqual(svc.map_network_cli_enter("\r", _FakeConn()), "\n") # type: ignore[arg-type] + telnet = _FakeConn() + telnet.RETURN = "\r\n" + self.assertEqual(svc.map_network_cli_enter("\r", telnet), "\r\n") # type: ignore[arg-type] + self.assertEqual(svc.normalize_cli_transcript("R2#R2#\nR2#"), "R2#") + self.assertEqual(svc.normalize_cli_transcript("banner\nR2#R2#"), "banner\nR2#") + self.assertEqual(svc.prepare_bootstrap_output("login\nR2#\nR2#"), "login\n") + self.assertTrue(svc.prepare_bootstrap_output("login\nR2#").endswith("\n")) + self.assertFalse(svc._looks_like_cli_prompt(svc.prepare_bootstrap_output("login\nR2#").rstrip("\n") or "x")) @patch.object(svc, "_audit") @patch.object(svc, "open_netmiko_connection") @@ -133,7 +146,18 @@ class WebcrtServiceTests(unittest.TestCase): }, ) fake = _FakeConn() - mock_open.return_value = fake + + def _open_with_log(*_a, **kwargs): + log_buf = kwargs.get("session_log") + if log_buf is not None and hasattr(log_buf, "write"): + log_buf.write( + b"Warning: Telnet is not a secure protocol...\r\n" + b"Username:huawei\r\nPassword:\r\n" + b"" + ) + return fake + + mock_open.side_effect = _open_with_log db = MagicMock() out = svc.create_session(db, ne_id="ne-hop", cols=100, rows=30, client="test") @@ -141,15 +165,19 @@ class WebcrtServiceTests(unittest.TestCase): called_creds = mock_open.call_args.args[0] self.assertTrue(called_creds["hop_enabled"]) self.assertEqual(called_creds["hop_vendor"], "bastion") + self.assertIn("session_log", mock_open.call_args.kwargs) fake.remote_conn.resize_pty.assert_called() self.assertEqual(out["ne_id"], "ne-hop") sess = svc.get_session(out["session_id"]) assert sess is not None - # create_session may nudge Enter when no prompt was drained. + boot = sess.bootstrap_output.decode("utf-8", errors="replace") + self.assertIn("Username:huawei", boot) + # Final prompt is stripped; live RETURN on WS attach paints the interactive prompt. + self.assertNotIn("", boot) + self.assertTrue(sess.needs_live_prompt) before = list(fake.written) sess.write_stdin("\n") self.assertEqual(fake.written[len(before) :], ["\n"]) - self.assertTrue(isinstance(sess.bootstrap_output, (bytes, bytearray))) svc.close_session(out["session_id"], reason="test") @patch.object(svc, "_audit") diff --git a/web/src/components/WebTerminal.tsx b/web/src/components/WebTerminal.tsx index 72ff54d..c206c23 100644 --- a/web/src/components/WebTerminal.tsx +++ b/web/src/components/WebTerminal.tsx @@ -1,27 +1,79 @@ -import { useEffect, useRef } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; +export type WebTerminalHandle = { + clear: () => void; + copyAll: () => Promise; + getText: () => string; + fit: () => void; +}; + type Props = { wsUrl: string; title?: string; + recording?: boolean; onStatus?: (state: string, message?: string) => void; onReady?: () => void; + onStdout?: (data: string) => void; }; -export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) { +function serializeTerminal(term: Terminal): string { + const buf = term.buffer.active; + const lines: string[] = []; + for (let i = 0; i < buf.length; i += 1) { + const line = buf.getLine(i); + if (!line) continue; + lines.push(line.translateToString(true)); + } + return lines.join("\n").replace(/\s+$/g, ""); +} + +export const WebTerminal = forwardRef(function WebTerminal( + { wsUrl, title, recording, onStatus, onReady, onStdout }, + ref, +) { const hostRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const wsRef = useRef(null); const onStatusRef = useRef(onStatus); const onReadyRef = useRef(onReady); + const onStdoutRef = useRef(onStdout); + const recordingRef = useRef(!!recording); useEffect(() => { onStatusRef.current = onStatus; onReadyRef.current = onReady; - }, [onStatus, onReady]); + onStdoutRef.current = onStdout; + }, [onStatus, onReady, onStdout]); + + useEffect(() => { + recordingRef.current = !!recording; + }, [recording]); + + useImperativeHandle(ref, () => ({ + clear: () => { + termRef.current?.clear(); + termRef.current?.write("\x1b[H\x1b[2J"); + }, + copyAll: async () => { + const text = termRef.current ? serializeTerminal(termRef.current) : ""; + if (text && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } + return text; + }, + getText: () => (termRef.current ? serializeTerminal(termRef.current) : ""), + fit: () => { + try { + fitRef.current?.fit(); + } catch { + /* ignore */ + } + }, + })); useEffect(() => { const host = hostRef.current; @@ -32,9 +84,9 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) { fontSize: 13, fontFamily: 'Consolas, "Courier New", monospace', theme: { - background: "#0f172a", + background: "#0b1220", foreground: "#e2e8f0", - cursor: "#93c5fd", + cursor: "#e2e8f0", }, convertEol: true, }); @@ -51,7 +103,6 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) { /* ignore */ } }; - // Fit after layout; hidden/zero-size parents need a deferred pass. requestAnimationFrame(() => { doFit(); window.setTimeout(doFit, 50); @@ -60,7 +111,6 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) { const ws = new WebSocket(wsUrl); wsRef.current = ws; onStatusRef.current?.("connecting"); - term.writeln(`\x1b[90mConnecting${title ? ` ${title}` : ""}…\x1b[0m`); const sendJson = (payload: Record) => { if (ws.readyState === WebSocket.OPEN) { @@ -86,21 +136,15 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) { data?: string; state?: string; message?: string; - ne_name?: string; - ne_ip?: string; - protocol?: string; }; if (msg.type === "stdout" && typeof msg.data === "string") { term.write(msg.data); + if (recordingRef.current) onStdoutRef.current?.(msg.data); return; } if (msg.type === "status") { onStatusRef.current?.(String(msg.state || ""), msg.message); - if (msg.state === "connected") { - const where = [msg.ne_name || title, msg.ne_ip, msg.protocol].filter(Boolean).join(" · "); - term.writeln(`\x1b[90m--- session ready${where ? `: ${where}` : ""} ---\x1b[0m`); - return; - } + if (msg.state === "connected") 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`); @@ -109,14 +153,15 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) { } if (msg.type === "pong") return; } catch { - term.write(String(ev.data || "")); + const raw = String(ev.data || ""); + term.write(raw); + if (recordingRef.current) onStdoutRef.current?.(raw); } }; ws.onerror = () => { onStatusRef.current?.("error", "websocket_error"); term.writeln("\r\n\x1b[31m[websocket error]\x1b[0m"); - term.writeln("\x1b[33mHint: ensure API has package 'websockets' and is restarted; Vite UI should reach ws://127.0.0.1:8890\x1b[0m"); }; ws.onclose = (ev) => { @@ -150,7 +195,6 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) { window.removeEventListener("resize", onWinResize); ro?.disconnect(); dataDisposable.dispose(); - // Do not send {type:"close"} here — React StrictMode remounts and needs reconnect. try { ws.close(); } catch { @@ -164,4 +208,4 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) { }, [wsUrl, title]); return
; -} +}); diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 5a74de5..20c7ce0 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -541,10 +541,11 @@ const en = { }, webcrt: { title: "WebCRT", - sessionManager: "Sessions", + deviceList: "Device list", + sessionManager: "Device list", connect: "Connect", - hint: "Pick a connected NE and open an interactive terminal in the browser. Credentials stay on the server.", - hintCrt: "Select an NE on the left, then double-click or press ⚡. Includes managed and UME inventory; search + paging for large fleets.", + hint: "Pick an NE and open an interactive terminal in the browser. Credentials stay on the server.", + hintCrt: "Click an NE on the left to open a terminal. Includes managed and UME inventory; search + paging for large fleets.", filterKeyword: "Filter", filterKeywordPh: "Filter by name / IP / tag", empty: "No matching NEs. Switch source or refine the search.", @@ -567,6 +568,18 @@ const en = { error: "Error", closed: "Closed", }, + actions: { + reconnect: "Reconnect", + recordLog: "Record log", + clear: "Clear", + copy: "Copy", + recording: "Recording", + recordingOn: "Log recording started", + recordingSaved: "Log saved", + copied: "Copied to clipboard", + copyEmpty: "Terminal is empty", + copyFailed: "Copy failed", + }, err: { sessionLimit: "Session limit reached; close other terminals first", neNotFound: "Network element not found", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 22e36e3..76877b8 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -539,10 +539,11 @@ const zh = { }, webcrt: { title: "WebCRT", - sessionManager: "会话管理", + deviceList: "设备列表", + sessionManager: "设备列表", connect: "连接", - hint: "选择已连通网元,在浏览器中打开交互式终端。凭据由服务端解密,不会下发到前端。", - hintCrt: "在左侧选择网元后双击或点 ⚡ 连接;支持托管网元与 UME 网元,可用搜索与分页浏览上万台设备。", + hint: "选择网元,在浏览器中打开交互式终端。凭据由服务端解密,不会下发到前端。", + hintCrt: "在左侧点击网元即可打开终端;支持托管与 UME 网元,可用搜索与分页浏览。", filterKeyword: "筛选", filterKeywordPh: "按名称 / IP / 标签筛选", empty: "没有匹配的网元。可切换来源或调整搜索关键字。", @@ -565,6 +566,18 @@ const zh = { error: "错误", closed: "已关闭", }, + actions: { + reconnect: "重新连接", + recordLog: "记录日志", + clear: "清空", + copy: "拷贝", + recording: "记录中", + recordingOn: "已开始记录日志", + recordingSaved: "日志已保存", + copied: "已复制到剪贴板", + copyEmpty: "终端内容为空", + copyFailed: "复制失败", + }, err: { sessionLimit: "并发会话已达上限,请先关闭其他终端", neNotFound: "网元不存在", diff --git a/web/src/index.css b/web/src/index.css index 1a2e41f..c3c1610 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1456,23 +1456,31 @@ pre { background: #f0f0f0; border-right: 1px solid #808080; min-width: 0; + min-height: 0; /* grid/flex: allow tree to shrink and scroll */ + overflow: hidden; + height: 100%; +} + +.webcrt-sidebar__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px 6px 10px; + background: linear-gradient(#f8f8f8, #e4e4e4); + border-bottom: 1px solid #b0b0b0; + flex-shrink: 0; } .webcrt-sidebar__title { - padding: 6px 10px; font-size: 13px; font-weight: 600; color: #1e293b; - background: linear-gradient(#f8f8f8, #e4e4e4); - border-bottom: 1px solid #b0b0b0; + line-height: 1.2; } .webcrt-sidebar__toolbar { - display: flex; - gap: 4px; - padding: 4px 6px; - border-bottom: 1px solid #d0d0d0; - background: #ececec; + display: none; } .webcrt-icon-btn { @@ -1494,6 +1502,7 @@ pre { .webcrt-sidebar__search { padding: 6px; border-bottom: 1px solid #d0d0d0; + flex-shrink: 0; } .webcrt-sidebar__search input { @@ -1511,6 +1520,7 @@ pre { grid-template-columns: repeat(3, 1fr); gap: 0; border-bottom: 1px solid #c8c8c8; + flex-shrink: 0; } .webcrt-source-tabs__btn { @@ -1532,10 +1542,12 @@ pre { } .webcrt-tree { - flex: 1; - overflow: auto; + flex: 1 1 auto; + overflow-x: hidden; + overflow-y: auto; background: #fff; min-height: 0; + overscroll-behavior: contain; } .webcrt-tree__folder { @@ -1612,6 +1624,7 @@ pre { background: #ececec; font-size: 11px; color: #475569; + flex-shrink: 0; } .webcrt-sidebar__pager-btns { @@ -1636,6 +1649,7 @@ pre { display: flex; border-top: 1px solid #a8a8a8; background: #ddd; + flex-shrink: 0; } .webcrt-sidebar__footer button { @@ -1698,6 +1712,40 @@ pre { color: #475569; } +.webcrt-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 6px 8px; + background: #f3f4f6; + border-bottom: 1px solid #d1d5db; + flex-shrink: 0; +} + +.webcrt-action-btn { + display: inline-flex; + align-items: center; + gap: 5px; + height: 28px; + padding: 0 10px; + border: 1px solid #c0c0c0; + border-radius: 3px; + background: #fff; + color: #1e293b; + font-size: 12px; + cursor: pointer; +} + +.webcrt-action-btn:hover { + background: #f8fafc; +} + +.webcrt-action-btn.is-active { + background: #dbeafe; + border-color: #93c5fd; + color: #1d4ed8; +} + .webcrt-main__body { flex: 1; min-height: 0; @@ -1752,12 +1800,7 @@ pre { } .webcrt-statusline { - padding: 3px 8px; - background: #ececec; - border-top: 1px solid #a8a8a8; - font-size: 11px; - color: #334155; - flex-shrink: 0; + display: none; } @media (max-width: 900px) { diff --git a/web/src/pages/NePage.tsx b/web/src/pages/NePage.tsx index 84ba8b4..088f9e7 100644 --- a/web/src/pages/NePage.tsx +++ b/web/src/pages/NePage.tsx @@ -26,7 +26,7 @@ import { useToast } from "../hooks/useToast"; import type { ManagedNeItem } from "../types"; import { pageCount } from "../utils/display"; import { formatSystemTime } from "../utils/time"; -import { openOrFocusModule } from "../utils/moduleWindows"; +import { openNewModuleWindow } from "../utils/moduleWindows"; import { defaultHopTemplate, isAutoHopTemplate, @@ -777,7 +777,7 @@ export function NePage() { type="button" className="link-btn" onClick={() => - openOrFocusModule({ + openNewModuleWindow({ moduleId: "webcrt", path: `/webcrt?ne_id=${encodeURIComponent(row.id)}`, }) diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index 7a3a907..bcc7099 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -1,7 +1,7 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; -import { WebTerminal } from "../components/WebTerminal"; +import { WebTerminal, type WebTerminalHandle } from "../components/WebTerminal"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; import { @@ -20,15 +20,21 @@ type TermTab = { key: string; sessionId: string; wsUrl: string; + termEpoch: number; target: CliTargetItem; status: "connecting" | "connected" | "error" | "closed"; errorMessage?: string; + recording: boolean; }; function targetKey(t: Pick): string { return `${t.source}:${t.id}`; } +function deviceLabel(t: Pick): string { + return String(t.name || t.ip_address || "").trim() || "-"; +} + function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record) => string): string { const raw = String(err); if (raw.includes("webcrt_session_limit")) return t("webcrt.err.sessionLimit"); @@ -39,6 +45,16 @@ function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record([]); const [activeTabKey, setActiveTabKey] = useState(""); const connectingKeysRef = useRef>(new Set()); - const presetDoneRef = useRef(false); + const tabsRef = useRef([]); + tabsRef.current = tabs; + const termRefs = useRef>(new Map()); + const logBuffersRef = useRef>(new Map()); useEffect(() => { const timer = window.setTimeout(() => { @@ -74,34 +92,39 @@ export function WebcrtPage() { const total = targetsQuery.data?.total ?? 0; const pages = pageCount(total, PAGE_SIZE); - const selected = useMemo( - () => items.find((x) => targetKey(x) === selectedKey) || null, - [items, selectedKey], - ); - const updateTab = useCallback((key: string, patch: Partial) => { setTabs((prev) => prev.map((tab) => (tab.key === key ? { ...tab, ...patch } : tab))); }, []); const openTarget = useCallback( - async (target: CliTargetItem) => { + async (target: CliTargetItem, opts?: { force?: boolean }) => { const key = targetKey(target); - const existing = tabs.find((tab) => tab.key === key && tab.status !== "closed" && tab.status !== "error"); - if (existing) { + const existing = tabsRef.current.find( + (tab) => tab.key === key && tab.status !== "closed" && tab.status !== "error", + ); + if (existing && !opts?.force) { setActiveTabKey(existing.key); - setSelectedKey(key); return; } if (connectingKeysRef.current.has(key)) return; connectingKeysRef.current.add(key); - setSelectedKey(key); + + if (opts?.force && existing?.sessionId) { + try { + await closeWebcrtSession(existing.sessionId); + } catch { + /* ignore */ + } + } const pending: TermTab = { key, sessionId: "", wsUrl: "", + termEpoch: (existing?.termEpoch || 0) + (opts?.force ? 1 : 0), target, status: "connecting", + recording: existing?.recording || false, }; setTabs((prev) => { const without = prev.filter((x) => x.key !== key); @@ -118,8 +141,13 @@ export function WebcrtPage() { : { ne_id: target.id, cols, rows }; const sess = await createWebcrtSession(body); const wsUrl = webcrtWsUrl(sess.session_id); - updateTab(key, { sessionId: sess.session_id, wsUrl, status: "connecting" }); - showOk(t("webcrt.opened", { name: target.name || target.ip_address })); + updateTab(key, { + sessionId: sess.session_id, + wsUrl, + status: "connecting", + termEpoch: pending.termEpoch + 1, + }); + showOk(t("webcrt.opened", { name: deviceLabel(target) })); } catch (err) { const message = webcrtErrorMessage(err, t); updateTab(key, { status: "error", errorMessage: message }); @@ -128,12 +156,15 @@ export function WebcrtPage() { connectingKeysRef.current.delete(key); } }, - [tabs, showOk, showError, t, updateTab], + [showOk, showError, t, updateTab], ); + const openTargetRef = useRef(openTarget); + openTargetRef.current = openTarget; + const closeTab = useCallback( async (key: string) => { - const tab = tabs.find((x) => x.key === key); + const tab = tabsRef.current.find((x) => x.key === key); if (tab?.sessionId) { try { await closeWebcrtSession(tab.sessionId); @@ -141,6 +172,8 @@ export function WebcrtPage() { /* ignore */ } } + logBuffersRef.current.delete(key); + termRefs.current.delete(key); setTabs((prev) => { const next = prev.filter((x) => x.key !== key); if (activeTabKey === key) { @@ -149,73 +182,113 @@ export function WebcrtPage() { return next; }); }, - [tabs, activeTabKey], + [activeTabKey], ); - // One-shot open from /webcrt?ne_id=... + const reconnectActive = useCallback(async () => { + const tab = tabsRef.current.find((x) => x.key === activeTabKey); + if (!tab) return; + await openTarget(tab.target, { force: true }); + }, [activeTabKey, openTarget]); + + const toggleRecording = useCallback(() => { + const tab = tabsRef.current.find((x) => x.key === activeTabKey); + if (!tab) return; + const next = !tab.recording; + if (next) { + const seed = termRefs.current.get(tab.key)?.getText() || ""; + 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 stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const name = `${deviceLabel(tab.target) || "session"}-${stamp}.log`; + downloadText(name, body); + logBuffersRef.current.delete(tab.key); + showOk(t("webcrt.actions.recordingSaved")); + } + updateTab(tab.key, { recording: next }); + }, [activeTabKey, showOk, t, updateTab]); + + const clearActive = useCallback(() => { + const tab = tabsRef.current.find((x) => x.key === activeTabKey); + if (!tab) return; + termRefs.current.get(tab.key)?.clear(); + }, [activeTabKey]); + + const copyActive = useCallback(async () => { + const tab = tabsRef.current.find((x) => x.key === activeTabKey); + if (!tab) return; + try { + const text = await termRefs.current.get(tab.key)?.copyAll(); + if (text) showOk(t("webcrt.actions.copied")); + else showError(t("webcrt.actions.copyEmpty")); + } catch { + showError(t("webcrt.actions.copyFailed")); + } + }, [activeTabKey, showOk, showError, t]); + + // Auto-connect from /webcrt?ne_id=... useEffect(() => { - if (!presetNeId || presetDoneRef.current) return; - let cancelled = false; + if (!presetNeId) return; + const neId = presetNeId; + const sourceHint = presetSource === "ume" ? "ume" : "managed"; + let alive = true; + (async () => { - presetDoneRef.current = true; try { - if (presetSource === "ume") { - await openTarget({ + if (sourceHint === "ume") { + await openTargetRef.current({ source: "ume", - id: presetNeId, - ume_ne_id: presetNeId, - name: presetNeId, + id: neId, + ume_ne_id: neId, + name: neId, ip_address: "", connect_status: "unknown", }); } else { - const hit = items.find((x) => x.source === "managed" && x.id === presetNeId); - if (hit) { - await openTarget(hit); - } else { - const row = await fetchManagedNeById(presetNeId); - if (cancelled) return; - await openTarget({ - source: "managed", - id: row.id, - name: row.name || row.ip_address, - ip_address: row.ip_address, - vendor: row.vendor, - device_type: row.device_type, - connect_status: row.connect_status, - cli_profile_ready: true, - }); - } + const row = await fetchManagedNeById(neId); + await openTargetRef.current({ + source: "managed", + id: row.id, + name: row.name || row.ip_address, + ip_address: row.ip_address, + vendor: row.vendor, + device_type: row.device_type, + connect_status: row.connect_status, + cli_profile_ready: true, + }); } } catch (err) { - if (!cancelled) showError(webcrtErrorMessage(err, t)); + if (alive) showError(webcrtErrorMessage(err, t)); } finally { - setSearchParams({}, { replace: true }); + if (!alive) return; + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (next.get("ne_id") === neId) { + next.delete("ne_id"); + next.delete("source"); + } + return next; + }, + { replace: true }, + ); } })(); + return () => { - cancelled = true; + alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [presetNeId]); + }, [presetNeId, presetSource, showError, t, setSearchParams]); const activeTab = tabs.find((x) => x.key === activeTabKey) || null; - const perPage = (n: number) => t("common.perPage", { n }); return (
@@ -317,7 +380,7 @@ export function WebcrtPage() { onClick={() => setActiveTabKey(tab.key)} > - {tab.target.ip_address || tab.target.name} + {deviceLabel(tab.target)} {tab.status === "connecting" ? ` (${t("webcrt.status.connecting")})` : ""}
+ {activeTab ? ( +
+ + + + +
+ ) : null}
{tabs.map((tab) => ( )} - {activeTab ? ( -
- {activeTab.target.name} · {activeTab.target.ip_address} · {activeTab.target.source} ·{" "} - {t(`webcrt.status.${activeTab.status}`)} -
- ) : null}
); diff --git a/web/src/utils/moduleWindows.ts b/web/src/utils/moduleWindows.ts index 5da2806..bddd155 100644 --- a/web/src/utils/moduleWindows.ts +++ b/web/src/utils/moduleWindows.ts @@ -30,8 +30,12 @@ function moduleAckKey(moduleId: string): string { } export function registerModuleWindow(moduleId: string): () => void { + const sharedName = moduleWindowName(moduleId); try { - window.name = moduleWindowName(moduleId); + // Keep unique names from openNewModuleWindow; only claim the singleton if unnamed. + if (!window.name) { + window.name = sharedName; + } } catch { /* ignore */ } @@ -40,9 +44,29 @@ export function registerModuleWindow(moduleId: string): () => void { MODULE_CHANNEL, moduleAckKey(moduleId), (data) => { + // One-shot session windows (unique name) must not merge into the singleton module tab. + if (window.name && window.name !== sharedName) { + return; + } const targetPath = String(data.path || "").trim(); - if (targetPath && window.location.pathname !== targetPath) { - window.location.assign(targetPath); + if (targetPath) { + // Compare pathname only for hard navigation. Same route + new query + // must soft-navigate so React state (open tabs) survives. + try { + const url = new URL(targetPath, window.location.origin); + const next = `${url.pathname}${url.search}${url.hash}`; + const cur = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (window.location.pathname !== url.pathname) { + window.location.assign(next); + } else if (cur !== next) { + window.history.pushState({}, "", next); + window.dispatchEvent(new PopStateEvent("popstate")); + } + } catch { + if (window.location.pathname + window.location.search !== targetPath) { + window.location.assign(targetPath); + } + } } window.focus(); }, @@ -74,4 +98,16 @@ export function openOrFocusModule({ moduleId, path }: ModuleWindowSpec): void { }); } +/** Always open a fresh browser tab/window; never focus or merge with an existing module tab. */ +export function openNewModuleWindow({ moduleId, path }: ModuleWindowSpec): void { + const mod = getModuleById(moduleId); + const base = mod?.path ?? "/"; + const targetPath = path && (path === base || path.startsWith(`${base}?`) || path.startsWith(`${base}/`)) + ? path + : base; + const uniqueName = `${moduleWindowName(moduleId)}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + focusWindowSafe(window.open(targetPath, uniqueName)); +} + + export { moduleIdFromPath } from "../config/modules";