diff --git a/netx_api/webcrt_session_registry.py b/netx_api/webcrt_session_registry.py index 3776b5c..1d0c123 100644 --- a/netx_api/webcrt_session_registry.py +++ b/netx_api/webcrt_session_registry.py @@ -285,10 +285,11 @@ def _finish_connect( if early: sess.push_connect_echo(early) seed = f"{pre_log}{early}" - already_prompted = _looks_like_cli_prompt(seed) + already_prompted = _looks_like_cli_prompt(seed) or _looks_like_cli_prompt(pre_log) primed = "" - # Auto-answer Huawei post-login password-change (Netmiko would have sent N). - if _looks_like_password_change_prompt(seed): + # Auto-answer Huawei post-login password-change only if still sitting on the prompt + # (Netmiko telnet_login usually already answered N — do not send a second N/Enter). + if _looks_like_password_change_prompt(seed) and not already_prompted: try: conn.write_channel("N" + (getattr(conn, "RETURN", None) or "\n")) sess.push_connect_echo("\r\n[netx] password-change → N\r\n") @@ -313,7 +314,7 @@ def _finish_connect( except Exception: primed = "" combined = f"{seed}{primed}" - # Final settle: keep stragglers in bootstrap (normalize collapses duplicate prompts). + # Final settle: keep stragglers (normalize collapses duplicate prompts when bootstrapping). try: more = _capture_raw_channel(conn, duration=0.35) if more: @@ -328,27 +329,28 @@ def _finish_connect( sess.push_connect_echo(combined) except Exception: combined = "" - bootstrap = prepare_bootstrap_output(combined) - # Discard lone punctuation left on the wire (would glue onto ```` in xterm). - try: - leftover = _capture_raw_channel(conn, duration=0.12) - except Exception: - leftover = "" - if leftover and leftover.strip() not in {":", ">", "#", "]", "$"}: - sess.push_connect_echo(leftover) - bootstrap = prepare_bootstrap_output(f"{bootstrap}{leftover}") - - # Prefer live echo already shown on the terminal; only bootstrap the delta. + # Do not rebuild a second login transcript for bootstrap. Live connect-echo + # already streamed session_log + settle bytes to the terminal (CRT-like). + # Replaying prepare_bootstrap_output(combined) caused a full duplicate login. echoed = sess.connect_echo_text() - if echoed and bootstrap: - # If bootstrap is fully covered by live echo, skip replaying it. - norm_boot = bootstrap.replace("\r\n", "\n").strip() - norm_echo = echoed.replace("\r\n", "\n") - if norm_boot and norm_boot in norm_echo: - bootstrap = "" - elif norm_echo and norm_boot.startswith(norm_echo.strip()[-min(200, len(norm_echo)) :]): - # Overlap at the end of echo — keep only unseen suffix when possible. - bootstrap = bootstrap + bootstrap = "" + if not echoed.strip(): + bootstrap = prepare_bootstrap_output(combined) + try: + leftover = _capture_raw_channel(conn, duration=0.12) + except Exception: + leftover = "" + if leftover and leftover.strip() not in {":", ">", "#", "]", "$"}: + sess.push_connect_echo(leftover) + bootstrap = prepare_bootstrap_output(f"{bootstrap}{leftover}") + else: + # Optional unseen tail only (already pushed to connect-echo above when present). + try: + leftover = _capture_raw_channel(conn, duration=0.12) + except Exception: + leftover = "" + if leftover and leftover.strip() not in {":", ">", "#", "]", "$"}: + sess.push_connect_echo(leftover) hop_guard = get_cli_hop_guard(conn) sess.conn = conn @@ -357,7 +359,7 @@ def _finish_connect( sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding) # Nudge Enter on WS attach only when we still need a shell prompt. # Never when already at CLI prompt or Username:/Password: (would empty-submit login). - final_view = bootstrap or echoed + final_view = echoed or bootstrap sess.needs_live_prompt = ( not _looks_like_cli_prompt(final_view) and not _looks_like_login_prompt(final_view) ) diff --git a/web/src/components/WebTerminal.tsx b/web/src/components/WebTerminal.tsx index e67d2d7..108ead8 100644 --- a/web/src/components/WebTerminal.tsx +++ b/web/src/components/WebTerminal.tsx @@ -82,6 +82,8 @@ type Props = { ) => void; onReady?: () => void; onStdout?: (data: string) => void; + /** Seed text when mounting (frozen view after disconnect, or pre-WS buffer). */ + initialOutput?: string; }; function serializeTerminal(term: Terminal): string { @@ -177,8 +179,8 @@ export const WebTerminal = forwardRef(function WebTerm { wsUrl, sessionId, - title, - recording, + title: _title, + recording: _recording, autoFocus = true, encoding = "utf-8", fontSize = 13, @@ -190,6 +192,7 @@ export const WebTerminal = forwardRef(function WebTerm onStatus, onReady, onStdout, + initialOutput, }, ref, ) { @@ -208,7 +211,6 @@ export const WebTerminal = forwardRef(function WebTerm const onStatusRef = useRef(onStatus); const onReadyRef = useRef(onReady); const onStdoutRef = useRef(onStdout); - const recordingRef = useRef(!!recording); const autoFocusRef = useRef(autoFocus); const encodingRef = useRef(encoding); const fontSizeRef = useRef(Math.max(10, Math.min(28, Number(fontSize) || 13))); @@ -238,10 +240,6 @@ export const WebTerminal = forwardRef(function WebTerm onStdoutRef.current = onStdout; }, [onStatus, onReady, onStdout]); - useEffect(() => { - recordingRef.current = !!recording; - }, [recording]); - useEffect(() => { autoFocusRef.current = autoFocus; }, [autoFocus]); @@ -406,10 +404,13 @@ export const WebTerminal = forwardRef(function WebTerm useEffect(() => { const host = hostRef.current; if (!host) return; - if (!sessionId && !wsUrl) return; + const live = Boolean(sessionId || wsUrl); + const seed = String(initialOutput || ""); + if (!live && !seed.trim()) return; const term = new Terminal({ - cursorBlink: true, + cursorBlink: live, + 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. @@ -444,6 +445,26 @@ export const WebTerminal = forwardRef(function WebTerm }, 50); }); + if (seed) { + term.write(applyKeywordHighlight(seed.endsWith("\n") ? seed : `${seed}\n`, keywordHighlightRef.current)); + } + + // Frozen transcript after disconnect / login failure — no WebSocket. + // Do not emit onStatus("closed"/"error"): parent already owns that state; + // re-emitting would bump termEpoch and remount in a loop. + if (!live) { + onReadyRef.current?.(); + return () => { + try { + term.dispose(); + } catch { + /* ignore */ + } + termRef.current = null; + fitRef.current = null; + }; + } + // Guard against React StrictMode remount: the first WS teardown must not // report closed/error after a newer socket owns the terminal. let cancelled = false; @@ -467,7 +488,8 @@ export const WebTerminal = forwardRef(function WebTerm const writeStdout = (raw: string) => { if (!raw || !isActiveSocket()) return; lastStdoutAtRef.current = performance.now(); - if (recordingRef.current) onStdoutRef.current?.(raw); + // Always forward to parent (log buffer / frozen failure transcript); recording flag only gates download. + onStdoutRef.current?.(raw); writeBuf += raw; if (writeBuf.length >= 16384) { if (writeRaf) { @@ -557,7 +579,23 @@ export const WebTerminal = forwardRef(function WebTerm return; } if (state === "closed" || state === "error") { - const detail = msg.message ? `: ${msg.message}` : ""; + const rawMsg = String(msg.message || ""); + // Device login failures are already live-echoed into the terminal + // (incl. Netmiko detail). Avoid dumping a second toast-sized blob. + let detail = ""; + if (rawMsg) { + if (/connect_failed|NetmikoTimeoutException|TCP connection to device failed/i.test(rawMsg)) { + const one = rawMsg + .replace(/^.*connect_failed:/i, "") + .split("\n---")[0] + .split("\n")[0] + .trim() + .slice(0, 240); + detail = one ? `: ${one}` : ""; + } else { + detail = `: ${rawMsg}`; + } + } term.writeln( `\r\n\x1b[33m${tRef.current("webcrt.term.sessionStatus", { state, @@ -764,7 +802,7 @@ export const WebTerminal = forwardRef(function WebTerm // Fresh ticket per mount when sessionId is set. Do not re-run when a late // parent wsUrl fill-in arrives — that remount dropped the wait-loop WS and // left Quick Connect stuck on "authenticating". - }, [sessionId || wsUrl]); + }, [sessionId || wsUrl, initialOutput]); const pasteFromClipboard = async () => { setCtxMenu(null); diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index 3d2c0b2..16f414e 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -162,6 +162,8 @@ type TermTab = { status: "connecting" | "connected" | "error" | "closed"; connectPhase?: ConnectPhase; errorMessage?: string; + /** Frozen CRT transcript after disconnect/login failure (no overlay). */ + frozenOutput?: string; recording: boolean; encoding: string; /** From session create — nested CLI hop cannot use SFTP. */ @@ -174,16 +176,6 @@ type TabMenuState = { key: string; x: number; y: number }; type TreeMenuState = { target: CliTargetItem; x: number; y: number }; -function connectPhaseLabel( - phase: ConnectPhase | undefined, - t: (key: string, vars?: Record) => string, -): string { - if (phase === "waiting_prompt") return t("webcrt.phase.waitingPrompt"); - if (phase === "authenticating") return t("webcrt.phase.authenticating"); - if (phase === "creating") return t("webcrt.phase.creating"); - return t("webcrt.status.connecting"); -} - function defaultSessionOptions(): SessionOptions { return { encoding: "utf-8", @@ -646,9 +638,9 @@ function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record): CliTargetItem { return { source: "webcrt", @@ -1009,6 +1024,7 @@ export function WebcrtPage() { recording: prior?.recording || existing?.recording || false, encoding, errorMessage: undefined, + frozenOutput: undefined, }; setTabs((prev) => { const without = prev.filter((x) => x.key !== key); @@ -1046,9 +1062,12 @@ export function WebcrtPage() { status: "connecting", connectPhase: "authenticating", termEpoch: pending.termEpoch + 1, + frozenOutput: undefined, + errorMessage: undefined, cliHop: Boolean(sess.cli_hop), sftpReady: typeof sess.sftp_ready === "boolean" ? sess.sftp_ready : undefined, }); + logBuffersRef.current.set(key, []); showOk(t("webcrt.opened", { name: deviceLabel(target) })); } catch (err) { const message = webcrtErrorMessage(err, t); @@ -1057,10 +1076,21 @@ export function WebcrtPage() { (String(err).includes("credentials_incomplete") || String(err).includes("connect_failed") || isSshAuthFailure(err)); - updateTab(key, { status: "error", connectPhase: undefined, errorMessage: message }); + const frozen = formatConnectFailureForTerm(String(err)); + updateTab(key, { + status: "error", + connectPhase: undefined, + errorMessage: message, + frozenOutput: frozen, + termEpoch: pending.termEpoch + 1, + sessionId: "", + wsUrl: "", + }); + logBuffersRef.current.set(key, [frozen]); if (needAuth) { openAuthForTarget(target, message); - } else { + } else if (!isDeviceLoginFailureMessage(message) && !isDeviceLoginFailureMessage(String(err))) { + // Non-device errors (session limit, etc.) may still toast. showError(message); } } finally { @@ -1080,6 +1110,7 @@ export function WebcrtPage() { connectPhase: "authenticating", termEpoch: tab.termEpoch + 1, errorMessage: undefined, + frozenOutput: undefined, }); setActiveTabKey(tab.key); return true; @@ -2241,9 +2272,6 @@ export function WebcrtPage() { > {deviceLabel(tab.target)} - {tab.status === "connecting" - ? ` (${connectPhaseLabel(tab.connectPhase, t)})` - : ""} {tab.status === "closed" ? ` (${t("webcrt.status.closed")})` : ""} {tab.status === "error" ? ` (${t("webcrt.status.error")})` : ""} {tabs.map((tab) => { const isActive = activeTabKey === tab.key; - // Cap concurrent xterm+WS: active always mounts; keep recent tabs warm. - // Do not mount while disconnected — reconnect is manual (no silent remount). + const frozen = Boolean(tab.frozenOutput); + // Live session, or frozen CRT transcript after login failure / disconnect. const mountTerminal = Boolean( - tab.sessionId && - tab.status !== "closed" && - tab.status !== "error" && - (isActive || warmTabKeys.has(tab.key)), + (isActive || warmTabKeys.has(tab.key)) && + ((tab.sessionId && tab.status !== "closed" && tab.status !== "error") || + (frozen && (tab.status === "error" || tab.status === "closed"))), ); return ( );