diff --git a/netx_api/webcrt_channel.py b/netx_api/webcrt_channel.py index 3194464..32645d3 100644 --- a/netx_api/webcrt_channel.py +++ b/netx_api/webcrt_channel.py @@ -89,10 +89,8 @@ def map_network_cli_enter(data: str, conn: ConnectHandler | None) -> str: 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) + # Replace once: chained replacements expand the CR in Telnet CRLF again. + return re.sub(r"\r\n|\r|\n", lambda _match: ret, text) def _drain_channel(conn: ConnectHandler, *, rounds: int = 6, wait: float = 0.06) -> str: diff --git a/netx_api/webcrt_router.py b/netx_api/webcrt_router.py index 74d02c8..a7e3493 100644 --- a/netx_api/webcrt_router.py +++ b/netx_api/webcrt_router.py @@ -208,9 +208,8 @@ def api_quick_connect( user_override = str(body.username or "").strip() or None if str(body.password or "").strip(): pwd_override = str(body.password) - # SSH with password: wait for auth so wrong credentials can re-prompt (SecureCRT-like). - wait_for_auth = proto == "ssh" and bool(pwd_override) - async_connect = bool(body.async_connect) and not wait_for_auth + # Open the terminal immediately; WS reports auth progress and retryable failures. + async_connect = bool(body.async_connect) try: session = create_session( db, diff --git a/netx_api/webcrt_session_registry.py b/netx_api/webcrt_session_registry.py index cc38cb0..2bdf91b 100644 --- a/netx_api/webcrt_session_registry.py +++ b/netx_api/webcrt_session_registry.py @@ -453,8 +453,12 @@ def _finish_connect( text = _decode_bytes(chunk, sess.encoding) except Exception: text = "" - if _is_prompt_only_echo(text, prompt_hint): + # A slow VM may emit its FIRST prompt after the reader starts. + # Only drop duplicates when a prompt was already delivered during login. + if _looks_like_cli_prompt(final_view) and _is_prompt_only_echo(text, prompt_hint): continue + if _looks_like_cli_prompt(text): + sess.needs_live_prompt = False # Non-prompt data: put back and stop settling. sess.out_queue.put(chunk) break diff --git a/tests/test_webcrt.py b/tests/test_webcrt.py index 4d93477..786ff23 100644 --- a/tests/test_webcrt.py +++ b/tests/test_webcrt.py @@ -94,6 +94,10 @@ class WebcrtServiceTests(unittest.TestCase): telnet = _FakeConn() telnet.RETURN = "\r\n" self.assertEqual(svc.map_network_cli_enter("\r", telnet), "\r\n") # type: ignore[arg-type] + self.assertEqual(svc.map_network_cli_enter("show clock\r\n\r\n", telnet), "show clock\r\n\r\n") + self.assertEqual(svc.map_network_cli_enter("a\nb\rc\r\n", telnet), "a\r\nb\r\nc\r\n") + telnet.RETURN = "\r" + self.assertEqual(svc.map_network_cli_enter("a\r\nb\n", telnet), "a\rb\r") 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\nR2#") @@ -154,6 +158,47 @@ class WebcrtServiceTests(unittest.TestCase): self.assertIn("R2#", text) self.assertNotIn("MagicMock", text) + @patch.object(reg, "_audit") + @patch.object(reg, "get_cli_hop_guard", return_value=None) + @patch.object(reg, "_capture_raw_channel", return_value="") + @patch.object(reg, "_prime_interactive_channel", return_value="") + @patch.object(reg, "open_netmiko_connection") + def test_first_prompt_arriving_after_reader_start_is_preserved(self, mock_open, *_mocks) -> None: + sess = svc.WebcrtSession( + session_id="late-first-prompt", ne_id="lab", ne_name="lab", + ne_ip="192.0.2.1", protocol="telnet", cols=80, rows=24, + ) + mock_open.return_value = _FakeConn() + # Auth banner has been streamed, but the VM has not sent its first prompt yet. + sess.push_connect_echo("Login successful\r\n") + with patch.object(sess, "start_reader", side_effect=lambda: sess.out_queue.put(b"")), \ + patch.object(sess, "open_session_log"), patch.object(sess, "append_session_log"): + reg._finish_connect(sess, creds={}, device={}, connect_timeout=30, client="test") + self.assertEqual(sess.state, "ready") + self.assertEqual(sess.out_queue.get_nowait(), b"") + self.assertFalse(sess.needs_live_prompt) + + def test_quick_connect_honors_async_authentication(self) -> None: + from netx_api.webcrt_router import WebcrtQuickConnectBody, api_quick_connect + + ne = MagicMock() + ne.id = "lab" + ctx = MagicMock() + ctx.user.id = "user" + ctx.user.username = "operator" + with patch("netx_api.ne_service.upsert_webcrt_session_host", return_value=(ne, "reused")), \ + patch("netx_api.webcrt_router.create_session", return_value={"session_id": "pending"}) as create, \ + patch("netx_api.webcrt_router._client_label", return_value="test"): + for async_connect in (True, False): + body = WebcrtQuickConnectBody( + ip_address="192.0.2.1", protocol="ssh", username="operator", + password="one-shot", save_password=False, async_connect=async_connect, + ) + result = api_quick_connect(body, request=MagicMock(), db=MagicMock(), ctx=ctx) + self.assertEqual(result["session_id"], "pending") + self.assertEqual(create.call_args.kwargs["async_connect"], async_connect) + self.assertEqual(create.call_args.kwargs["password_override"], "one-shot") + @patch.object(reg, "_audit") @patch.object(reg, "open_netmiko_connection") @patch("netx_api.cli_resolve.resolve_cli_target") diff --git a/web/src/components/WebTerminal.tsx b/web/src/components/WebTerminal.tsx index 49376a7..693dbed 100644 --- a/web/src/components/WebTerminal.tsx +++ b/web/src/components/WebTerminal.tsx @@ -17,7 +17,7 @@ export type WebTerminalHandle = { copySelection: () => Promise; getText: () => string; fit: () => void; - focus: () => void; + focus: (opts?: { force?: boolean }) => void; sendBreak: () => void; sendText: (data: string, opts?: { throttle?: boolean }) => void; findNext: (term: string) => void; @@ -74,6 +74,7 @@ type Props = { themeName?: "dark" | "light"; pasteDelayMs?: number; copyOnSelect?: boolean; + rightClickPaste?: boolean; keywordHighlight?: KeywordHighlightConfig; onStatus?: ( state: string, @@ -147,7 +148,7 @@ export function isAuditableCommandLine(line: string): boolean { function currentCommandLine(term: Terminal): string { const buf = term.buffer.active; - const y = buf.cursorY; + const y = buf.baseY + buf.cursorY; const curText = normalizeAuditLine(buf.getLine(y)?.translateToString(true) ?? ""); // Sitting on a bare prompt / blank row after output — never walk up into the @@ -267,7 +268,7 @@ function loadPrefs(): { copyOnSelect: boolean; pasteDelayMs: number } { const j = JSON.parse(raw) as Partial<{ copyOnSelect: boolean; pasteDelayMs: number }>; return { copyOnSelect: j.copyOnSelect !== false, - pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs) || 40)), + pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs ?? 40) || 0)), }; } catch { return { copyOnSelect: true, pasteDelayMs: 40 }; @@ -287,6 +288,7 @@ export const WebTerminal = forwardRef(function WebTerm themeName = "dark", pasteDelayMs, copyOnSelect, + rightClickPaste = true, keywordHighlight, onStatus, onReady, @@ -316,6 +318,7 @@ export const WebTerminal = forwardRef(function WebTerm const termColorsRef = useRef(resolvedColors); const pasteDelayRef = useRef(pasteDelayMs ?? loadPrefs().pasteDelayMs); const copyOnSelectRef = useRef(copyOnSelect ?? loadPrefs().copyOnSelect); + const rightClickPasteRef = useRef(rightClickPaste); const pasteQueueRef = useRef>(Promise.resolve()); /** Updated whenever device stdout arrives; used to pace paste by echo, not fixed sleep. */ const lastStdoutAtRef = useRef(0); @@ -332,10 +335,6 @@ export const WebTerminal = forwardRef(function WebTerm const pasteBridgeOpenRef = useRef(false); pasteBridgeOpenRef.current = pasteBridgeOpen; const findInputRef = useRef(null); - /** Suppress duplicate Enter frames (keydown + xterm onData both fire). */ - const enterHandledAtRef = useRef(0); - /** Last Tab keystroke — Enter soon after must wait for device completion redraw. */ - const lastTabAtRef = useRef(0); useEffect(() => { onStatusRef.current = onStatus; @@ -378,9 +377,16 @@ export const WebTerminal = forwardRef(function WebTerm if (copyOnSelect != null) copyOnSelectRef.current = copyOnSelect; }, [copyOnSelect]); + rightClickPasteRef.current = rightClickPaste; + keywordHighlightRef.current = keywordHighlight; - const focusTerminal = () => { + const focusTerminal = (force = false) => { + const host = hostRef.current; + if (!autoFocusRef.current || !host || host.closest("[hidden]")) return; + if (document.querySelector('[role="dialog"], [role="alertdialog"]')) return; + const active = document.activeElement; + if (!force && active && active !== document.body && !host.contains(active)) return; try { termRef.current?.focus(); } catch { @@ -405,27 +411,6 @@ export const WebTerminal = forwardRef(function WebTerm sendJson(payload); }; - const sendEnterStdin = (term: Terminal, explicitAuditLine?: string) => { - const recentTab = performance.now() - lastTabAtRef.current < 450; - if (recentTab) { - // Re-read xterm after device Tab redraw; ignore any stale snapshot. - // Mark Enter handled now so onData duplicate frame is suppressed while we wait. - enterHandledAtRef.current = performance.now(); - window.setTimeout(() => { - enterHandledAtRef.current = performance.now(); - sendStdinWithAudit("\r", auditLineForEnter(term)); - }, 100); - return; - } - enterHandledAtRef.current = performance.now(); - sendStdinWithAudit("\r", explicitAuditLine ?? auditLineForEnter(term)); - }; - - const isDuplicateEnterFrame = (data: string): boolean => { - if (!/^[\r\n]+$/.test(data)) return false; - return performance.now() - enterHandledAtRef.current < 120; - }; - const sendStdinImmediate = (data: string) => { sendStdinWithAudit(data); }; @@ -508,7 +493,7 @@ export const WebTerminal = forwardRef(function WebTerm /* ignore */ } }, - focus: focusTerminal, + focus: (opts) => focusTerminal(opts?.force !== false), sendBreak: () => sendJson({ type: "break" }), sendText: (data: string, opts?: { throttle?: boolean }) => { if (opts?.throttle) sendStdinThrottled(data); @@ -546,8 +531,9 @@ export const WebTerminal = forwardRef(function WebTerm disableStdin: !live, fontSize: fontSizeRef.current, fontFamily: 'Consolas, "Courier New", monospace', - // Lower than CRT-style 10k: remounts + multi-tab stay lighter; server replays log tail on attach. - scrollback: 4000, + scrollback: 10000, + cursorStyle: "block", + scrollOnUserInput: true, theme: xtermThemeFromColors(termColorsRef.current), convertEol: true, }); @@ -656,14 +642,12 @@ export const WebTerminal = forwardRef(function WebTerm if (!isActiveSocket()) return; if (ev.data instanceof ArrayBuffer) { writeStdout(decodeBytes(ev.data, encodingRef.current)); - maybeFocus(); return; } if (typeof Blob !== "undefined" && ev.data instanceof Blob) { void ev.data.arrayBuffer().then((buf) => { if (!isActiveSocket()) return; writeStdout(decodeBytes(buf, encodingRef.current)); - maybeFocus(); }); return; } @@ -679,7 +663,6 @@ export const WebTerminal = forwardRef(function WebTerm }; if (msg.type === "stdout" && typeof msg.data === "string") { writeStdout(msg.data); - maybeFocus(); return; } if (msg.type === "status") { @@ -793,23 +776,7 @@ export const WebTerminal = forwardRef(function WebTerm const dataDisposable = term.onData((data) => { const normalized = data.replace(/\x7f/g, "\x08"); - if (normalized.includes("\t")) { - lastTabAtRef.current = performance.now(); - } - if (isDuplicateEnterFrame(normalized)) return; - const isEnter = normalized.includes("\r") || normalized.includes("\n"); - if (isEnter) { - enterHandledAtRef.current = performance.now(); - const recentTab = performance.now() - lastTabAtRef.current < 450; - // After Tab, wait briefly so Huawei/ZTE redraw lands in xterm before we snapshot. - if (recentTab && /^[\r\n]+$/.test(normalized)) { - window.setTimeout(() => { - sendStdinWithAudit("\r", auditLineForEnter(term)); - }, 100); - return; - } - } const auditLine = isEnter ? auditLineForEnter(term) : undefined; // Large pastes from xterm arrive as one onData blob. @@ -830,6 +797,9 @@ export const WebTerminal = forwardRef(function WebTerm if (!autoFocusRef.current) return; if (host.closest("[hidden]")) return; if (isSidebarSearchTarget(e.target)) return; + if (document.querySelector('[role="dialog"], [role="alertdialog"]')) return; + if (!host.contains(e.target as Node) && e.target !== document.body) return; + if (e.isComposing) return; if ((e.ctrlKey || e.metaKey) && (e.key === "f" || e.key === "F")) { e.preventDefault(); @@ -869,12 +839,7 @@ export const WebTerminal = forwardRef(function WebTerm return; } - if (e.key === "Backspace") { - e.preventDefault(); - } - if (isXtermTextarea(e.target)) { - maybeFocus(); return; } @@ -885,7 +850,7 @@ export const WebTerminal = forwardRef(function WebTerm e.stopPropagation(); maybeFocus(); if (e.key === "Enter") { - sendEnterStdin(term); + sendStdinWithAudit("\r", auditLineForEnter(term)); return; } sendStdinWithAudit(data); @@ -894,6 +859,10 @@ export const WebTerminal = forwardRef(function WebTerm const onContextMenu = (e: MouseEvent) => { e.preventDefault(); + if (live && rightClickPasteRef.current && !e.shiftKey) { + void pasteFromClipboard(); + return; + } menuSelectionRef.current = term.getSelection() || ""; setCtxMenu({ x: e.clientX, y: e.clientY }); }; @@ -905,6 +874,7 @@ export const WebTerminal = forwardRef(function WebTerm if (host.closest("[hidden]")) return; if (isSidebarSearchTarget(e.target)) return; const text = e.clipboardData?.getData("text") || ""; + if (!host.contains(e.target as Node)) return; if (!text) return; // Intercept paste so we always apply delay (even if focus is odd). if (document.activeElement === term.textarea || host.contains(document.activeElement) || autoFocusRef.current) { diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 1ad92c0..ba5c1a6 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -2388,6 +2388,8 @@ const en = { custom: "Custom", }, copyOnSelect: "Copy on select", + rightClickPaste: "Paste on right click", + rightClickPasteHint: "Shift + right click opens the menu. Turn off to always show the menu.", pasteDelay: "Paste max line wait", pasteDelayOff: "No delay (bulk send)", keepalive: "SSH keepalive", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index c770ea1..14f793f 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -2370,6 +2370,8 @@ const zh = { custom: "自定义", }, copyOnSelect: "选中即复制", + rightClickPaste: "右键直接粘贴", + rightClickPasteHint: "Shift + 右键打开菜单;关闭此项后,右键始终打开菜单。", pasteDelay: "粘贴最大行间隔", pasteDelayOff: "无延迟(整块发送)", keepalive: "SSH Keepalive", diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index a7be5b7..6f2034a 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -111,6 +111,7 @@ type SessionOptions = { background: string; foreground: string; copyOnSelect: boolean; + rightClickPaste: boolean; pasteDelayMs: number; keepaliveSec: number; }; @@ -186,6 +187,7 @@ function defaultSessionOptions(): SessionOptions { background: COLOR_SCHEMES.dark.background, foreground: COLOR_SCHEMES.dark.foreground, copyOnSelect: true, + rightClickPaste: true, pasteDelayMs: 40, keepaliveSec: 0, }; @@ -199,7 +201,7 @@ function loadSessionOptions(): SessionOptions { const legacy = JSON.parse(legacyRaw) as Partial<{ copyOnSelect: boolean; pasteDelayMs: number }>; if (legacy.copyOnSelect === false) base.copyOnSelect = false; if (legacy.pasteDelayMs != null) { - base.pasteDelayMs = Math.max(0, Math.min(200, Number(legacy.pasteDelayMs) || 40)); + base.pasteDelayMs = Math.max(0, Math.min(200, Number(legacy.pasteDelayMs) || 0)); } } const raw = localStorage.getItem(SESSION_OPTS_KEY); @@ -234,7 +236,8 @@ function loadSessionOptions(): SessionOptions { background, foreground, copyOnSelect: j.copyOnSelect !== false, - pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs ?? base.pasteDelayMs) || 40)), + rightClickPaste: j.rightClickPaste !== false, + pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs ?? base.pasteDelayMs) || 0)), keepaliveSec: Math.max(0, Math.min(600, Number(j.keepaliveSec ?? base.keepaliveSec) || 0)), }; } catch { @@ -263,6 +266,7 @@ function normalizeSessionOptions(draft: SessionOptions): SessionOptions { background: preset.background, foreground: preset.foreground, copyOnSelect: draft.copyOnSelect !== false, + rightClickPaste: draft.rightClickPaste !== false, pasteDelayMs: Math.max(0, Math.min(200, Number(draft.pasteDelayMs) || 0)), keepaliveSec: Math.max(0, Math.min(600, Number(draft.keepaliveSec) || 0)), }; @@ -274,6 +278,7 @@ function normalizeSessionOptions(draft: SessionOptions): SessionOptions { background, foreground, copyOnSelect: draft.copyOnSelect !== false, + rightClickPaste: draft.rightClickPaste !== false, pasteDelayMs: Math.max(0, Math.min(200, Number(draft.pasteDelayMs) || 0)), keepaliveSec: Math.max(0, Math.min(600, Number(draft.keepaliveSec) || 0)), }; @@ -1245,7 +1250,7 @@ export function WebcrtPage() { rows: dims.rows, encoding: dims.encoding, keepalive_sec: dims.keepaliveSec, - async_connect: false, + async_connect: true, username, password: authForm.password, }); @@ -1279,7 +1284,7 @@ export function WebcrtPage() { rows: dims.rows, encoding: dims.encoding, keepalive_sec: dims.keepaliveSec, - async_connect: false, + async_connect: true, }); const target: CliTargetItem = { source: "webcrt", @@ -2420,9 +2425,10 @@ export function WebcrtPage() { foreground: sessionOpts.foreground, }} copyOnSelect={sessionOpts.copyOnSelect} + rightClickPaste={sessionOpts.rightClickPaste} pasteDelayMs={sessionOpts.pasteDelayMs} keywordHighlight={keywordHl} - autoFocus={tab.status === "connected"} + autoFocus={isActive && (tab.status === "connected" || tab.status === "connecting")} onStdout={(chunk) => { if (!chunk) return; let parts = logBuffersRef.current.get(tab.key); @@ -2452,7 +2458,7 @@ export function WebcrtPage() { ...(typeof meta?.sftpReady === "boolean" ? { sftpReady: meta.sftpReady } : {}), ...(typeof meta?.cliHop === "boolean" ? { cliHop: meta.cliHop } : {}), }); - window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40); + window.setTimeout(() => termRefs.current.get(tab.key)?.focus({ force: false }), 40); } else if (state === "open" || state === "connecting") { const nextPhase: ConnectPhase = phase === "waiting_prompt" || message === "waiting_prompt" @@ -3257,6 +3263,17 @@ export function WebcrtPage() { } /> + +

{t("webcrt.rightClickPasteHint")}