From 30f2db970818b364539de1bef34986e3da19866a Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 29 Jul 2026 18:54:59 +0800 Subject: [PATCH] fix(webcrt): replay login banner/prompt on attach Drain and nudge the device prompt after Netmiko login, replay bootstrap stdout when the WebSocket attaches, and surface connect errors in the terminal pane. Co-authored-by: Cursor --- netx_api/webcrt_router.py | 18 ++++++++++++ netx_api/webcrt_service.py | 47 ++++++++++++++++++++++++++++-- tests/test_webcrt.py | 5 +++- web/src/components/WebTerminal.tsx | 7 +++-- web/src/index.css | 10 +++++++ web/src/pages/WebcrtPage.tsx | 9 ++++-- 6 files changed, 87 insertions(+), 9 deletions(-) diff --git a/netx_api/webcrt_router.py b/netx_api/webcrt_router.py index 38d6ed1..83b3af4 100644 --- a/netx_api/webcrt_router.py +++ b/netx_api/webcrt_router.py @@ -93,9 +93,27 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: "protocol": sess.protocol, "cols": sess.cols, "rows": sess.rows, + "device_type": sess.device_type, + "vendor": sess.vendor, } ) + # Replay post-login banner/prompt so the UI is not blank until the user presses Enter. + bootstrap = bytes(sess.bootstrap_output or b"") + if bootstrap: + try: + await websocket.send_json( + {"type": "stdout", "data": bootstrap.decode("utf-8", errors="replace")} + ) + 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 + stop = asyncio.Event() async def pump_stdout() -> None: diff --git a/netx_api/webcrt_service.py b/netx_api/webcrt_service.py index 895819d..4e9f2a9 100644 --- a/netx_api/webcrt_service.py +++ b/netx_api/webcrt_service.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import logging import queue +import re import threading import time import uuid @@ -76,6 +77,35 @@ def map_network_cli_keys(data: str) -> str: i += 1 return "".join(out) +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] = [] + empty_streak = 0 + for _ in range(max(1, rounds)): + time.sleep(wait) + try: + part = conn.read_channel() + except Exception: + break + if part: + chunks.append(str(part)) + empty_streak = 0 + else: + empty_streak += 1 + if empty_streak >= 2 and chunks: + break + return "".join(chunks) + + +def _looks_like_cli_prompt(text: str) -> bool: + s = str(text or "").rstrip() + if not s: + return False + # Common network CLI prompts: [HUAWEI] Router# Router> + return bool(re.search(r"(?:[>\]]|#)\s*$", s)) or bool(re.search(r"<[^>\r\n]+>\s*$", s)) + + + def _utc_now() -> datetime: return datetime.now(timezone.utc) @@ -120,6 +150,7 @@ class WebcrtSession: detach_deadline: float | None = None closed: bool = False close_reason: str = "" + bootstrap_output: bytes = b"" 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) @@ -358,9 +389,19 @@ def create_session( pass try: - leftover = conn.read_channel() + leftover = _drain_channel(conn, rounds=8, wait=0.1) 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 sess = WebcrtSession( session_id=session_id, @@ -374,9 +415,9 @@ def create_session( vendor=vendor, cli_keymap=cli_keymap, conn=conn, + bootstrap_output=str(leftover or "").encode("utf-8", errors="replace"), ) - if leftover: - sess.out_queue.put(str(leftover).encode("utf-8", errors="replace")) + # Keep bootstrap for WS attach replay; do not rely solely on out_queue (StrictMode remount). sess.start_reader() with _sessions_lock: diff --git a/tests/test_webcrt.py b/tests/test_webcrt.py index f02af0f..9484838 100644 --- a/tests/test_webcrt.py +++ b/tests/test_webcrt.py @@ -145,8 +145,11 @@ class WebcrtServiceTests(unittest.TestCase): 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. + before = list(fake.written) sess.write_stdin("\n") - self.assertEqual(fake.written, ["\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 5781705..72ff54d 100644 --- a/web/src/components/WebTerminal.tsx +++ b/web/src/components/WebTerminal.tsx @@ -75,7 +75,6 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) { ws.onopen = () => { onStatusRef.current?.("open"); - term.writeln("\x1b[32mConnected.\x1b[0m"); sendResize(); onReadyRef.current?.(); }; @@ -87,6 +86,9 @@ 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); @@ -95,7 +97,8 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) { if (msg.type === "status") { onStatusRef.current?.(String(msg.state || ""), msg.message); if (msg.state === "connected") { - // Server ack; keep terminal clean. + 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 === "closed" || msg.state === "error") { diff --git a/web/src/index.css b/web/src/index.css index 593c857..1a2e41f 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1741,6 +1741,16 @@ pre { color: #fecaca; } +.webcrt-error-detail { + margin: 8px 0 0; + max-width: 80%; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + color: #fee2e2; + text-align: left; +} + .webcrt-statusline { padding: 3px 8px; background: #ececec; diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index 90606c4..7a3a907 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -22,6 +22,7 @@ type TermTab = { wsUrl: string; target: CliTargetItem; status: "connecting" | "connected" | "error" | "closed"; + errorMessage?: string; }; function targetKey(t: Pick): string { @@ -120,8 +121,9 @@ export function WebcrtPage() { updateTab(key, { sessionId: sess.session_id, wsUrl, status: "connecting" }); showOk(t("webcrt.opened", { name: target.name || target.ip_address })); } catch (err) { - updateTab(key, { status: "error" }); - showError(webcrtErrorMessage(err, t)); + const message = webcrtErrorMessage(err, t); + updateTab(key, { status: "error", errorMessage: message }); + showError(message); } finally { connectingKeysRef.current.delete(key); } @@ -344,7 +346,8 @@ export function WebcrtPage() { ) : null} {tab.status === "error" && !tab.wsUrl ? (
- {t("webcrt.status.error")} +
{t("webcrt.status.error")}
+ {tab.errorMessage ?
{tab.errorMessage}
: null}
) : null} {tab.wsUrl ? (