From 7b62824c2c8ea5515e81b2b7e688ac1a5da8f140 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 2 Aug 2026 18:32:06 +0800 Subject: [PATCH] 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 --- web/src/components/WebTerminal.tsx | 33 +++++++++++++++++++++++++++--- web/src/pages/WebcrtPage.tsx | 32 ++++++++++++++++++++--------- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/web/src/components/WebTerminal.tsx b/web/src/components/WebTerminal.tsx index 18d7a8f..2f638f7 100644 --- a/web/src/components/WebTerminal.tsx +++ b/web/src/components/WebTerminal.tsx @@ -90,7 +90,7 @@ function serializeTerminal(term: Terminal): string { 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( term: Terminal, query: string, @@ -402,7 +402,8 @@ export const WebTerminal = forwardRef(function WebTerm cursorBlink: true, fontSize: fontSizeRef.current, 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), convertEol: true, }); @@ -443,11 +444,32 @@ export const WebTerminal = forwardRef(function WebTerm 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) => { if (!raw || !isActiveSocket()) return; lastStdoutAtRef.current = performance.now(); 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 = () => { @@ -640,6 +662,11 @@ export const WebTerminal = forwardRef(function WebTerm return () => { cancelled = true; + if (writeRaf) { + cancelAnimationFrame(writeRaf); + writeRaf = 0; + } + writeBuf = ""; window.clearInterval(pingTimer); window.removeEventListener("resize", onWinResize); window.removeEventListener("keydown", onKeyDownCapture, true); diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index 06bde17..0588366 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, + useMemo, useRef, useState, 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). */ const LOG_COMPACT_CHUNKS = 1500; 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"; @@ -343,11 +346,10 @@ function isSshAuthFailure(err: unknown): boolean { ); } -/** Inventory SSH (managed / webcrt Quick Connect). UME uses shared CLI profile — no per-NE popup. */ -function isInventorySsh(target: Pick): boolean { +/** Inventory managed / Quick Connect. UME uses shared CLI profile — no per-NE popup. */ +function isInventoryTarget(target: Pick): boolean { const src = String(target.source || "").toLowerCase(); - if (src !== "webcrt" && src !== "managed") return false; - return String(target.protocol || "ssh").toLowerCase() !== "telnet"; + return src === "webcrt" || src === "managed"; } function isSessionGoneError(err: unknown): boolean { @@ -667,6 +669,8 @@ export function WebcrtPage() { const [page, setPage] = useState(1); const [tabs, setTabs] = useState([]); const [activeTabKey, setActiveTabKey] = useState(""); + /** MRU tab keys for warm-mount (active always mounts via isActive even before this updates). */ + const [warmOrder, setWarmOrder] = useState([]); const [sftpOpen, setSftpOpen] = useState(false); const [sftpPath, setSftpPath] = useState("."); const [sftpBusy, setSftpBusy] = useState(false); @@ -725,6 +729,13 @@ export function WebcrtPage() { const sessionOptsRef = useRef(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(() => { if (!optionsMenuOpen) return; const onDoc = (e: MouseEvent) => { @@ -869,8 +880,8 @@ export function WebcrtPage() { } if (connectingKeysRef.current.has(key)) return; - // Managed / WebCRT SSH without saved password → credential popup (UME uses shared profile). - if (isInventorySsh(target) && !target.has_password && !opts?.force) { + // Managed / WebCRT without saved password → credential popup (UME uses shared profile). + if (isInventoryTarget(target) && !target.has_password && !opts?.force) { openAuthForTarget(target); return; } @@ -944,7 +955,7 @@ export function WebcrtPage() { } catch (err) { const message = webcrtErrorMessage(err, t); const needAuth = - isInventorySsh(target) && + isInventoryTarget(target) && (String(err).includes("credentials_incomplete") || String(err).includes("connect_failed") || isSshAuthFailure(err)); @@ -2188,8 +2199,9 @@ export function WebcrtPage() {
{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); + // Cap concurrent xterm+WS: active always mounts; keep one recent background warm. + // 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 (
undefined); } - if (isInventorySsh(tab.target) && isSshAuthFailure(errMsg)) { + if (isInventoryTarget(tab.target) && isSshAuthFailure(errMsg)) { openAuthForTarget(tab.target, webcrtErrorMessage(errMsg, t)); } } else if (state === "closed") {