mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
Keep WebCRT login failures in-terminal CRT-style and skip duplicate bootstrap replay.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6aedd2c522
commit
cacd8c7b75
3 changed files with 167 additions and 121 deletions
|
|
@ -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 ``<r1>`` 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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<WebTerminalHandle, Props>(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<WebTerminalHandle, Props>(function WebTerm
|
|||
onStatus,
|
||||
onReady,
|
||||
onStdout,
|
||||
initialOutput,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
|
|
@ -208,7 +211,6 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(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<WebTerminalHandle, Props>(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<WebTerminalHandle, Props>(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<WebTerminalHandle, Props>(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<WebTerminalHandle, Props>(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<WebTerminalHandle, Props>(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<WebTerminalHandle, Props>(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);
|
||||
|
|
|
|||
|
|
@ -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 | number>) => 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<string,
|
|||
if (raw.includes("ip_address_conflict_restart_required")) return t("webcrt.err.ipConflictRestart");
|
||||
if (raw.includes("ip_address_required")) return t("webcrt.newSession.ipRequired");
|
||||
if (raw.includes("cli_connect_profile_not_configured")) return t("webcrt.err.cliProfile");
|
||||
if (raw.includes("connect_failed")) {
|
||||
const detail = raw.replace(/^.*connect_failed:/, "").split("\n---")[0].trim();
|
||||
return t("webcrt.err.connectFailed", { detail: detail.slice(0, 180) });
|
||||
if (raw.includes("connect_failed") || /NetmikoTimeoutException|TCP connection to device failed/i.test(raw)) {
|
||||
const detail = raw.replace(/^.*connect_failed:/i, "").split("\n---")[0].trim();
|
||||
return t("webcrt.err.connectFailed", { detail: (detail || raw).slice(0, 180) });
|
||||
}
|
||||
if (raw.includes("sftp_hop_not_supported")) return t("webcrt.err.sftpHop");
|
||||
if (raw.includes("sftp_requires_ssh")) return t("webcrt.err.sftpSsh");
|
||||
|
|
@ -659,6 +651,29 @@ function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record<string,
|
|||
return raw;
|
||||
}
|
||||
|
||||
/** Device TCP/login failures belong in the terminal transcript, not a toast popup. */
|
||||
function isDeviceLoginFailureMessage(msg: string): boolean {
|
||||
const raw = String(msg || "");
|
||||
return (
|
||||
raw.includes("connect_failed") ||
|
||||
raw.includes("登录设备失败") ||
|
||||
raw.includes("Device login failed") ||
|
||||
/NetmikoTimeoutException|TCP connection to device failed/i.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
/** Format connect failure for CRT-style terminal echo (no modal). */
|
||||
function formatConnectFailureForTerm(raw: string): string {
|
||||
let body = String(raw || "").trim();
|
||||
if (!body) return "\r\n[netx] connect failed\r\n";
|
||||
if (/connect_failed:/i.test(body)) {
|
||||
body = body.replace(/^[\s\S]*?connect_failed:/i, "").trim();
|
||||
}
|
||||
// Live echo already showed device transcript; keep the exception summary only.
|
||||
const main = body.split(/\n---\s*device transcript\s*---/i)[0].trim();
|
||||
return `\r\n[netx] connect failed\r\n${main}\r\n`;
|
||||
}
|
||||
|
||||
function targetFromManagedNe(ne: ManagedNeItem, fallback?: Partial<CliTargetItem>): 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() {
|
|||
>
|
||||
<span>
|
||||
{deviceLabel(tab.target)}
|
||||
{tab.status === "connecting"
|
||||
? ` (${connectPhaseLabel(tab.connectPhase, t)})`
|
||||
: ""}
|
||||
{tab.status === "closed" ? ` (${t("webcrt.status.closed")})` : ""}
|
||||
{tab.status === "error" ? ` (${t("webcrt.status.error")})` : ""}
|
||||
<span
|
||||
|
|
@ -2332,13 +2360,12 @@ export function WebcrtPage() {
|
|||
<div className="webcrt-main__terms">
|
||||
{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 (
|
||||
<div
|
||||
|
|
@ -2346,51 +2373,12 @@ export function WebcrtPage() {
|
|||
className="webcrt-main__pane"
|
||||
hidden={!isActive}
|
||||
>
|
||||
{tab.status === "connecting" && !tab.sessionId ? (
|
||||
{tab.status === "connecting" && !tab.sessionId && !frozen ? (
|
||||
<div className="webcrt-main__placeholder">
|
||||
<div>{connectPhaseLabel(tab.connectPhase, t)}…</div>
|
||||
<p className="panel__hint">{t("webcrt.phase.hint")}</p>
|
||||
<div>{t("webcrt.status.connecting")}…</div>
|
||||
</div>
|
||||
) : null}
|
||||
{tab.status === "error" && !tab.sessionId ? (
|
||||
<div className="webcrt-main__placeholder webcrt-main__placeholder--error">
|
||||
<div>{t("webcrt.status.error")}</div>
|
||||
{tab.errorMessage ? <pre className="webcrt-error-detail">{tab.errorMessage}</pre> : null}
|
||||
<button type="button" className="webcrt-action-btn is-warn" onClick={() => void reconnectTab(tab)}>
|
||||
{t("webcrt.actions.reconnect")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{tab.sessionId ? (
|
||||
<>
|
||||
{tab.status === "connecting" ? (
|
||||
<div className="webcrt-connect-banner" role="status">
|
||||
<div className="webcrt-connect-banner__text">
|
||||
<strong>{connectPhaseLabel(tab.connectPhase, t)}…</strong>
|
||||
<span>{t("webcrt.phase.hint")}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{tab.status === "closed" || tab.status === "error" ? (
|
||||
<div className="webcrt-disconnect-banner" role="status">
|
||||
<div className="webcrt-disconnect-banner__text">
|
||||
<strong>
|
||||
{tab.status === "error"
|
||||
? t("webcrt.disconnectBannerError")
|
||||
: t("webcrt.disconnectBanner")}
|
||||
</strong>
|
||||
{tab.errorMessage ? <span>{tab.errorMessage}</span> : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="webcrt-action-btn is-warn"
|
||||
onClick={() => void reconnectTab(tab)}
|
||||
>
|
||||
{t("webcrt.actions.reconnect")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{mountTerminal ? (
|
||||
{mountTerminal ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="webcrt-main__placeholder" role="status">
|
||||
|
|
@ -2404,8 +2392,9 @@ export function WebcrtPage() {
|
|||
if (handle) termRefs.current.set(tab.key, handle);
|
||||
else termRefs.current.delete(tab.key);
|
||||
}}
|
||||
sessionId={tab.sessionId || undefined}
|
||||
wsUrl={tab.wsUrl || undefined}
|
||||
sessionId={frozen ? undefined : tab.sessionId || undefined}
|
||||
wsUrl={frozen ? undefined : tab.wsUrl || undefined}
|
||||
initialOutput={frozen ? tab.frozenOutput : undefined}
|
||||
title={deviceLabel(tab.target)}
|
||||
recording={tab.recording}
|
||||
encoding={tab.encoding}
|
||||
|
|
@ -2443,6 +2432,7 @@ export function WebcrtPage() {
|
|||
status: "connected",
|
||||
connectPhase: undefined,
|
||||
errorMessage: undefined,
|
||||
frozenOutput: undefined,
|
||||
...(typeof meta?.sftpReady === "boolean" ? { sftpReady: meta.sftpReady } : {}),
|
||||
...(typeof meta?.cliHop === "boolean" ? { cliHop: meta.cliHop } : {}),
|
||||
});
|
||||
|
|
@ -2475,47 +2465,63 @@ export function WebcrtPage() {
|
|||
} else if (state === "error") {
|
||||
const errMsg = message || t("webcrt.disconnectBannerError");
|
||||
const sid = tab.sessionId;
|
||||
// Session gone / auth failure: stop here — user must click Reconnect.
|
||||
if (sid && isSessionGoneError(errMsg)) {
|
||||
updateTab(tab.key, {
|
||||
status: "closed",
|
||||
sessionId: "",
|
||||
wsUrl: "",
|
||||
connectPhase: undefined,
|
||||
errorMessage: webcrtErrorMessage(errMsg, t),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pretty = webcrtErrorMessage(errMsg, t);
|
||||
const termText = termRefs.current.get(tab.key)?.getText() || "";
|
||||
const buf = (logBuffersRef.current.get(tab.key) || []).join("");
|
||||
const base = (buf || termText).replace(/\s+$/g, "");
|
||||
const failLine =
|
||||
isDeviceLoginFailureMessage(errMsg) || /connect_failed/i.test(String(message || ""))
|
||||
? formatConnectFailureForTerm(String(message || errMsg))
|
||||
: `\r\n[netx] ${pretty}\r\n`;
|
||||
const frozenOut =
|
||||
base.includes("[netx] connect failed") || (pretty && base.includes(pretty))
|
||||
? `${base}\r\n`
|
||||
: `${base}${failLine}`;
|
||||
updateTab(tab.key, {
|
||||
status: "error",
|
||||
sessionId: "",
|
||||
wsUrl: "",
|
||||
connectPhase: undefined,
|
||||
errorMessage: webcrtErrorMessage(errMsg, t),
|
||||
errorMessage: pretty,
|
||||
frozenOutput: frozenOut,
|
||||
termEpoch: tab.termEpoch + 1,
|
||||
});
|
||||
logBuffersRef.current.set(tab.key, [frozenOut]);
|
||||
if (sid) {
|
||||
void closeWebcrtSession(sid).catch(() => undefined);
|
||||
}
|
||||
if (isInventorySsh(tab.target) && isSshAuthFailure(errMsg)) {
|
||||
openAuthForTarget(tab.target, webcrtErrorMessage(errMsg, t));
|
||||
openAuthForTarget(tab.target, pretty);
|
||||
}
|
||||
} else if (state === "closed") {
|
||||
const msg = String(message || "");
|
||||
// No silent remount / auto re-login — show banner; user clicks Reconnect.
|
||||
const deviceGone = isDeviceClosedMessage(msg);
|
||||
const termText = termRefs.current.get(tab.key)?.getText() || "";
|
||||
const buf = (logBuffersRef.current.get(tab.key) || []).join("");
|
||||
const base = (buf || termText).replace(/\s+$/g, "");
|
||||
const note = msg
|
||||
? `\r\n[netx] session closed: ${msg}\r\n`
|
||||
: "\r\n[netx] session closed\r\n";
|
||||
const frozenOut = base ? `${base}${note}` : note;
|
||||
// Keep sessionId when soft-closed so toolbar Reconnect can reattach;
|
||||
// frozen mount still omits WS (sessionId passed as undefined when frozen).
|
||||
updateTab(tab.key, {
|
||||
status: "closed",
|
||||
sessionId: deviceGone ? "" : tab.sessionId,
|
||||
wsUrl: deviceGone ? "" : tab.wsUrl,
|
||||
connectPhase: undefined,
|
||||
errorMessage: msg || t("webcrt.disconnectBanner"),
|
||||
frozenOutput: frozenOut,
|
||||
termEpoch: tab.termEpoch + 1,
|
||||
});
|
||||
logBuffersRef.current.set(tab.key, [frozenOut]);
|
||||
if (deviceGone && tab.sessionId) {
|
||||
void closeWebcrtSession(tab.sessionId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue