mirror of
https://github.com/hansjone/netx.git
synced 2026-10-11 04:13:21 +08:00
Improve WebCRT multi-tab performance and inventory auth UX.
Keep two warm terminals, coalesce stdout per frame, trim scrollback, and allow Telnet credential prompts for managed/quick-connect targets. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
03ab449277
commit
7b62824c2c
2 changed files with 52 additions and 13 deletions
|
|
@ -90,7 +90,7 @@ function serializeTerminal(term: Terminal): string {
|
||||||
return lines.join("\n").replace(/\s+$/g, "");
|
return lines.join("\n").replace(/\s+$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Line-wise search — avoids serializing the full 10k scrollback on every Find. */
|
/** Line-wise search — avoids serializing the full scrollback on every Find. */
|
||||||
function findBufferLine(
|
function findBufferLine(
|
||||||
term: Terminal,
|
term: Terminal,
|
||||||
query: string,
|
query: string,
|
||||||
|
|
@ -402,7 +402,8 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: fontSizeRef.current,
|
fontSize: fontSizeRef.current,
|
||||||
fontFamily: 'Consolas, "Courier New", monospace',
|
fontFamily: 'Consolas, "Courier New", monospace',
|
||||||
scrollback: 10000,
|
// Lower than CRT-style 10k: remounts + multi-tab stay lighter; server replays log tail on attach.
|
||||||
|
scrollback: 4000,
|
||||||
theme: xtermThemeFromColors(termColorsRef.current),
|
theme: xtermThemeFromColors(termColorsRef.current),
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
});
|
});
|
||||||
|
|
@ -443,11 +444,32 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
|
|
||||||
const isActiveSocket = () => !cancelled && wsRef.current === ws;
|
const isActiveSocket = () => !cancelled && wsRef.current === ws;
|
||||||
|
|
||||||
|
// Coalesce high-rate stdout into one paint frame to cut xterm write churn.
|
||||||
|
let writeBuf = "";
|
||||||
|
let writeRaf = 0;
|
||||||
|
const flushWriteBuf = () => {
|
||||||
|
writeRaf = 0;
|
||||||
|
const chunk = writeBuf;
|
||||||
|
writeBuf = "";
|
||||||
|
if (!chunk || cancelled) return;
|
||||||
|
term.write(applyKeywordHighlight(chunk, keywordHighlightRef.current));
|
||||||
|
};
|
||||||
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);
|
if (recordingRef.current) onStdoutRef.current?.(raw);
|
||||||
term.write(applyKeywordHighlight(raw, keywordHighlightRef.current));
|
writeBuf += raw;
|
||||||
|
if (writeBuf.length >= 16384) {
|
||||||
|
if (writeRaf) {
|
||||||
|
cancelAnimationFrame(writeRaf);
|
||||||
|
writeRaf = 0;
|
||||||
|
}
|
||||||
|
flushWriteBuf();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!writeRaf) {
|
||||||
|
writeRaf = requestAnimationFrame(flushWriteBuf);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendResize = () => {
|
const sendResize = () => {
|
||||||
|
|
@ -640,6 +662,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (writeRaf) {
|
||||||
|
cancelAnimationFrame(writeRaf);
|
||||||
|
writeRaf = 0;
|
||||||
|
}
|
||||||
|
writeBuf = "";
|
||||||
window.clearInterval(pingTimer);
|
window.clearInterval(pingTimer);
|
||||||
window.removeEventListener("resize", onWinResize);
|
window.removeEventListener("resize", onWinResize);
|
||||||
window.removeEventListener("keydown", onKeyDownCapture, true);
|
window.removeEventListener("keydown", onKeyDownCapture, true);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type DragEvent as ReactDragEvent,
|
type DragEvent as ReactDragEvent,
|
||||||
|
|
@ -77,6 +78,8 @@ const KEEPALIVE_OPTIONS = [0, 15, 30, 60, 120] as const;
|
||||||
/** Compact recording chunks before they grow unbounded (join + trim). */
|
/** Compact recording chunks before they grow unbounded (join + trim). */
|
||||||
const LOG_COMPACT_CHUNKS = 1500;
|
const LOG_COMPACT_CHUNKS = 1500;
|
||||||
const LOG_MAX_CHARS = 8 * 1024 * 1024;
|
const LOG_MAX_CHARS = 8 * 1024 * 1024;
|
||||||
|
/** Keep active + (N-1) recent tabs mounted; colder tabs detach WS/xterm until focused. */
|
||||||
|
const WARM_TAB_LIMIT = 2;
|
||||||
|
|
||||||
type ColorSchemeId = "dark" | "blackWhite" | "whiteBlack" | "greenBlack" | "amberBlack" | "custom";
|
type ColorSchemeId = "dark" | "blackWhite" | "whiteBlack" | "greenBlack" | "amberBlack" | "custom";
|
||||||
|
|
||||||
|
|
@ -343,11 +346,10 @@ function isSshAuthFailure(err: unknown): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Inventory SSH (managed / webcrt Quick Connect). UME uses shared CLI profile — no per-NE popup. */
|
/** Inventory managed / Quick Connect. UME uses shared CLI profile — no per-NE popup. */
|
||||||
function isInventorySsh(target: Pick<CliTargetItem, "source" | "protocol">): boolean {
|
function isInventoryTarget(target: Pick<CliTargetItem, "source" | "protocol">): boolean {
|
||||||
const src = String(target.source || "").toLowerCase();
|
const src = String(target.source || "").toLowerCase();
|
||||||
if (src !== "webcrt" && src !== "managed") return false;
|
return src === "webcrt" || src === "managed";
|
||||||
return String(target.protocol || "ssh").toLowerCase() !== "telnet";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSessionGoneError(err: unknown): boolean {
|
function isSessionGoneError(err: unknown): boolean {
|
||||||
|
|
@ -667,6 +669,8 @@ export function WebcrtPage() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [tabs, setTabs] = useState<TermTab[]>([]);
|
const [tabs, setTabs] = useState<TermTab[]>([]);
|
||||||
const [activeTabKey, setActiveTabKey] = useState("");
|
const [activeTabKey, setActiveTabKey] = useState("");
|
||||||
|
/** MRU tab keys for warm-mount (active always mounts via isActive even before this updates). */
|
||||||
|
const [warmOrder, setWarmOrder] = useState<string[]>([]);
|
||||||
const [sftpOpen, setSftpOpen] = useState(false);
|
const [sftpOpen, setSftpOpen] = useState(false);
|
||||||
const [sftpPath, setSftpPath] = useState(".");
|
const [sftpPath, setSftpPath] = useState(".");
|
||||||
const [sftpBusy, setSftpBusy] = useState(false);
|
const [sftpBusy, setSftpBusy] = useState(false);
|
||||||
|
|
@ -725,6 +729,13 @@ export function WebcrtPage() {
|
||||||
const sessionOptsRef = useRef(sessionOpts);
|
const sessionOptsRef = useRef(sessionOpts);
|
||||||
sessionOptsRef.current = sessionOpts;
|
sessionOptsRef.current = sessionOpts;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeTabKey) return;
|
||||||
|
setWarmOrder((prev) => [activeTabKey, ...prev.filter((k) => k !== activeTabKey)].slice(0, 16));
|
||||||
|
}, [activeTabKey]);
|
||||||
|
|
||||||
|
const warmTabKeys = useMemo(() => new Set(warmOrder.slice(0, WARM_TAB_LIMIT)), [warmOrder]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!optionsMenuOpen) return;
|
if (!optionsMenuOpen) return;
|
||||||
const onDoc = (e: MouseEvent) => {
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
|
@ -869,8 +880,8 @@ export function WebcrtPage() {
|
||||||
}
|
}
|
||||||
if (connectingKeysRef.current.has(key)) return;
|
if (connectingKeysRef.current.has(key)) return;
|
||||||
|
|
||||||
// Managed / WebCRT SSH without saved password → credential popup (UME uses shared profile).
|
// Managed / WebCRT without saved password → credential popup (UME uses shared profile).
|
||||||
if (isInventorySsh(target) && !target.has_password && !opts?.force) {
|
if (isInventoryTarget(target) && !target.has_password && !opts?.force) {
|
||||||
openAuthForTarget(target);
|
openAuthForTarget(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -944,7 +955,7 @@ export function WebcrtPage() {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = webcrtErrorMessage(err, t);
|
const message = webcrtErrorMessage(err, t);
|
||||||
const needAuth =
|
const needAuth =
|
||||||
isInventorySsh(target) &&
|
isInventoryTarget(target) &&
|
||||||
(String(err).includes("credentials_incomplete") ||
|
(String(err).includes("credentials_incomplete") ||
|
||||||
String(err).includes("connect_failed") ||
|
String(err).includes("connect_failed") ||
|
||||||
isSshAuthFailure(err));
|
isSshAuthFailure(err));
|
||||||
|
|
@ -2188,8 +2199,9 @@ 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;
|
||||||
// Only the active tab mounts xterm + WS; background tabs detach and re-attach on focus.
|
// Cap concurrent xterm+WS: active always mounts; keep one recent background warm.
|
||||||
const mountTerminal = Boolean(isActive && tab.wsUrl);
|
// Colder tabs detach (server grace) and re-attach on focus via the same session_id.
|
||||||
|
const mountTerminal = Boolean(tab.wsUrl && (isActive || warmTabKeys.has(tab.key)));
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
|
|
@ -2339,7 +2351,7 @@ export function WebcrtPage() {
|
||||||
if (sid) {
|
if (sid) {
|
||||||
void closeWebcrtSession(sid).catch(() => undefined);
|
void closeWebcrtSession(sid).catch(() => undefined);
|
||||||
}
|
}
|
||||||
if (isInventorySsh(tab.target) && isSshAuthFailure(errMsg)) {
|
if (isInventoryTarget(tab.target) && isSshAuthFailure(errMsg)) {
|
||||||
openAuthForTarget(tab.target, webcrtErrorMessage(errMsg, t));
|
openAuthForTarget(tab.target, webcrtErrorMessage(errMsg, t));
|
||||||
}
|
}
|
||||||
} else if (state === "closed") {
|
} else if (state === "closed") {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue