mirror of
https://github.com/hansjone/netx.git
synced 2026-10-19 05:33:17 +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:
|
if early:
|
||||||
sess.push_connect_echo(early)
|
sess.push_connect_echo(early)
|
||||||
seed = f"{pre_log}{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 = ""
|
primed = ""
|
||||||
# Auto-answer Huawei post-login password-change (Netmiko would have sent N).
|
# Auto-answer Huawei post-login password-change only if still sitting on the prompt
|
||||||
if _looks_like_password_change_prompt(seed):
|
# (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:
|
try:
|
||||||
conn.write_channel("N" + (getattr(conn, "RETURN", None) or "\n"))
|
conn.write_channel("N" + (getattr(conn, "RETURN", None) or "\n"))
|
||||||
sess.push_connect_echo("\r\n[netx] password-change → N\r\n")
|
sess.push_connect_echo("\r\n[netx] password-change → N\r\n")
|
||||||
|
|
@ -313,7 +314,7 @@ def _finish_connect(
|
||||||
except Exception:
|
except Exception:
|
||||||
primed = ""
|
primed = ""
|
||||||
combined = f"{seed}{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:
|
try:
|
||||||
more = _capture_raw_channel(conn, duration=0.35)
|
more = _capture_raw_channel(conn, duration=0.35)
|
||||||
if more:
|
if more:
|
||||||
|
|
@ -328,8 +329,13 @@ def _finish_connect(
|
||||||
sess.push_connect_echo(combined)
|
sess.push_connect_echo(combined)
|
||||||
except Exception:
|
except Exception:
|
||||||
combined = ""
|
combined = ""
|
||||||
|
# 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()
|
||||||
|
bootstrap = ""
|
||||||
|
if not echoed.strip():
|
||||||
bootstrap = prepare_bootstrap_output(combined)
|
bootstrap = prepare_bootstrap_output(combined)
|
||||||
# Discard lone punctuation left on the wire (would glue onto ``<r1>`` in xterm).
|
|
||||||
try:
|
try:
|
||||||
leftover = _capture_raw_channel(conn, duration=0.12)
|
leftover = _capture_raw_channel(conn, duration=0.12)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -337,18 +343,14 @@ def _finish_connect(
|
||||||
if leftover and leftover.strip() not in {":", ">", "#", "]", "$"}:
|
if leftover and leftover.strip() not in {":", ">", "#", "]", "$"}:
|
||||||
sess.push_connect_echo(leftover)
|
sess.push_connect_echo(leftover)
|
||||||
bootstrap = prepare_bootstrap_output(f"{bootstrap}{leftover}")
|
bootstrap = prepare_bootstrap_output(f"{bootstrap}{leftover}")
|
||||||
|
else:
|
||||||
# Prefer live echo already shown on the terminal; only bootstrap the delta.
|
# Optional unseen tail only (already pushed to connect-echo above when present).
|
||||||
echoed = sess.connect_echo_text()
|
try:
|
||||||
if echoed and bootstrap:
|
leftover = _capture_raw_channel(conn, duration=0.12)
|
||||||
# If bootstrap is fully covered by live echo, skip replaying it.
|
except Exception:
|
||||||
norm_boot = bootstrap.replace("\r\n", "\n").strip()
|
leftover = ""
|
||||||
norm_echo = echoed.replace("\r\n", "\n")
|
if leftover and leftover.strip() not in {":", ">", "#", "]", "$"}:
|
||||||
if norm_boot and norm_boot in norm_echo:
|
sess.push_connect_echo(leftover)
|
||||||
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
|
|
||||||
|
|
||||||
hop_guard = get_cli_hop_guard(conn)
|
hop_guard = get_cli_hop_guard(conn)
|
||||||
sess.conn = conn
|
sess.conn = conn
|
||||||
|
|
@ -357,7 +359,7 @@ def _finish_connect(
|
||||||
sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding)
|
sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding)
|
||||||
# Nudge Enter on WS attach only when we still need a shell prompt.
|
# 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).
|
# 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 = (
|
sess.needs_live_prompt = (
|
||||||
not _looks_like_cli_prompt(final_view) and not _looks_like_login_prompt(final_view)
|
not _looks_like_cli_prompt(final_view) and not _looks_like_login_prompt(final_view)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ type Props = {
|
||||||
) => void;
|
) => void;
|
||||||
onReady?: () => void;
|
onReady?: () => void;
|
||||||
onStdout?: (data: string) => void;
|
onStdout?: (data: string) => void;
|
||||||
|
/** Seed text when mounting (frozen view after disconnect, or pre-WS buffer). */
|
||||||
|
initialOutput?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function serializeTerminal(term: Terminal): string {
|
function serializeTerminal(term: Terminal): string {
|
||||||
|
|
@ -177,8 +179,8 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
{
|
{
|
||||||
wsUrl,
|
wsUrl,
|
||||||
sessionId,
|
sessionId,
|
||||||
title,
|
title: _title,
|
||||||
recording,
|
recording: _recording,
|
||||||
autoFocus = true,
|
autoFocus = true,
|
||||||
encoding = "utf-8",
|
encoding = "utf-8",
|
||||||
fontSize = 13,
|
fontSize = 13,
|
||||||
|
|
@ -190,6 +192,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
onStatus,
|
onStatus,
|
||||||
onReady,
|
onReady,
|
||||||
onStdout,
|
onStdout,
|
||||||
|
initialOutput,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
|
|
@ -208,7 +211,6 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
const onStatusRef = useRef(onStatus);
|
const onStatusRef = useRef(onStatus);
|
||||||
const onReadyRef = useRef(onReady);
|
const onReadyRef = useRef(onReady);
|
||||||
const onStdoutRef = useRef(onStdout);
|
const onStdoutRef = useRef(onStdout);
|
||||||
const recordingRef = useRef(!!recording);
|
|
||||||
const autoFocusRef = useRef(autoFocus);
|
const autoFocusRef = useRef(autoFocus);
|
||||||
const encodingRef = useRef(encoding);
|
const encodingRef = useRef(encoding);
|
||||||
const fontSizeRef = useRef(Math.max(10, Math.min(28, Number(fontSize) || 13)));
|
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;
|
onStdoutRef.current = onStdout;
|
||||||
}, [onStatus, onReady, onStdout]);
|
}, [onStatus, onReady, onStdout]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
recordingRef.current = !!recording;
|
|
||||||
}, [recording]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
autoFocusRef.current = autoFocus;
|
autoFocusRef.current = autoFocus;
|
||||||
}, [autoFocus]);
|
}, [autoFocus]);
|
||||||
|
|
@ -406,10 +404,13 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
if (!host) return;
|
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({
|
const term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: live,
|
||||||
|
disableStdin: !live,
|
||||||
fontSize: fontSizeRef.current,
|
fontSize: fontSizeRef.current,
|
||||||
fontFamily: 'Consolas, "Courier New", monospace',
|
fontFamily: 'Consolas, "Courier New", monospace',
|
||||||
// Lower than CRT-style 10k: remounts + multi-tab stay lighter; server replays log tail on attach.
|
// 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);
|
}, 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
|
// Guard against React StrictMode remount: the first WS teardown must not
|
||||||
// report closed/error after a newer socket owns the terminal.
|
// report closed/error after a newer socket owns the terminal.
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -467,7 +488,8 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
const writeStdout = (raw: string) => {
|
const writeStdout = (raw: string) => {
|
||||||
if (!raw || !isActiveSocket()) return;
|
if (!raw || !isActiveSocket()) return;
|
||||||
lastStdoutAtRef.current = performance.now();
|
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;
|
writeBuf += raw;
|
||||||
if (writeBuf.length >= 16384) {
|
if (writeBuf.length >= 16384) {
|
||||||
if (writeRaf) {
|
if (writeRaf) {
|
||||||
|
|
@ -557,7 +579,23 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state === "closed" || state === "error") {
|
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(
|
term.writeln(
|
||||||
`\r\n\x1b[33m${tRef.current("webcrt.term.sessionStatus", {
|
`\r\n\x1b[33m${tRef.current("webcrt.term.sessionStatus", {
|
||||||
state,
|
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
|
// 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
|
// parent wsUrl fill-in arrives — that remount dropped the wait-loop WS and
|
||||||
// left Quick Connect stuck on "authenticating".
|
// left Quick Connect stuck on "authenticating".
|
||||||
}, [sessionId || wsUrl]);
|
}, [sessionId || wsUrl, initialOutput]);
|
||||||
|
|
||||||
const pasteFromClipboard = async () => {
|
const pasteFromClipboard = async () => {
|
||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,8 @@ type TermTab = {
|
||||||
status: "connecting" | "connected" | "error" | "closed";
|
status: "connecting" | "connected" | "error" | "closed";
|
||||||
connectPhase?: ConnectPhase;
|
connectPhase?: ConnectPhase;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
/** Frozen CRT transcript after disconnect/login failure (no overlay). */
|
||||||
|
frozenOutput?: string;
|
||||||
recording: boolean;
|
recording: boolean;
|
||||||
encoding: string;
|
encoding: string;
|
||||||
/** From session create — nested CLI hop cannot use SFTP. */
|
/** 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 };
|
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 {
|
function defaultSessionOptions(): SessionOptions {
|
||||||
return {
|
return {
|
||||||
encoding: "utf-8",
|
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_conflict_restart_required")) return t("webcrt.err.ipConflictRestart");
|
||||||
if (raw.includes("ip_address_required")) return t("webcrt.newSession.ipRequired");
|
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("cli_connect_profile_not_configured")) return t("webcrt.err.cliProfile");
|
||||||
if (raw.includes("connect_failed")) {
|
if (raw.includes("connect_failed") || /NetmikoTimeoutException|TCP connection to device failed/i.test(raw)) {
|
||||||
const detail = raw.replace(/^.*connect_failed:/, "").split("\n---")[0].trim();
|
const detail = raw.replace(/^.*connect_failed:/i, "").split("\n---")[0].trim();
|
||||||
return t("webcrt.err.connectFailed", { detail: detail.slice(0, 180) });
|
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_hop_not_supported")) return t("webcrt.err.sftpHop");
|
||||||
if (raw.includes("sftp_requires_ssh")) return t("webcrt.err.sftpSsh");
|
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;
|
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 {
|
function targetFromManagedNe(ne: ManagedNeItem, fallback?: Partial<CliTargetItem>): CliTargetItem {
|
||||||
return {
|
return {
|
||||||
source: "webcrt",
|
source: "webcrt",
|
||||||
|
|
@ -1009,6 +1024,7 @@ export function WebcrtPage() {
|
||||||
recording: prior?.recording || existing?.recording || false,
|
recording: prior?.recording || existing?.recording || false,
|
||||||
encoding,
|
encoding,
|
||||||
errorMessage: undefined,
|
errorMessage: undefined,
|
||||||
|
frozenOutput: undefined,
|
||||||
};
|
};
|
||||||
setTabs((prev) => {
|
setTabs((prev) => {
|
||||||
const without = prev.filter((x) => x.key !== key);
|
const without = prev.filter((x) => x.key !== key);
|
||||||
|
|
@ -1046,9 +1062,12 @@ export function WebcrtPage() {
|
||||||
status: "connecting",
|
status: "connecting",
|
||||||
connectPhase: "authenticating",
|
connectPhase: "authenticating",
|
||||||
termEpoch: pending.termEpoch + 1,
|
termEpoch: pending.termEpoch + 1,
|
||||||
|
frozenOutput: undefined,
|
||||||
|
errorMessage: undefined,
|
||||||
cliHop: Boolean(sess.cli_hop),
|
cliHop: Boolean(sess.cli_hop),
|
||||||
sftpReady: typeof sess.sftp_ready === "boolean" ? sess.sftp_ready : undefined,
|
sftpReady: typeof sess.sftp_ready === "boolean" ? sess.sftp_ready : undefined,
|
||||||
});
|
});
|
||||||
|
logBuffersRef.current.set(key, []);
|
||||||
showOk(t("webcrt.opened", { name: deviceLabel(target) }));
|
showOk(t("webcrt.opened", { name: deviceLabel(target) }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = webcrtErrorMessage(err, t);
|
const message = webcrtErrorMessage(err, t);
|
||||||
|
|
@ -1057,10 +1076,21 @@ export function WebcrtPage() {
|
||||||
(String(err).includes("credentials_incomplete") ||
|
(String(err).includes("credentials_incomplete") ||
|
||||||
String(err).includes("connect_failed") ||
|
String(err).includes("connect_failed") ||
|
||||||
isSshAuthFailure(err));
|
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) {
|
if (needAuth) {
|
||||||
openAuthForTarget(target, message);
|
openAuthForTarget(target, message);
|
||||||
} else {
|
} else if (!isDeviceLoginFailureMessage(message) && !isDeviceLoginFailureMessage(String(err))) {
|
||||||
|
// Non-device errors (session limit, etc.) may still toast.
|
||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1080,6 +1110,7 @@ export function WebcrtPage() {
|
||||||
connectPhase: "authenticating",
|
connectPhase: "authenticating",
|
||||||
termEpoch: tab.termEpoch + 1,
|
termEpoch: tab.termEpoch + 1,
|
||||||
errorMessage: undefined,
|
errorMessage: undefined,
|
||||||
|
frozenOutput: undefined,
|
||||||
});
|
});
|
||||||
setActiveTabKey(tab.key);
|
setActiveTabKey(tab.key);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -2241,9 +2272,6 @@ export function WebcrtPage() {
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{deviceLabel(tab.target)}
|
{deviceLabel(tab.target)}
|
||||||
{tab.status === "connecting"
|
|
||||||
? ` (${connectPhaseLabel(tab.connectPhase, t)})`
|
|
||||||
: ""}
|
|
||||||
{tab.status === "closed" ? ` (${t("webcrt.status.closed")})` : ""}
|
{tab.status === "closed" ? ` (${t("webcrt.status.closed")})` : ""}
|
||||||
{tab.status === "error" ? ` (${t("webcrt.status.error")})` : ""}
|
{tab.status === "error" ? ` (${t("webcrt.status.error")})` : ""}
|
||||||
<span
|
<span
|
||||||
|
|
@ -2332,13 +2360,12 @@ export function WebcrtPage() {
|
||||||
<div className="webcrt-main__terms">
|
<div className="webcrt-main__terms">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = activeTabKey === tab.key;
|
const isActive = activeTabKey === tab.key;
|
||||||
// Cap concurrent xterm+WS: active always mounts; keep recent tabs warm.
|
const frozen = Boolean(tab.frozenOutput);
|
||||||
// Do not mount while disconnected — reconnect is manual (no silent remount).
|
// Live session, or frozen CRT transcript after login failure / disconnect.
|
||||||
const mountTerminal = Boolean(
|
const mountTerminal = Boolean(
|
||||||
tab.sessionId &&
|
(isActive || warmTabKeys.has(tab.key)) &&
|
||||||
tab.status !== "closed" &&
|
((tab.sessionId && tab.status !== "closed" && tab.status !== "error") ||
|
||||||
tab.status !== "error" &&
|
(frozen && (tab.status === "error" || tab.status === "closed"))),
|
||||||
(isActive || warmTabKeys.has(tab.key)),
|
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -2346,48 +2373,9 @@ export function WebcrtPage() {
|
||||||
className="webcrt-main__pane"
|
className="webcrt-main__pane"
|
||||||
hidden={!isActive}
|
hidden={!isActive}
|
||||||
>
|
>
|
||||||
{tab.status === "connecting" && !tab.sessionId ? (
|
{tab.status === "connecting" && !tab.sessionId && !frozen ? (
|
||||||
<div className="webcrt-main__placeholder">
|
<div className="webcrt-main__placeholder">
|
||||||
<div>{connectPhaseLabel(tab.connectPhase, t)}…</div>
|
<div>{t("webcrt.status.connecting")}…</div>
|
||||||
<p className="panel__hint">{t("webcrt.phase.hint")}</p>
|
|
||||||
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{mountTerminal ? (
|
{mountTerminal ? (
|
||||||
|
|
@ -2404,8 +2392,9 @@ export function WebcrtPage() {
|
||||||
if (handle) termRefs.current.set(tab.key, handle);
|
if (handle) termRefs.current.set(tab.key, handle);
|
||||||
else termRefs.current.delete(tab.key);
|
else termRefs.current.delete(tab.key);
|
||||||
}}
|
}}
|
||||||
sessionId={tab.sessionId || undefined}
|
sessionId={frozen ? undefined : tab.sessionId || undefined}
|
||||||
wsUrl={tab.wsUrl || undefined}
|
wsUrl={frozen ? undefined : tab.wsUrl || undefined}
|
||||||
|
initialOutput={frozen ? tab.frozenOutput : undefined}
|
||||||
title={deviceLabel(tab.target)}
|
title={deviceLabel(tab.target)}
|
||||||
recording={tab.recording}
|
recording={tab.recording}
|
||||||
encoding={tab.encoding}
|
encoding={tab.encoding}
|
||||||
|
|
@ -2443,6 +2432,7 @@ export function WebcrtPage() {
|
||||||
status: "connected",
|
status: "connected",
|
||||||
connectPhase: undefined,
|
connectPhase: undefined,
|
||||||
errorMessage: undefined,
|
errorMessage: undefined,
|
||||||
|
frozenOutput: undefined,
|
||||||
...(typeof meta?.sftpReady === "boolean" ? { sftpReady: meta.sftpReady } : {}),
|
...(typeof meta?.sftpReady === "boolean" ? { sftpReady: meta.sftpReady } : {}),
|
||||||
...(typeof meta?.cliHop === "boolean" ? { cliHop: meta.cliHop } : {}),
|
...(typeof meta?.cliHop === "boolean" ? { cliHop: meta.cliHop } : {}),
|
||||||
});
|
});
|
||||||
|
|
@ -2475,48 +2465,64 @@ export function WebcrtPage() {
|
||||||
} else if (state === "error") {
|
} else if (state === "error") {
|
||||||
const errMsg = message || t("webcrt.disconnectBannerError");
|
const errMsg = message || t("webcrt.disconnectBannerError");
|
||||||
const sid = tab.sessionId;
|
const sid = tab.sessionId;
|
||||||
// Session gone / auth failure: stop here — user must click Reconnect.
|
const pretty = webcrtErrorMessage(errMsg, t);
|
||||||
if (sid && isSessionGoneError(errMsg)) {
|
const termText = termRefs.current.get(tab.key)?.getText() || "";
|
||||||
updateTab(tab.key, {
|
const buf = (logBuffersRef.current.get(tab.key) || []).join("");
|
||||||
status: "closed",
|
const base = (buf || termText).replace(/\s+$/g, "");
|
||||||
sessionId: "",
|
const failLine =
|
||||||
wsUrl: "",
|
isDeviceLoginFailureMessage(errMsg) || /connect_failed/i.test(String(message || ""))
|
||||||
connectPhase: undefined,
|
? formatConnectFailureForTerm(String(message || errMsg))
|
||||||
errorMessage: webcrtErrorMessage(errMsg, t),
|
: `\r\n[netx] ${pretty}\r\n`;
|
||||||
});
|
const frozenOut =
|
||||||
return;
|
base.includes("[netx] connect failed") || (pretty && base.includes(pretty))
|
||||||
}
|
? `${base}\r\n`
|
||||||
|
: `${base}${failLine}`;
|
||||||
updateTab(tab.key, {
|
updateTab(tab.key, {
|
||||||
status: "error",
|
status: "error",
|
||||||
sessionId: "",
|
sessionId: "",
|
||||||
wsUrl: "",
|
wsUrl: "",
|
||||||
connectPhase: undefined,
|
connectPhase: undefined,
|
||||||
errorMessage: webcrtErrorMessage(errMsg, t),
|
errorMessage: pretty,
|
||||||
|
frozenOutput: frozenOut,
|
||||||
|
termEpoch: tab.termEpoch + 1,
|
||||||
});
|
});
|
||||||
|
logBuffersRef.current.set(tab.key, [frozenOut]);
|
||||||
if (sid) {
|
if (sid) {
|
||||||
void closeWebcrtSession(sid).catch(() => undefined);
|
void closeWebcrtSession(sid).catch(() => undefined);
|
||||||
}
|
}
|
||||||
if (isInventorySsh(tab.target) && isSshAuthFailure(errMsg)) {
|
if (isInventorySsh(tab.target) && isSshAuthFailure(errMsg)) {
|
||||||
openAuthForTarget(tab.target, webcrtErrorMessage(errMsg, t));
|
openAuthForTarget(tab.target, pretty);
|
||||||
}
|
}
|
||||||
} else if (state === "closed") {
|
} else if (state === "closed") {
|
||||||
const msg = String(message || "");
|
const msg = String(message || "");
|
||||||
// No silent remount / auto re-login — show banner; user clicks Reconnect.
|
|
||||||
const deviceGone = isDeviceClosedMessage(msg);
|
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, {
|
updateTab(tab.key, {
|
||||||
status: "closed",
|
status: "closed",
|
||||||
sessionId: deviceGone ? "" : tab.sessionId,
|
sessionId: deviceGone ? "" : tab.sessionId,
|
||||||
wsUrl: deviceGone ? "" : tab.wsUrl,
|
wsUrl: deviceGone ? "" : tab.wsUrl,
|
||||||
connectPhase: undefined,
|
connectPhase: undefined,
|
||||||
errorMessage: msg || t("webcrt.disconnectBanner"),
|
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>
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue