mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 06:40:45 +08:00
Harden WebCRT reconnect, resource use, and SFTP UX.
Reuse detached sessions on reconnect, mount only the active tab, surface backpressure drops, gate SFTP for unsupported targets, and polish attach replay, find/recording performance, and terminal i18n. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f6b399e03e
commit
d87bdf4be1
13 changed files with 648 additions and 151 deletions
|
|
@ -2,6 +2,7 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "re
|
|||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useI18n } from "../i18n";
|
||||
import {
|
||||
applyKeywordHighlight,
|
||||
type KeywordHighlightConfig,
|
||||
|
|
@ -84,6 +85,28 @@ function serializeTerminal(term: Terminal): string {
|
|||
return lines.join("\n").replace(/\s+$/g, "");
|
||||
}
|
||||
|
||||
/** Line-wise search — avoids serializing the full 10k scrollback on every Find. */
|
||||
function findBufferLine(
|
||||
term: Terminal,
|
||||
query: string,
|
||||
startLine: number,
|
||||
direction: 1 | -1,
|
||||
): number {
|
||||
const needle = String(query || "").toLowerCase();
|
||||
if (!needle) return -1;
|
||||
const buf = term.buffer.active;
|
||||
const len = buf.length;
|
||||
if (len <= 0) return -1;
|
||||
const start = ((Math.trunc(startLine) % len) + len) % len;
|
||||
for (let step = 0; step < len; step += 1) {
|
||||
const i = direction > 0 ? (start + step) % len : (start - step + len) % len;
|
||||
const line = buf.getLine(i);
|
||||
if (!line) continue;
|
||||
if (line.translateToString(true).toLowerCase().includes(needle)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function isSidebarSearchTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") {
|
||||
|
|
@ -159,6 +182,9 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useI18n();
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const resolvedColors: TermColors = termColors ||
|
||||
(themeName === "light"
|
||||
? { background: "#ffffff", foreground: "#000000" }
|
||||
|
|
@ -167,7 +193,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const findIndexRef = useRef(0);
|
||||
const findLineRef = useRef(-1);
|
||||
const onStatusRef = useRef(onStatus);
|
||||
const onReadyRef = useRef(onReady);
|
||||
const onStdoutRef = useRef(onStdout);
|
||||
|
|
@ -346,29 +372,19 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
findNext: (q: string) => {
|
||||
const term = termRef.current;
|
||||
if (!term || !q) return;
|
||||
const text = serializeTerminal(term).toLowerCase();
|
||||
const needle = q.toLowerCase();
|
||||
let idx = text.indexOf(needle, findIndexRef.current + 1);
|
||||
if (idx < 0) idx = text.indexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
// Approximate scroll: each buffer line ~1 row.
|
||||
const line = text.slice(0, idx).split("\n").length - 1;
|
||||
term.scrollToLine(Math.max(0, line - 2));
|
||||
const hit = findBufferLine(term, q, findLineRef.current + 1, 1);
|
||||
if (hit >= 0) {
|
||||
findLineRef.current = hit;
|
||||
term.scrollToLine(Math.max(0, hit - 2));
|
||||
}
|
||||
},
|
||||
findPrevious: (q: string) => {
|
||||
const term = termRef.current;
|
||||
if (!term || !q) return;
|
||||
const text = serializeTerminal(term).toLowerCase();
|
||||
const needle = q.toLowerCase();
|
||||
const before = text.slice(0, Math.max(0, findIndexRef.current));
|
||||
let idx = before.lastIndexOf(needle);
|
||||
if (idx < 0) idx = text.lastIndexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
const line = text.slice(0, idx).split("\n").length - 1;
|
||||
term.scrollToLine(Math.max(0, line - 2));
|
||||
const hit = findBufferLine(term, q, Math.max(0, findLineRef.current) - 1, -1);
|
||||
if (hit >= 0) {
|
||||
findLineRef.current = hit;
|
||||
term.scrollToLine(Math.max(0, hit - 2));
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
|
@ -479,9 +495,24 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
maybeFocus();
|
||||
return;
|
||||
}
|
||||
if (msg.state === "warning") {
|
||||
const m = String(msg.message || "");
|
||||
const dropMatch = /^queue_dropped:(\d+)/i.exec(m);
|
||||
if (dropMatch) {
|
||||
term.writeln(
|
||||
`\r\n\x1b[33m${tRef.current("webcrt.term.outputTruncated", { count: dropMatch[1] })}\x1b[0m`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.state === "closed" || msg.state === "error") {
|
||||
const detail = msg.message ? `: ${msg.message}` : "";
|
||||
term.writeln(`\r\n\x1b[33m[session ${msg.state}${detail}]\x1b[0m`);
|
||||
term.writeln(
|
||||
`\r\n\x1b[33m${tRef.current("webcrt.term.sessionStatus", {
|
||||
state: String(msg.state || ""),
|
||||
detail,
|
||||
})}\x1b[0m`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -494,7 +525,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
ws.onerror = () => {
|
||||
if (!isActiveSocket()) return;
|
||||
onStatusRef.current?.("error", "websocket_error");
|
||||
term.writeln("\r\n\x1b[31m[websocket error]\x1b[0m");
|
||||
term.writeln(`\r\n\x1b[31m${tRef.current("webcrt.term.wsError")}\x1b[0m`);
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
|
|
@ -502,7 +533,9 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
if (!isActiveSocket()) return;
|
||||
onStatusRef.current?.("closed", `websocket_closed:${ev.code}`);
|
||||
if (!ev.wasClean) {
|
||||
term.writeln(`\r\n\x1b[33m[websocket closed code=${ev.code}]\x1b[0m`);
|
||||
term.writeln(
|
||||
`\r\n\x1b[33m${tRef.current("webcrt.term.wsClosed", { code: ev.code })}\x1b[0m`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -637,7 +670,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
<div className="webcrt-term-wrap">
|
||||
{pasteStatus ? (
|
||||
<div className="webcrt-paste-status" role="status" aria-live="polite">
|
||||
粘贴中 {pasteStatus.done}/{pasteStatus.total} 行…
|
||||
{t("webcrt.term.pasting", { done: pasteStatus.done, total: pasteStatus.total })}
|
||||
</div>
|
||||
) : null}
|
||||
{findOpen ? (
|
||||
|
|
@ -646,30 +679,26 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
ref={findInputRef}
|
||||
type="search"
|
||||
value={findQuery}
|
||||
placeholder="Find…"
|
||||
onChange={(e) => setFindQuery(e.target.value)}
|
||||
placeholder={t("webcrt.term.findPh")}
|
||||
aria-label={t("webcrt.term.findPh")}
|
||||
onChange={(e) => {
|
||||
setFindQuery(e.target.value);
|
||||
findLineRef.current = -1;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const term = termRef.current;
|
||||
if (!term || !findQuery) return;
|
||||
const text = serializeTerminal(term).toLowerCase();
|
||||
const needle = findQuery.toLowerCase();
|
||||
if (e.shiftKey) {
|
||||
const before = text.slice(0, Math.max(0, findIndexRef.current));
|
||||
let idx = before.lastIndexOf(needle);
|
||||
if (idx < 0) idx = text.lastIndexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||
}
|
||||
} else {
|
||||
let idx = text.indexOf(needle, findIndexRef.current + 1);
|
||||
if (idx < 0) idx = text.indexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||
}
|
||||
const hit = findBufferLine(
|
||||
term,
|
||||
findQuery,
|
||||
e.shiftKey ? Math.max(0, findLineRef.current) - 1 : findLineRef.current + 1,
|
||||
e.shiftKey ? -1 : 1,
|
||||
);
|
||||
if (hit >= 0) {
|
||||
findLineRef.current = hit;
|
||||
term.scrollToLine(Math.max(0, hit - 2));
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setFindOpen(false);
|
||||
|
|
@ -679,17 +708,15 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("webcrt.term.findPrev")}
|
||||
title={t("webcrt.term.findPrev")}
|
||||
onClick={() => {
|
||||
const term = termRef.current;
|
||||
if (!term || !findQuery) return;
|
||||
const text = serializeTerminal(term).toLowerCase();
|
||||
const needle = findQuery.toLowerCase();
|
||||
const before = text.slice(0, Math.max(0, findIndexRef.current));
|
||||
let idx = before.lastIndexOf(needle);
|
||||
if (idx < 0) idx = text.lastIndexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||
const hit = findBufferLine(term, findQuery, Math.max(0, findLineRef.current) - 1, -1);
|
||||
if (hit >= 0) {
|
||||
findLineRef.current = hit;
|
||||
term.scrollToLine(Math.max(0, hit - 2));
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -697,16 +724,15 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("webcrt.term.findNext")}
|
||||
title={t("webcrt.term.findNext")}
|
||||
onClick={() => {
|
||||
const term = termRef.current;
|
||||
if (!term || !findQuery) return;
|
||||
const text = serializeTerminal(term).toLowerCase();
|
||||
const needle = findQuery.toLowerCase();
|
||||
let idx = text.indexOf(needle, findIndexRef.current + 1);
|
||||
if (idx < 0) idx = text.indexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||
const hit = findBufferLine(term, findQuery, findLineRef.current + 1, 1);
|
||||
if (hit >= 0) {
|
||||
findLineRef.current = hit;
|
||||
term.scrollToLine(Math.max(0, hit - 2));
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -714,6 +740,8 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("webcrt.term.findClose")}
|
||||
title={t("webcrt.term.findClose")}
|
||||
onClick={() => {
|
||||
setFindOpen(false);
|
||||
focusTerminal();
|
||||
|
|
@ -738,10 +766,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button type="button" onClick={() => void copySelection()}>
|
||||
Copy
|
||||
{t("webcrt.term.copy")}
|
||||
</button>
|
||||
<button type="button" onClick={() => void pasteFromClipboard()}>
|
||||
Paste
|
||||
{t("webcrt.term.paste")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -751,7 +779,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
{t("webcrt.term.clear")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1040,9 +1040,26 @@ const en = {
|
|||
postLoginPrompt: "Post-login commands (one per line, this device)",
|
||||
postLoginSaved: "Post-login commands saved",
|
||||
sftp: {
|
||||
title: "SFTP",
|
||||
refresh: "Refresh",
|
||||
up: "Up",
|
||||
upload: "Upload",
|
||||
uploaded: "Upload complete",
|
||||
dir: "dir",
|
||||
},
|
||||
term: {
|
||||
findPh: "Find…",
|
||||
findPrev: "Previous",
|
||||
findNext: "Next",
|
||||
findClose: "Close find",
|
||||
copy: "Copy",
|
||||
paste: "Paste",
|
||||
clear: "Clear",
|
||||
pasting: "Pasting {{done}}/{{total}} lines…",
|
||||
wsError: "[WebSocket error]",
|
||||
wsClosed: "[WebSocket closed code={{code}}]",
|
||||
sessionStatus: "[session {{state}}{{detail}}]",
|
||||
outputTruncated: "[output truncated: {{count}} chunk(s) dropped]",
|
||||
},
|
||||
err: {
|
||||
deviceType: "Unsupported device type",
|
||||
|
|
@ -1055,6 +1072,9 @@ const en = {
|
|||
connectFailed: "Device login failed: {{detail}}",
|
||||
sftpHop: "SFTP does not support hop devices yet (direct SSH only)",
|
||||
sftpSsh: "SFTP requires SSH",
|
||||
sftpNeedPassword: "SFTP needs a saved SSH password (one-shot dialog passwords are not reused for files)",
|
||||
queueDropped: "Terminal output too fast; about {{count}} chunk(s) dropped — transcript may be incomplete",
|
||||
websocket: "WebSocket connection failed",
|
||||
},
|
||||
},
|
||||
topology: {
|
||||
|
|
|
|||
|
|
@ -1037,9 +1037,26 @@ const zh = {
|
|||
postLoginPrompt: "登录后自动执行(每行一条,仅本设备)",
|
||||
postLoginSaved: "登录后命令已保存",
|
||||
sftp: {
|
||||
title: "SFTP",
|
||||
refresh: "刷新",
|
||||
up: "上级",
|
||||
upload: "上传",
|
||||
uploaded: "上传成功",
|
||||
dir: "目录",
|
||||
},
|
||||
term: {
|
||||
findPh: "查找…",
|
||||
findPrev: "上一个",
|
||||
findNext: "下一个",
|
||||
findClose: "关闭查找",
|
||||
copy: "复制",
|
||||
paste: "粘贴",
|
||||
clear: "清空",
|
||||
pasting: "粘贴中 {{done}}/{{total}} 行…",
|
||||
wsError: "[WebSocket 错误]",
|
||||
wsClosed: "[WebSocket 已关闭 code={{code}}]",
|
||||
sessionStatus: "[会话 {{state}}{{detail}}]",
|
||||
outputTruncated: "[输出过快,已丢弃 {{count}} 段]",
|
||||
},
|
||||
err: {
|
||||
deviceType: "不支持的设备类型",
|
||||
|
|
@ -1052,6 +1069,9 @@ const zh = {
|
|||
connectFailed: "登录设备失败:{{detail}}",
|
||||
sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH",
|
||||
sftpSsh: "SFTP 仅支持 SSH 协议",
|
||||
sftpNeedPassword: "SFTP 需要已保存的 SSH 密码(会话弹窗密码不会用于文件传输)",
|
||||
queueDropped: "终端输出过快,已丢弃约 {{count}} 段,内容可能不完整",
|
||||
websocket: "WebSocket 连接失败",
|
||||
},
|
||||
},
|
||||
topology: {
|
||||
|
|
|
|||
|
|
@ -1907,10 +1907,10 @@ pre {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #b8c4d2;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--nm-brand);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
|
|
@ -1920,16 +1920,17 @@ pre {
|
|||
}
|
||||
|
||||
.webcrt-sidebar__new-btn:hover {
|
||||
background: #e8f0fe;
|
||||
border-color: #9eb0c6;
|
||||
background: transparent;
|
||||
color: var(--nm-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.app-main .webcrt-sidebar__new-btn {
|
||||
border: 1px solid #b8c4d2;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.webcrt-menubar__item {
|
||||
|
|
@ -1941,9 +1942,9 @@ pre {
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
|
|
@ -1955,15 +1956,17 @@ pre {
|
|||
|
||||
.webcrt-menubar__btn:hover,
|
||||
.webcrt-menubar__btn.is-open {
|
||||
background: #dbe4f0;
|
||||
background: transparent;
|
||||
color: var(--nm-brand);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.app-main .webcrt-menubar__btn {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.webcrt-menubar__menu {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ const ENCODING_OPTIONS = ["utf-8", "gbk", "gb2312", "gb18030"] as const;
|
|||
const FONT_SIZE_OPTIONS = [12, 13, 14, 16, 18, 20] as const;
|
||||
const PASTE_DELAY_OPTIONS = [0, 20, 40, 60, 100, 150] as const;
|
||||
const KEEPALIVE_OPTIONS = [0, 15, 30, 60, 120] as const;
|
||||
/** Compact recording chunks before they grow unbounded (join + trim). */
|
||||
const LOG_COMPACT_CHUNKS = 1500;
|
||||
const LOG_MAX_CHARS = 8 * 1024 * 1024;
|
||||
|
||||
type ColorSchemeId = "dark" | "blackWhite" | "whiteBlack" | "greenBlack" | "amberBlack" | "custom";
|
||||
|
||||
|
|
@ -109,6 +112,8 @@ type TermTab = {
|
|||
errorMessage?: string;
|
||||
recording: boolean;
|
||||
encoding: string;
|
||||
/** From session create — nested CLI hop cannot use SFTP. */
|
||||
cliHop?: boolean;
|
||||
};
|
||||
|
||||
type TabMenuState = { key: string; x: number; y: number };
|
||||
|
|
@ -282,6 +287,58 @@ function isSshAuthFailure(err: unknown): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
/** Inventory SSH (managed / webcrt Quick Connect). UME uses shared CLI profile — no per-NE popup. */
|
||||
function isInventorySsh(target: Pick<CliTargetItem, "source" | "protocol">): boolean {
|
||||
const src = String(target.source || "").toLowerCase();
|
||||
if (src !== "webcrt" && src !== "managed") return false;
|
||||
return String(target.protocol || "ssh").toLowerCase() !== "telnet";
|
||||
}
|
||||
|
||||
function isSessionGoneError(err: unknown): boolean {
|
||||
const raw = String(err).toLowerCase();
|
||||
return (
|
||||
raw.includes("webcrt_session_not_found") ||
|
||||
raw.includes("session_not_found") ||
|
||||
raw.includes("4404")
|
||||
);
|
||||
}
|
||||
|
||||
function isDeviceClosedMessage(err: unknown): boolean {
|
||||
const raw = String(err).toLowerCase();
|
||||
return (
|
||||
raw.includes("device_closed") ||
|
||||
raw.includes("client_close") ||
|
||||
raw.includes("client_delete") ||
|
||||
raw.includes("cli_hop_return") ||
|
||||
raw.includes("idle_timeout") ||
|
||||
raw.includes("detach_timeout") ||
|
||||
raw.includes("attach_timeout") ||
|
||||
raw.includes("session closed")
|
||||
);
|
||||
}
|
||||
|
||||
/** SFTP is direct SSH only; returns i18n key when unavailable. */
|
||||
function sftpUnavailableReason(
|
||||
tab: Pick<TermTab, "target" | "cliHop">,
|
||||
): "webcrt.err.sftpSsh" | "webcrt.err.sftpHop" | "webcrt.err.sftpNeedPassword" | null {
|
||||
const proto = String(tab.target.protocol || "ssh").toLowerCase();
|
||||
if (proto === "telnet") return "webcrt.err.sftpSsh";
|
||||
if (tab.cliHop || tab.target.hop_enabled) return "webcrt.err.sftpHop";
|
||||
// Inventory/quick-connect SFTP opens a fresh Paramiko session from saved DB creds.
|
||||
if (tab.target.source !== "ume" && !tab.target.has_password) return "webcrt.err.sftpNeedPassword";
|
||||
return null;
|
||||
}
|
||||
|
||||
function sftpParentPath(path: string): string {
|
||||
const cur = String(path || ".").replace(/\\/g, "/").replace(/\/+$/, "") || ".";
|
||||
if (cur === "." || cur === "/") return cur === "/" ? "/" : ".";
|
||||
const parts = cur.split("/").filter((p, i) => p || i === 0);
|
||||
if (parts.length <= 1) return cur.startsWith("/") ? "/" : ".";
|
||||
parts.pop();
|
||||
const parent = parts.join("/") || (cur.startsWith("/") ? "/" : ".");
|
||||
return parent || ".";
|
||||
}
|
||||
|
||||
function connectFailedNe(err: unknown): ManagedNeItem | null {
|
||||
if (!(err instanceof ApiRequestError)) return null;
|
||||
const detail = err.detail;
|
||||
|
|
@ -310,6 +367,7 @@ function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record<string,
|
|||
}
|
||||
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("websocket_error")) return t("webcrt.err.websocket");
|
||||
return raw;
|
||||
}
|
||||
|
||||
|
|
@ -380,7 +438,9 @@ export function WebcrtPage() {
|
|||
const tabsRef = useRef<TermTab[]>([]);
|
||||
tabsRef.current = tabs;
|
||||
const termRefs = useRef<Map<string, WebTerminalHandle>>(new Map());
|
||||
const logBuffersRef = useRef<Map<string, string>>(new Map());
|
||||
/** Chunk lists avoid O(n²) string append while recording. */
|
||||
const logBuffersRef = useRef<Map<string, string[]>>(new Map());
|
||||
const lastQueueDropToastAtRef = useRef(0);
|
||||
const optionsMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const tabMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const treeMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -467,7 +527,7 @@ export function WebcrtPage() {
|
|||
}, []);
|
||||
|
||||
const attachSessionResult = useCallback(
|
||||
(target: CliTargetItem, sessionId: string, encoding: string) => {
|
||||
(target: CliTargetItem, sessionId: string, encoding: string, cliHop?: boolean) => {
|
||||
const key = targetKey(target);
|
||||
const existing = tabsRef.current.find((tab) => tab.key === key);
|
||||
const pending: TermTab = {
|
||||
|
|
@ -481,6 +541,7 @@ export function WebcrtPage() {
|
|||
recording: existing?.recording || false,
|
||||
encoding,
|
||||
errorMessage: undefined,
|
||||
cliHop: Boolean(cliHop),
|
||||
};
|
||||
setTabs((prev) => {
|
||||
const without = prev.filter((x) => x.key !== key);
|
||||
|
|
@ -520,19 +581,19 @@ export function WebcrtPage() {
|
|||
}
|
||||
if (connectingKeysRef.current.has(key)) return;
|
||||
|
||||
// WebCRT SSH without saved password → credential popup.
|
||||
const isWebcrtSsh =
|
||||
target.source === "webcrt" && String(target.protocol || "ssh").toLowerCase() !== "telnet";
|
||||
if (isWebcrtSsh && !target.has_password && !opts?.force) {
|
||||
// Managed / WebCRT SSH without saved password → credential popup (UME uses shared profile).
|
||||
if (isInventorySsh(target) && !target.has_password && !opts?.force) {
|
||||
openAuthForTarget(target);
|
||||
return;
|
||||
}
|
||||
|
||||
connectingKeysRef.current.add(key);
|
||||
|
||||
if (opts?.force && existing?.sessionId) {
|
||||
// force = brand-new device session; drop any prior PTY first.
|
||||
const prior = tabsRef.current.find((tab) => tab.key === key);
|
||||
if (opts?.force && prior?.sessionId) {
|
||||
try {
|
||||
await closeWebcrtSession(existing.sessionId);
|
||||
await closeWebcrtSession(prior.sessionId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
|
@ -545,11 +606,11 @@ export function WebcrtPage() {
|
|||
key,
|
||||
sessionId: "",
|
||||
wsUrl: "",
|
||||
termEpoch: (existing?.termEpoch || 0) + (opts?.force ? 1 : 0),
|
||||
termEpoch: (prior?.termEpoch || existing?.termEpoch || 0) + (opts?.force ? 1 : 0),
|
||||
target,
|
||||
status: "connecting",
|
||||
connectPhase: "creating",
|
||||
recording: existing?.recording || false,
|
||||
recording: prior?.recording || existing?.recording || false,
|
||||
encoding,
|
||||
errorMessage: undefined,
|
||||
};
|
||||
|
|
@ -588,13 +649,13 @@ export function WebcrtPage() {
|
|||
status: "connecting",
|
||||
connectPhase: "authenticating",
|
||||
termEpoch: pending.termEpoch + 1,
|
||||
cliHop: Boolean(sess.cli_hop),
|
||||
});
|
||||
showOk(t("webcrt.opened", { name: deviceLabel(target) }));
|
||||
} catch (err) {
|
||||
const message = webcrtErrorMessage(err, t);
|
||||
const needAuth =
|
||||
target.source === "webcrt" &&
|
||||
String(target.protocol || "ssh").toLowerCase() !== "telnet" &&
|
||||
isInventorySsh(target) &&
|
||||
(String(err).includes("credentials_incomplete") ||
|
||||
String(err).includes("connect_failed") ||
|
||||
isSshAuthFailure(err));
|
||||
|
|
@ -611,6 +672,23 @@ export function WebcrtPage() {
|
|||
[openAuthForTarget, showOk, showError, t, updateTab],
|
||||
);
|
||||
|
||||
/** Re-open WS to an existing backend session (within detach grace). */
|
||||
const reattachTab = useCallback(
|
||||
(tab: TermTab) => {
|
||||
if (!tab.sessionId) return false;
|
||||
updateTab(tab.key, {
|
||||
status: "connecting",
|
||||
connectPhase: "authenticating",
|
||||
termEpoch: tab.termEpoch + 1,
|
||||
wsUrl: webcrtWsUrl(tab.sessionId),
|
||||
errorMessage: undefined,
|
||||
});
|
||||
setActiveTabKey(tab.key);
|
||||
return true;
|
||||
},
|
||||
[updateTab],
|
||||
);
|
||||
|
||||
const openTargetRef = useRef(openTarget);
|
||||
openTargetRef.current = openTarget;
|
||||
|
||||
|
|
@ -667,7 +745,7 @@ export function WebcrtPage() {
|
|||
connect_status: result.ne.connect_status || "unknown",
|
||||
cli_profile_ready: true,
|
||||
};
|
||||
attachSessionResult(target, result.session_id, dims.encoding);
|
||||
attachSessionResult(target, result.session_id, dims.encoding, Boolean(result.cli_hop));
|
||||
setHostDialogOpen(false);
|
||||
setHostForm(emptyHostForm());
|
||||
setSource(listSource === "managed" ? "managed" : "webcrt");
|
||||
|
|
@ -703,7 +781,8 @@ export function WebcrtPage() {
|
|||
try {
|
||||
if (authDialog.mode === "retry" && authDialog.target) {
|
||||
const existing = authDialog.target;
|
||||
if (authForm.savePassword && existing.source === "webcrt") {
|
||||
// Persist to ManagedNE for inventory + Quick Connect; UME never uses this dialog.
|
||||
if (authForm.savePassword && existing.source !== "ume") {
|
||||
try {
|
||||
await updateManagedNe(existing.id, { username, password: authForm.password });
|
||||
} catch {
|
||||
|
|
@ -728,6 +807,7 @@ export function WebcrtPage() {
|
|||
},
|
||||
sess.session_id,
|
||||
dims.encoding,
|
||||
Boolean(sess.cli_hop),
|
||||
);
|
||||
setAuthDialog(null);
|
||||
setAuthForm(emptyAuthForm());
|
||||
|
|
@ -762,7 +842,7 @@ export function WebcrtPage() {
|
|||
connect_status: result.ne.connect_status || "unknown",
|
||||
cli_profile_ready: true,
|
||||
};
|
||||
attachSessionResult(target, result.session_id, dims.encoding);
|
||||
attachSessionResult(target, result.session_id, dims.encoding, Boolean(result.cli_hop));
|
||||
setAuthDialog(null);
|
||||
setAuthForm(emptyAuthForm());
|
||||
setSource("webcrt");
|
||||
|
|
@ -881,11 +961,24 @@ export function WebcrtPage() {
|
|||
[showError, showOk, t],
|
||||
);
|
||||
|
||||
const reconnectTab = useCallback(
|
||||
async (tab: TermTab) => {
|
||||
// Prefer re-attach while the device PTY may still be in detach grace.
|
||||
const canReattach =
|
||||
Boolean(tab.sessionId) &&
|
||||
!isDeviceClosedMessage(tab.errorMessage) &&
|
||||
!isSshAuthFailure(tab.errorMessage);
|
||||
if (canReattach && reattachTab(tab)) return;
|
||||
await openTarget(tab.target, { force: true });
|
||||
},
|
||||
[openTarget, reattachTab],
|
||||
);
|
||||
|
||||
const reconnectActive = useCallback(async () => {
|
||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
if (!tab) return;
|
||||
await openTarget(tab.target, { force: true });
|
||||
}, [activeTabKey, openTarget]);
|
||||
await reconnectTab(tab);
|
||||
}, [activeTabKey, reconnectTab]);
|
||||
|
||||
const toggleRecording = useCallback(() => {
|
||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
|
|
@ -893,10 +986,13 @@ export function WebcrtPage() {
|
|||
const next = !tab.recording;
|
||||
if (next) {
|
||||
const seed = termRefs.current.get(tab.key)?.getText() || "";
|
||||
logBuffersRef.current.set(tab.key, seed ? `${seed}\n` : "");
|
||||
logBuffersRef.current.set(tab.key, seed ? [`${seed}\n`] : []);
|
||||
showOk(t("webcrt.actions.recordingOn"));
|
||||
} else {
|
||||
const body = logBuffersRef.current.get(tab.key) || termRefs.current.get(tab.key)?.getText() || "";
|
||||
const chunks = logBuffersRef.current.get(tab.key);
|
||||
const body = chunks?.length
|
||||
? chunks.join("")
|
||||
: termRefs.current.get(tab.key)?.getText() || "";
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const name = `${deviceLabel(tab.target) || "session"}-${stamp}.log`;
|
||||
downloadText(name, body);
|
||||
|
|
@ -997,6 +1093,12 @@ export function WebcrtPage() {
|
|||
}, [presetNeId, presetSource, showError, t, setSearchParams]);
|
||||
|
||||
const activeTab = tabs.find((x) => x.key === activeTabKey) || null;
|
||||
const sftpBlockedKey = activeTab ? sftpUnavailableReason(activeTab) : "webcrt.err.sftpSsh";
|
||||
const sftpAllowed = Boolean(activeTab && !sftpBlockedKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (sftpOpen && !sftpAllowed) setSftpOpen(false);
|
||||
}, [sftpOpen, sftpAllowed]);
|
||||
|
||||
const renameWebcrtSession = useCallback(
|
||||
async (target: CliTargetItem, nextName: string) => {
|
||||
|
|
@ -1271,21 +1373,31 @@ export function WebcrtPage() {
|
|||
<button
|
||||
type="button"
|
||||
className={`webcrt-action-btn${sftpOpen ? " is-active" : ""}`}
|
||||
disabled={!sftpAllowed}
|
||||
title={sftpAllowed ? t("webcrt.sftp.title") : t(sftpBlockedKey || "webcrt.err.sftpSsh")}
|
||||
onClick={() => {
|
||||
if (!sftpAllowed) {
|
||||
showError(t(sftpBlockedKey || "webcrt.err.sftpSsh"));
|
||||
return;
|
||||
}
|
||||
setSftpOpen((v) => !v);
|
||||
if (!sftpOpen) void refreshSftp();
|
||||
}}
|
||||
>
|
||||
SFTP
|
||||
{t("webcrt.sftp.title")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="webcrt-main__body">
|
||||
{tabs.map((tab) => (
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTabKey === tab.key;
|
||||
// Only the active tab mounts xterm + WS; background tabs detach and re-attach on focus.
|
||||
const mountTerminal = Boolean(isActive && tab.wsUrl);
|
||||
return (
|
||||
<div
|
||||
key={`${tab.key}:${tab.termEpoch}`}
|
||||
key={tab.key}
|
||||
className="webcrt-main__pane"
|
||||
hidden={activeTabKey !== tab.key}
|
||||
hidden={!isActive}
|
||||
>
|
||||
{tab.status === "connecting" && !tab.wsUrl ? (
|
||||
<div className="webcrt-main__placeholder">
|
||||
|
|
@ -1297,7 +1409,7 @@ export function WebcrtPage() {
|
|||
<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 openTarget(tab.target, { force: true })}>
|
||||
<button type="button" className="webcrt-action-btn is-warn" onClick={() => void reconnectTab(tab)}>
|
||||
{t("webcrt.actions.reconnect")}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -1325,13 +1437,15 @@ export function WebcrtPage() {
|
|||
<button
|
||||
type="button"
|
||||
className="webcrt-action-btn is-warn"
|
||||
onClick={() => void openTarget(tab.target, { force: true })}
|
||||
onClick={() => void reconnectTab(tab)}
|
||||
>
|
||||
{t("webcrt.actions.reconnect")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{mountTerminal ? (
|
||||
<WebTerminal
|
||||
key={`${tab.key}:${tab.termEpoch}`}
|
||||
ref={(handle) => {
|
||||
if (handle) termRefs.current.set(tab.key, handle);
|
||||
else termRefs.current.delete(tab.key);
|
||||
|
|
@ -1348,10 +1462,22 @@ export function WebcrtPage() {
|
|||
copyOnSelect={sessionOpts.copyOnSelect}
|
||||
pasteDelayMs={sessionOpts.pasteDelayMs}
|
||||
keywordHighlight={keywordHl}
|
||||
autoFocus={activeTabKey === tab.key && tab.status === "connected"}
|
||||
autoFocus={tab.status === "connected"}
|
||||
onStdout={(chunk) => {
|
||||
const prev = logBuffersRef.current.get(tab.key) || "";
|
||||
logBuffersRef.current.set(tab.key, prev + chunk);
|
||||
if (!chunk) return;
|
||||
let parts = logBuffersRef.current.get(tab.key);
|
||||
if (!parts) {
|
||||
parts = [];
|
||||
logBuffersRef.current.set(tab.key, parts);
|
||||
}
|
||||
parts.push(chunk);
|
||||
if (parts.length >= LOG_COMPACT_CHUNKS) {
|
||||
const joined = parts.join("");
|
||||
logBuffersRef.current.set(
|
||||
tab.key,
|
||||
[joined.length > LOG_MAX_CHARS ? joined.slice(-LOG_MAX_CHARS) : joined],
|
||||
);
|
||||
}
|
||||
}}
|
||||
onReady={() => {
|
||||
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
|
||||
|
|
@ -1363,9 +1489,7 @@ export function WebcrtPage() {
|
|||
connectPhase: undefined,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
if (activeTabKey === tab.key) {
|
||||
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
|
||||
}
|
||||
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
|
||||
} else if (state === "open" || state === "connecting") {
|
||||
const nextPhase: ConnectPhase =
|
||||
phase === "waiting_prompt" || message === "waiting_prompt"
|
||||
|
|
@ -1375,44 +1499,90 @@ export function WebcrtPage() {
|
|||
status: "connecting",
|
||||
connectPhase: nextPhase,
|
||||
});
|
||||
} else if (state === "warning") {
|
||||
const m = String(message || "");
|
||||
const dropMatch = /^queue_dropped:(\d+)/i.exec(m);
|
||||
if (dropMatch) {
|
||||
const now = Date.now();
|
||||
if (now - lastQueueDropToastAtRef.current > 3000) {
|
||||
lastQueueDropToastAtRef.current = now;
|
||||
showError(
|
||||
t("webcrt.err.queueDropped", {
|
||||
count: Number(dropMatch[1]) || dropMatch[1],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (state === "error") {
|
||||
const errMsg = message || t("webcrt.disconnectBannerError");
|
||||
const sid = tab.sessionId;
|
||||
// Re-attach missed the grace window → create a fresh session.
|
||||
if (sid && isSessionGoneError(errMsg)) {
|
||||
updateTab(tab.key, {
|
||||
status: "connecting",
|
||||
sessionId: "",
|
||||
wsUrl: "",
|
||||
connectPhase: "creating",
|
||||
errorMessage: undefined,
|
||||
});
|
||||
void openTarget(tab.target, { force: true });
|
||||
return;
|
||||
}
|
||||
updateTab(tab.key, {
|
||||
status: "error",
|
||||
sessionId: "",
|
||||
wsUrl: "",
|
||||
connectPhase: undefined,
|
||||
errorMessage: errMsg,
|
||||
errorMessage: webcrtErrorMessage(errMsg, t),
|
||||
});
|
||||
if (sid) {
|
||||
void closeWebcrtSession(sid).catch(() => undefined);
|
||||
}
|
||||
const tgt = tab.target;
|
||||
const isWebcrtSsh =
|
||||
tgt.source === "webcrt" &&
|
||||
String(tgt.protocol || "ssh").toLowerCase() !== "telnet";
|
||||
if (isWebcrtSsh && isSshAuthFailure(errMsg)) {
|
||||
openAuthForTarget(tgt, webcrtErrorMessage(errMsg, t));
|
||||
if (isInventorySsh(tab.target) && isSshAuthFailure(errMsg)) {
|
||||
openAuthForTarget(tab.target, webcrtErrorMessage(errMsg, t));
|
||||
}
|
||||
} else if (state === "closed") {
|
||||
// Ignore local socket teardown noise; real device closes send session closed.
|
||||
if (String(message || "").startsWith("websocket_closed:")) return;
|
||||
const msg = String(message || "");
|
||||
// Local WS drop: keep session_id so reconnect / tab-focus can re-attach.
|
||||
if (msg.startsWith("websocket_closed:")) {
|
||||
updateTab(tab.key, {
|
||||
status: "closed",
|
||||
connectPhase: undefined,
|
||||
errorMessage: t("webcrt.disconnectBanner"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const deviceGone = isDeviceClosedMessage(msg);
|
||||
updateTab(tab.key, {
|
||||
status: "closed",
|
||||
sessionId: deviceGone ? "" : tab.sessionId,
|
||||
wsUrl: deviceGone ? "" : tab.wsUrl,
|
||||
connectPhase: undefined,
|
||||
errorMessage: message || t("webcrt.disconnectBanner"),
|
||||
errorMessage: msg || t("webcrt.disconnectBanner"),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{sftpOpen && activeTab ? (
|
||||
);
|
||||
})}
|
||||
{sftpOpen && activeTab && sftpAllowed ? (
|
||||
<div className="webcrt-sftp">
|
||||
<div className="webcrt-sftp__bar">
|
||||
<button
|
||||
type="button"
|
||||
title={t("webcrt.sftp.up")}
|
||||
onClick={() => {
|
||||
const parent = sftpParentPath(sftpPath);
|
||||
setSftpPath(parent);
|
||||
window.setTimeout(() => void refreshSftp(), 0);
|
||||
}}
|
||||
>
|
||||
{t("webcrt.sftp.up")}
|
||||
</button>
|
||||
<input value={sftpPath} onChange={(e) => setSftpPath(e.target.value)} />
|
||||
<button type="button" onClick={() => void refreshSftp()}>
|
||||
{t("webcrt.sftp.refresh")}
|
||||
|
|
@ -1485,7 +1655,8 @@ export function WebcrtPage() {
|
|||
})();
|
||||
}}
|
||||
>
|
||||
{it.is_dir ? "📁" : "📄"} {it.name}
|
||||
{it.is_dir ? `[${t("webcrt.sftp.dir")}] ` : ""}
|
||||
{it.name}
|
||||
{!it.is_dir ? ` (${it.size})` : ""}
|
||||
</button>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ export type CliTargetItem = {
|
|||
protocol?: string;
|
||||
username?: string;
|
||||
has_password?: boolean;
|
||||
hop_enabled?: boolean;
|
||||
connect_status: string;
|
||||
cli_profile_ready?: boolean;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue