Improve WebCRT new-session flow for SecureCRT-like SSH/Telnet use.

Add quick-connect sessions with credential retry, raw interactive drivers, bootstrap banner replay, Huawei telnet prompt cleanup, and session rename/delete in the tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 22:52:31 +08:00
parent accd28c9a1
commit f6b399e03e
23 changed files with 3682 additions and 225 deletions

View file

@ -2,6 +2,10 @@ 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 {
applyKeywordHighlight,
type KeywordHighlightConfig,
} from "../utils/webcrtKeywordHighlight";
export type WebTerminalHandle = {
clear: () => void;
@ -16,15 +20,55 @@ export type WebTerminalHandle = {
findPrevious: (term: string) => void;
};
export type TermColors = {
background: string;
foreground: string;
};
const DEFAULT_TERM_COLORS: TermColors = {
background: "#0b1220",
foreground: "#e2e8f0",
};
function normalizeHex(hex: string, fallback: string): string {
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex || "").trim());
return m ? `#${m[1].toLowerCase()}` : fallback;
}
function hexLuminance(hex: string): number {
const n = normalizeHex(hex, "#000000").slice(1);
const r = parseInt(n.slice(0, 2), 16) / 255;
const g = parseInt(n.slice(2, 4), 16) / 255;
const b = parseInt(n.slice(4, 6), 16) / 255;
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function xtermThemeFromColors(colors: TermColors) {
const background = normalizeHex(colors.background, DEFAULT_TERM_COLORS.background);
const foreground = normalizeHex(colors.foreground, DEFAULT_TERM_COLORS.foreground);
return {
background,
foreground,
cursor: foreground,
selectionBackground: hexLuminance(background) < 0.5 ? "#334155" : "#93c5fd",
};
}
type Props = {
wsUrl: string;
title?: string;
recording?: boolean;
autoFocus?: boolean;
encoding?: string;
fontSize?: number;
/** Terminal background / foreground (overrides legacy themeName). */
termColors?: TermColors;
/** @deprecated use termColors */
themeName?: "dark" | "light";
pasteDelayMs?: number;
copyOnSelect?: boolean;
onStatus?: (state: string, message?: string) => void;
keywordHighlight?: KeywordHighlightConfig;
onStatus?: (state: string, message?: string, phase?: string) => void;
onReady?: () => void;
onStdout?: (data: string) => void;
};
@ -103,14 +147,22 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
recording,
autoFocus = true,
encoding = "utf-8",
fontSize = 13,
termColors,
themeName = "dark",
pasteDelayMs,
copyOnSelect,
keywordHighlight,
onStatus,
onReady,
onStdout,
},
ref,
) {
const resolvedColors: TermColors = termColors ||
(themeName === "light"
? { background: "#ffffff", foreground: "#000000" }
: DEFAULT_TERM_COLORS);
const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
@ -122,9 +174,14 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
const recordingRef = useRef(!!recording);
const autoFocusRef = useRef(autoFocus);
const encodingRef = useRef(encoding);
const fontSizeRef = useRef(Math.max(10, Math.min(28, Number(fontSize) || 13)));
const termColorsRef = useRef<TermColors>(resolvedColors);
const pasteDelayRef = useRef(pasteDelayMs ?? loadPrefs().pasteDelayMs);
const copyOnSelectRef = useRef(copyOnSelect ?? loadPrefs().copyOnSelect);
const pasteQueueRef = useRef<Promise<void>>(Promise.resolve());
/** Updated whenever device stdout arrives; used to pace paste by echo, not fixed sleep. */
const lastStdoutAtRef = useRef(0);
const keywordHighlightRef = useRef(keywordHighlight);
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const [findOpen, setFindOpen] = useState(false);
const [findQuery, setFindQuery] = useState("");
@ -149,6 +206,25 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
encodingRef.current = encoding;
}, [encoding]);
useEffect(() => {
fontSizeRef.current = Math.max(10, Math.min(28, Number(fontSize) || 13));
const term = termRef.current;
if (!term) return;
term.options.fontSize = fontSizeRef.current;
try {
fitRef.current?.fit();
} catch {
/* ignore */
}
}, [fontSize]);
useEffect(() => {
termColorsRef.current = resolvedColors;
const term = termRef.current;
if (!term) return;
term.options.theme = xtermThemeFromColors(resolvedColors);
}, [resolvedColors.background, resolvedColors.foreground]);
useEffect(() => {
if (pasteDelayMs != null) pasteDelayRef.current = pasteDelayMs;
}, [pasteDelayMs]);
@ -157,6 +233,8 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
if (copyOnSelect != null) copyOnSelectRef.current = copyOnSelect;
}, [copyOnSelect]);
keywordHighlightRef.current = keywordHighlight;
const focusTerminal = () => {
try {
termRef.current?.focus();
@ -177,11 +255,37 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
sendJson({ type: "stdin", data });
};
/** SecureCRT-like paste: send line-by-line with delay; show progress while sending. */
/** Wait until device echoes (stdout after sentAt) or maxMs elapses — whichever first. */
const waitForEchoOrTimeout = (maxMs: number, sentAt: number) =>
new Promise<void>((resolve) => {
if (maxMs <= 0) {
resolve();
return;
}
const started = performance.now();
const tick = () => {
if (lastStdoutAtRef.current >= sentAt) {
// Brief settle so multi-chunk echoes finish before the next line.
window.setTimeout(resolve, 2);
return;
}
if (performance.now() - started >= maxMs) {
resolve();
return;
}
window.setTimeout(tick, 2);
};
tick();
});
/**
* Line-by-line paste paced by device echo.
* pasteDelayMs is a *maximum* wait per line; fast responses advance immediately.
*/
const sendStdinThrottled = (data: string) => {
if (!data) return;
const delay = pasteDelayRef.current;
if (delay <= 0 || data.length < 8) {
const maxDelay = pasteDelayRef.current;
if (maxDelay <= 0 || data.length < 8) {
sendStdinImmediate(data);
return;
}
@ -193,10 +297,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const chunk = i < lines.length - 1 ? `${line}\r` : line;
const sentAt = performance.now();
if (chunk) sendStdinImmediate(chunk);
setPasteStatus({ done: i + 1, total });
if (i < lines.length - 1) {
await new Promise((r) => window.setTimeout(r, delay));
await waitForEchoOrTimeout(maxDelay, sentAt);
}
}
} finally {
@ -274,15 +379,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontSize: fontSizeRef.current,
fontFamily: 'Consolas, "Courier New", monospace',
scrollback: 10000,
theme: {
background: "#0b1220",
foreground: "#e2e8f0",
cursor: "#e2e8f0",
selectionBackground: "#334155",
},
theme: xtermThemeFromColors(termColorsRef.current),
convertEol: true,
});
const fit = new FitAddon();
@ -312,22 +412,31 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}, 50);
});
// Guard against React StrictMode remount: the first WS teardown must not
// report closed/error after a newer socket owns the terminal.
let cancelled = false;
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
onStatusRef.current?.("connecting");
const isActiveSocket = () => !cancelled && wsRef.current === ws;
const writeStdout = (raw: string) => {
if (raw) term.write(raw);
if (!raw || !isActiveSocket()) return;
lastStdoutAtRef.current = performance.now();
if (recordingRef.current) onStdoutRef.current?.(raw);
term.write(applyKeywordHighlight(raw, keywordHighlightRef.current));
};
const sendResize = () => {
if (!isActiveSocket()) return;
doFit();
sendJson({ type: "resize", cols: term.cols, rows: term.rows });
};
ws.onopen = () => {
if (!isActiveSocket()) return;
onStatusRef.current?.("open");
sendResize();
onReadyRef.current?.();
@ -335,6 +444,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
};
ws.onmessage = (ev) => {
if (!isActiveSocket()) return;
if (ev.data instanceof ArrayBuffer) {
writeStdout(decodeBytes(ev.data, encodingRef.current));
maybeFocus();
@ -342,6 +452,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
void ev.data.arrayBuffer().then((buf) => {
if (!isActiveSocket()) return;
writeStdout(decodeBytes(buf, encodingRef.current));
maybeFocus();
});
@ -353,6 +464,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
data?: string;
state?: string;
message?: string;
phase?: string;
};
if (msg.type === "stdout" && typeof msg.data === "string") {
writeStdout(msg.data);
@ -360,7 +472,9 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
return;
}
if (msg.type === "status") {
onStatusRef.current?.(String(msg.state || ""), msg.message);
if (!isActiveSocket()) return;
const phase = typeof msg.phase === "string" ? msg.phase : undefined;
onStatusRef.current?.(String(msg.state || ""), msg.message, phase);
if (msg.state === "connected" || msg.state === "connecting") {
maybeFocus();
return;
@ -378,11 +492,14 @@ 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");
};
ws.onclose = (ev) => {
// Intentional unmount/remount closes the socket; do not flip UI to "closed".
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`);
@ -475,6 +592,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
ro?.observe(host);
return () => {
cancelled = true;
window.clearInterval(pingTimer);
window.removeEventListener("resize", onWinResize);
window.removeEventListener("keydown", onKeyDownCapture, true);
@ -483,17 +601,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
ro?.disconnect();
dataDisposable.dispose();
selDisposable.dispose();
if (wsRef.current === ws) wsRef.current = null;
try {
ws.close();
} catch {
/* ignore */
}
wsRef.current = null;
term.dispose();
termRef.current = null;
fitRef.current = null;
};
}, [wsUrl, title]);
// title is display-only; remounting on title change tears down a live WS.
}, [wsUrl]);
const pasteFromClipboard = async () => {
try {

View file

@ -905,7 +905,7 @@ const en = {
sessionManager: "Device list",
connect: "Connect",
hint: "Pick an NE and open an interactive terminal in the browser. Credentials stay on the server.",
hintCrt: "Click an NE on the left to open a terminal. Includes managed and UME inventory; search + paging for large fleets.",
hintCrt: "Click an NE on the left to open a terminal. Includes managed, Sessions, and UME; or use New Session for a quick login.",
filterKeyword: "Filter",
filterKeywordPh: "Filter by name / IP / tag",
empty: "No matching NEs. Switch source or refine the search.",
@ -919,8 +919,25 @@ const en = {
source: {
all: "All",
managed: "Managed",
webcrt: "Sessions",
ume: "UME",
},
newSession: {
title: "New Session",
hint: "Enter host and protocol. SSH prompts for username/password (editable on retry); Telnet logs in interactively. Duplicate names become (1), (2)…; same IP is allowed.",
connect: "Connect",
connecting: "Connecting…",
protocol: "Protocol",
host: "Hostname",
port: "Port",
sessionName: "Session name",
sessionNamePh: "Optional; defaults to IP",
authTitle: "SSH Authentication",
savePassword: "Save password",
ipRequired: "Hostname is required",
userRequired: "Username is required",
passwordRequired: "Password is required",
},
status: {
idle: "Idle",
connecting: "Connecting",
@ -928,6 +945,47 @@ const en = {
error: "Error",
closed: "Closed",
},
phase: {
creating: "Creating session",
authenticating: "Authenticating",
waitingPrompt: "Waiting for device prompt",
hint: "Slow devices may take more than 10 seconds",
},
tabMenu: {
close: "Close",
closeOthers: "Close others",
closeAll: "Close all",
copyIp: "Copy IP",
copyIpOk: "Copied {{ip}}",
copyIpEmpty: "No IP on this tab",
},
treeMenu: {
rename: "Rename",
renameLabel: "Session name",
renameSave: "Save",
renameRequired: "Session name is required",
renamed: "Session renamed",
delete: "Delete session",
deleteConfirm: "Delete session \"{{name}}\"? This cannot be undone.",
deleted: "Session deleted",
},
keywordHl: {
title: "Keyword Highlighting",
enabled: "Enabled",
newKeyword: "New keyword",
newKeywordPh: "Keyword or regex",
regex: "Regular expression",
add: "Add",
colKeyword: "Keyword",
colRegex: "Regex",
empty: "No keywords yet",
moveUp: "Move up",
moveDown: "Move down",
remove: "Remove",
caseSensitive: "Case sensitive",
color: "Text color",
saved: "Keyword highlighting saved",
},
actions: {
reconnect: "Reconnect",
recordLog: "Record log",
@ -949,7 +1007,34 @@ const en = {
recent: "Recent",
favorite: "Toggle favorite",
encoding: "Encoding",
fontSize: "Font",
fontSize: "Font size",
theme: "Theme",
themeDark: "Dark",
themeLight: "Light",
colorScheme: "Color scheme",
backgroundColor: "Background",
foregroundColor: "Font color",
scheme: {
dark: "Deep blue (default)",
blackWhite: "Black on white",
whiteBlack: "White on black",
greenBlack: "Green on black",
amberBlack: "Amber on black",
custom: "Custom",
},
copyOnSelect: "Copy on select",
pasteDelay: "Paste max line wait",
pasteDelayOff: "No delay (bulk send)",
keepalive: "SSH keepalive",
keepaliveOff: "Off",
keepaliveSec: "{{n}} sec",
options: "Options",
globalSessionOptions: "Global Session Options",
sessionOptionsSave: "Save",
sessionOptionsCancel: "Cancel",
sessionOptionsSaved: "Global session options saved",
disconnectBanner: "Session disconnected",
disconnectBannerError: "Session error or connection lost",
broadcastPh: "Send to all tabs…",
buttonBarHint: "One per line: label|command",
postLoginPrompt: "Post-login commands (one per line, this device)",
@ -960,9 +1045,12 @@ const en = {
uploaded: "Upload complete",
},
err: {
deviceType: "Unsupported device type",
ipConflictRestart: "Database still enforces unique IP; restart the netx API and retry",
sessionLimit: "Session limit reached; close other terminals first",
neNotFound: "Network element not found",
credsIncomplete: "Incomplete username/password on this NE",
authRejected: "Wrong username or password. Please try again.",
cliProfile: "UME NEs require a CLI connect profile (default account / hop)",
connectFailed: "Device login failed: {{detail}}",
sftpHop: "SFTP does not support hop devices yet (direct SSH only)",

View file

@ -902,7 +902,7 @@ const zh = {
sessionManager: "设备列表",
connect: "连接",
hint: "选择网元,在浏览器中打开交互式终端。凭据由服务端解密,不会下发到前端。",
hintCrt: "在左侧点击网元即可打开终端;支持托管与 UME 网元,可用搜索与分页浏览。",
hintCrt: "在左侧点击网元即可打开终端;支持托管、会话与 UME;也可用菜单「新建会话」快速登录。",
filterKeyword: "筛选",
filterKeywordPh: "按名称 / IP / 标签筛选",
empty: "没有匹配的网元。可切换来源或调整搜索关键字。",
@ -916,8 +916,25 @@ const zh = {
source: {
all: "全部",
managed: "托管",
webcrt: "会话",
ume: "UME",
},
newSession: {
title: "新建会话",
hint: "填写主机与协议后连接。SSH 弹出用户名/密码(可改);Telnet 进终端交互登录。同名会话自动加 (1)、(2)…,允许相同 IP。",
connect: "连接",
connecting: "连接中…",
protocol: "协议",
host: "主机",
port: "端口",
sessionName: "会话名",
sessionNamePh: "可选,默认使用 IP",
authTitle: "SSH 登录",
savePassword: "保存密码",
ipRequired: "请填写主机地址",
userRequired: "请填写用户名",
passwordRequired: "请填写密码",
},
status: {
idle: "空闲",
connecting: "连接中",
@ -925,6 +942,47 @@ const zh = {
error: "错误",
closed: "已关闭",
},
phase: {
creating: "创建会话",
authenticating: "正在认证",
waitingPrompt: "等待设备提示符",
hint: "设备较慢时可能需要十几秒,请稍候",
},
tabMenu: {
close: "关闭",
closeOthers: "关闭其他",
closeAll: "关闭全部",
copyIp: "复制 IP",
copyIpOk: "已复制 {{ip}}",
copyIpEmpty: "该标签没有 IP",
},
treeMenu: {
rename: "重命名",
renameLabel: "会话名",
renameSave: "保存",
renameRequired: "请填写会话名",
renamed: "会话已重命名",
delete: "删除会话",
deleteConfirm: "确定删除会话「{{name}}」?此操作不可恢复。",
deleted: "会话已删除",
},
keywordHl: {
title: "关键字突出显示",
enabled: "启用",
newKeyword: "新建关键字",
newKeywordPh: "输入关键字或正则",
regex: "正则表达式",
add: "添加",
colKeyword: "关键字",
colRegex: "正则",
empty: "尚未添加关键字",
moveUp: "上移",
moveDown: "下移",
remove: "删除",
caseSensitive: "区分大小写",
color: "字符颜色",
saved: "关键字突出显示已保存",
},
actions: {
reconnect: "重新连接",
recordLog: "记录日志",
@ -947,6 +1005,33 @@ const zh = {
favorite: "收藏/取消",
encoding: "编码",
fontSize: "字号",
theme: "主题",
themeDark: "深色",
themeLight: "浅色",
colorScheme: "配色方案",
backgroundColor: "背景色",
foregroundColor: "字体色",
scheme: {
dark: "深蓝(默认)",
blackWhite: "白底黑字",
whiteBlack: "黑底白字",
greenBlack: "黑底绿字",
amberBlack: "黑底琥珀",
custom: "自定义",
},
copyOnSelect: "选中即复制",
pasteDelay: "粘贴最大行间隔",
pasteDelayOff: "无延迟(整块发送)",
keepalive: "SSH Keepalive",
keepaliveOff: "关闭",
keepaliveSec: "{{n}} 秒",
options: "选项",
globalSessionOptions: "全局会话选项",
sessionOptionsSave: "保存",
sessionOptionsCancel: "取消",
sessionOptionsSaved: "已保存全局会话选项",
disconnectBanner: "会话已断开",
disconnectBannerError: "会话出错或连接中断",
broadcastPh: "发送到全部标签…",
buttonBarHint: "每行:按钮名|命令",
postLoginPrompt: "登录后自动执行(每行一条,仅本设备)",
@ -957,9 +1042,12 @@ const zh = {
uploaded: "上传成功",
},
err: {
deviceType: "不支持的设备类型",
ipConflictRestart: "数据库仍限制 IP 唯一,请重启 netx API 后再试",
sessionLimit: "并发会话已达上限,请先关闭其他终端",
neNotFound: "网元不存在",
credsIncomplete: "网元账号或密码不完整",
authRejected: "用户名或密码错误,请重新输入",
cliProfile: "UME 网元需要先配置 CLI 连接模板(默认账号/跳板)",
connectFailed: "登录设备失败:{{detail}}",
sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH",

View file

@ -1883,20 +1883,354 @@ pre {
letter-spacing: -0.01em;
}
.webcrt-sidebar__chrome {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 10px;
background: linear-gradient(180deg, #f7f9fc 0%, #eef2f7 100%);
border-bottom: 1px solid #d7dee8;
flex-shrink: 0;
}
.webcrt-sidebar__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 28px;
user-select: none;
}
.webcrt-sidebar__new-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 10px;
border: 1px solid #b8c4d2;
border-radius: 6px;
background: #fff;
color: var(--nm-brand);
font-size: 12px;
font-weight: 650;
line-height: 1;
cursor: pointer;
box-shadow: none;
}
.webcrt-sidebar__new-btn:hover {
background: #e8f0fe;
border-color: #9eb0c6;
color: var(--nm-accent);
}
.app-main .webcrt-sidebar__new-btn {
border: 1px solid #b8c4d2;
box-shadow: none;
height: 28px;
border-radius: 6px;
}
.webcrt-menubar__item {
position: relative;
flex-shrink: 0;
}
.webcrt-menubar__btn {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: #475569;
font-size: 12px;
font-weight: 600;
line-height: 1;
cursor: pointer;
box-shadow: none;
}
.webcrt-menubar__btn:hover,
.webcrt-menubar__btn.is-open {
background: #dbe4f0;
color: var(--nm-brand);
}
.app-main .webcrt-menubar__btn {
border: 0;
box-shadow: none;
height: 28px;
border-radius: 6px;
}
.webcrt-menubar__menu {
position: absolute;
top: calc(100% + 2px);
left: 0;
z-index: 40;
min-width: 180px;
padding: 4px 0;
background: #fff;
border: 1px solid #c5d0de;
border-radius: 4px;
box-shadow: 0 8px 20px rgba(15, 39, 68, 0.14);
}
.webcrt-menubar__menu--end {
left: auto;
right: 0;
}
.webcrt-menubar__menu-item {
display: block;
width: 100%;
padding: 7px 14px;
border: 0;
background: transparent;
color: #1e293b;
font-size: 12px;
text-align: left;
cursor: pointer;
box-shadow: none;
border-radius: 0;
}
.webcrt-menubar__menu-item:hover {
background: #e8f0fe;
color: var(--nm-brand);
}
.app-main .webcrt-menubar__menu-item {
border: 0;
box-shadow: none;
border-radius: 0;
height: auto;
}
.webcrt-session-opts {
display: grid;
gap: 14px;
}
.webcrt-session-opts__field {
display: grid;
gap: 6px;
font-size: 13px;
color: #334155;
font-weight: 600;
}
.webcrt-session-opts__field--check {
grid-template-columns: 1fr auto;
align-items: center;
gap: 12px;
}
.webcrt-session-opts__field--check input[type="checkbox"] {
width: 16px;
height: 16px;
margin: 0;
}
.webcrt-session-opts__field select {
height: 34px;
padding: 0 10px;
border: 1px solid #b8c4d2;
border-radius: 6px;
background: #fff;
font-size: 13px;
font-weight: 500;
color: #0f172a;
}
.webcrt-session-opts__hint {
margin: 12px 0 0;
}
.webcrt-session-opts__colors {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
align-items: end;
}
.webcrt-session-opts__color input[type="color"] {
width: 100%;
height: 34px;
padding: 2px;
border: 1px solid #b8c4d2;
border-radius: 6px;
background: #fff;
cursor: pointer;
}
.webcrt-session-opts__preview {
grid-column: 1 / -1;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #c5d0de;
border-radius: 6px;
font-family: Consolas, "Courier New", monospace;
font-size: 13px;
font-weight: 600;
}
.webcrt-session-opts-modal {
width: min(460px, 100%);
}
.webcrt-keyword-modal {
width: min(520px, 100%);
}
.webcrt-keyword-add {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 8px;
align-items: end;
margin: 12px 0;
}
.webcrt-keyword-add__regex {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #334155;
padding-bottom: 8px;
white-space: nowrap;
}
.webcrt-keyword-list-wrap {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto 1fr;
gap: 6px 8px;
margin-top: 4px;
}
.webcrt-keyword-list-head {
grid-column: 1;
display: grid;
grid-template-columns: 1fr 48px;
gap: 8px;
padding: 0 8px;
font-size: 11px;
font-weight: 650;
color: #64748b;
}
.webcrt-keyword-list {
grid-column: 1;
list-style: none;
margin: 0;
padding: 0;
min-height: 160px;
max-height: 220px;
overflow: auto;
border: 1px solid #c5d0de;
border-radius: 6px;
background: #fff;
}
.webcrt-keyword-list__empty {
padding: 16px;
font-size: 12px;
color: #94a3b8;
text-align: center;
}
.webcrt-keyword-list__row {
display: grid;
grid-template-columns: 1fr 48px;
gap: 8px;
width: 100%;
padding: 7px 8px;
border: 0;
border-bottom: 1px solid #eef2f7;
background: transparent;
color: #0f172a;
font-size: 12px;
text-align: left;
cursor: pointer;
border-radius: 0;
box-shadow: none;
}
.webcrt-keyword-list__row:hover {
background: #f1f5f9;
}
.webcrt-keyword-list__row.is-selected {
background: #e8f0fe;
color: var(--nm-brand);
}
.app-main .webcrt-keyword-list__row {
border-radius: 0;
box-shadow: none;
height: auto;
}
.webcrt-keyword-list__pattern {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.webcrt-keyword-list-actions {
grid-column: 2;
grid-row: 2;
display: flex;
flex-direction: column;
gap: 4px;
}
.webcrt-keyword-list-actions button {
width: 32px;
height: 28px;
padding: 0;
font-size: 14px;
line-height: 1;
}
.webcrt-keyword-footer {
display: grid;
grid-template-columns: 1fr auto;
gap: 16px;
align-items: end;
margin-top: 14px;
}
.webcrt-keyword-color input[type="color"] {
width: 48px;
height: 32px;
padding: 2px;
border: 1px solid #b8c4d2;
border-radius: 6px;
background: #fff;
cursor: pointer;
}
.webcrt-sidebar__toolbar {
display: none;
}
.webcrt-sidebar__search {
padding: 8px 10px;
border-bottom: 1px solid #d7dee8;
.webcrt-sidebar__chrome .webcrt-sidebar__search {
padding: 0;
border-bottom: 0;
flex-shrink: 0;
}
.webcrt-sidebar__search input {
width: 100%;
box-sizing: border-box;
height: 32px;
height: 30px;
padding: 0 10px;
border: 1px solid #b8c4d2;
border-radius: 6px;
@ -1904,11 +2238,15 @@ pre {
font-size: 12px;
}
.webcrt-sidebar__chrome .webcrt-source-tabs {
margin: 0;
}
.webcrt-source-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2px;
margin: 0 10px 8px;
margin: 0;
padding: 2px;
background: #d7dee8;
border: 1px solid var(--nm-nav-border);
@ -1916,21 +2254,41 @@ pre {
flex-shrink: 0;
}
.webcrt-source-tabs--4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.webcrt-new-session-modal,
.webcrt-auth-modal {
width: min(420px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
overflow: auto;
}
.webcrt-auth-error {
color: #b91c1c;
margin-top: -4px;
}
.webcrt-source-tabs__btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 6px;
min-width: 0;
height: 26px;
padding: 0 2px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--nm-muted);
font-size: 12px;
font-size: 11px;
font-weight: 600;
line-height: 1;
cursor: pointer;
box-shadow: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-main .webcrt-source-tabs__btn,
@ -1996,7 +2354,7 @@ pre {
background: transparent;
min-height: 0;
overscroll-behavior: contain;
padding: 0 8px 8px;
padding: 8px 10px;
}
.webcrt-tree__folder {
@ -2202,6 +2560,20 @@ pre {
box-shadow: 0 1px 0 #fff;
}
.webcrt-tabs__item.is-dead {
color: #b91c1c;
}
.webcrt-tabs__item.is-dead.is-active {
background: #fef2f2;
border-color: #fecaca;
color: #b91c1c;
}
.webcrt-tabs__item.is-connecting {
color: #b45309;
}
/* span close mark — avoid global `button` border/padding chrome */
.webcrt-tabs__close {
display: inline;
@ -2271,6 +2643,83 @@ pre {
color: var(--nm-accent);
}
.webcrt-action-btn.is-warn {
background: #fef2f2;
border-color: #fca5a5;
color: #b91c1c;
}
.webcrt-action-btn.is-warn:hover {
background: #fee2e2;
border-color: #f87171;
}
.webcrt-connect-banner {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
padding: 8px 12px;
background: #1e3a5f;
border-bottom: 1px solid #274a73;
color: #dbeafe;
z-index: 2;
}
.webcrt-connect-banner__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
font-size: 12px;
}
.webcrt-connect-banner__text strong {
font-size: 13px;
}
.webcrt-connect-banner__text span {
opacity: 0.85;
}
.webcrt-disconnect-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
padding: 8px 12px;
background: #7f1d1d;
border-bottom: 1px solid #991b1b;
color: #fee2e2;
z-index: 2;
}
.webcrt-disconnect-banner__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
font-size: 12px;
}
.webcrt-disconnect-banner__text strong {
font-size: 13px;
}
.webcrt-disconnect-banner__text span {
opacity: 0.85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.webcrt-disconnect-banner .webcrt-action-btn.is-warn {
background: #fff;
border-color: #fecaca;
flex-shrink: 0;
}
.webcrt-main__body {
flex: 1;
min-height: 0;
@ -2290,13 +2739,33 @@ pre {
}
.webcrt-main__pane .webcrt-term,
.webcrt-main__pane .webcrt-term-wrap {
.webcrt-main__pane .webcrt-term-wrap,
.webcrt-main__pane .webcrt-ssh-login {
flex: 1;
width: 100%;
height: auto !important;
min-height: 240px !important;
}
.webcrt-ssh-login {
display: flex;
flex-direction: column;
min-height: 0;
}
.webcrt-ssh-login__save {
flex: 0 0 auto;
padding: 6px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
color: #cbd5e1;
background: #0b1220;
}
.webcrt-ssh-login .ssh-login-term {
flex: 1;
min-height: 200px !important;
}
.webcrt-main__empty,
.webcrt-main__placeholder {
height: 100%;
@ -2415,6 +2884,20 @@ pre {
background: #334155;
}
.webcrt-ctx button:disabled {
opacity: 0.4;
cursor: default;
}
.webcrt-ctx button:disabled:hover {
background: transparent;
}
.webcrt-tab-ctx {
border-radius: 6px;
min-width: 140px;
}
.webcrt-tree__row {
display: flex;
align-items: stretch;
@ -2625,10 +3108,6 @@ pre {
.webcrt-main {
min-height: 50vh;
}
.webcrt-source-tabs {
margin-bottom: 8px;
}
}
@media (max-width: 1200px) {

File diff suppressed because it is too large Load diff

View file

@ -92,6 +92,46 @@ const parseApiResponse = async (res: Response): Promise<Record<string, unknown>>
}
};
/** Preserve FastAPI structured ``detail`` objects (e.g. connect_failed + ne). */
export class ApiRequestError extends Error {
status: number;
detail: unknown;
constructor(status: number, detail: unknown) {
super(formatApiDetail(detail) || `${status}`);
this.name = "ApiRequestError";
this.status = status;
this.detail = detail;
}
}
export function formatApiDetail(detail: unknown): string {
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
return detail
.map((item) => {
if (typeof item === "string") return item;
if (item && typeof item === "object" && "msg" in item) {
return String((item as { msg?: unknown }).msg || "");
}
return "";
})
.filter(Boolean)
.join("; ");
}
if (detail && typeof detail === "object") {
const obj = detail as Record<string, unknown>;
if (typeof obj.message === "string" && obj.message.trim()) return obj.message;
if (typeof obj.error === "string" && obj.error.trim()) return obj.error;
try {
return JSON.stringify(detail);
} catch {
/* fall through */
}
}
return detail == null ? "" : String(detail);
}
export const apiGet = async <T,>(path: string): Promise<T> => {
const res = await fetch(path, { headers: authHeaders() });
if (res.status === 401) {
@ -111,9 +151,9 @@ export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
const data = await parseApiResponse(res);
if (res.status === 401) {
handleUnauthorized(path);
throw new Error(String(data.detail || "unauthorized"));
throw new ApiRequestError(401, data.detail || "unauthorized");
}
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`);
return data as T;
};
@ -126,9 +166,9 @@ export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
const data = await parseApiResponse(res);
if (res.status === 401) {
handleUnauthorized(path);
throw new Error(String(data.detail || "unauthorized"));
throw new ApiRequestError(401, data.detail || "unauthorized");
}
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`);
return data as T;
};
@ -137,9 +177,9 @@ export const apiDelete = async <T,>(path: string): Promise<T> => {
const data = await parseApiResponse(res);
if (res.status === 401) {
handleUnauthorized(path);
throw new Error(String(data.detail || "unauthorized"));
throw new ApiRequestError(401, data.detail || "unauthorized");
}
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`);
return data as T;
};
@ -152,9 +192,9 @@ export const apiPut = async <T,>(path: string, body: unknown): Promise<T> => {
const data = await parseApiResponse(res);
if (res.status === 401) {
handleUnauthorized(path);
throw new Error(String(data.detail || "unauthorized"));
throw new ApiRequestError(401, data.detail || "unauthorized");
}
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`);
return data as T;
};
@ -471,10 +511,34 @@ export const createWebcrtSession = (body: {
cols?: number;
rows?: number;
encoding?: string;
keepalive_sec?: number;
post_login_commands?: string[];
async_connect?: boolean;
username?: string;
password?: string;
}) => apiPost<WebcrtSessionCreateResult>("/v1/webcrt/sessions", body);
export type WebcrtQuickConnectResult = WebcrtSessionCreateResult & {
ne: ManagedNeItem;
ne_action: "created" | "updated" | "reused" | string;
list_source: "webcrt" | "managed" | string;
};
export const quickConnectWebcrtSession = (body: {
name?: string;
ip_address: string;
port?: number;
protocol?: string;
username?: string;
password?: string;
save_password?: boolean;
cols?: number;
rows?: number;
encoding?: string;
keepalive_sec?: number;
async_connect?: boolean;
}) => apiPost<WebcrtQuickConnectResult>("/v1/webcrt/sessions/quick-connect", body);
export const closeWebcrtSession = (sessionId: string) =>
apiDelete<{ ok: boolean; session_id: string; closed: boolean }>(
`/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`,
@ -565,7 +629,7 @@ export const fetchCliProfiles = () =>
apiGet<{ items: CliConnectProfileItem[] }>("/v1/cli/profiles");
export const fetchCliTargets = (params: {
source?: "managed" | "ume" | "all";
source?: "managed" | "ume" | "webcrt" | "all";
keyword?: string;
page?: number;
pageSize?: number;

View file

@ -257,6 +257,9 @@ export type CliTargetItem = {
ne_type?: string;
vendor?: string;
device_type?: string;
protocol?: string;
username?: string;
has_password?: boolean;
connect_status: string;
cli_profile_ready?: boolean;
};

View file

@ -0,0 +1,189 @@
/** SecureCRT-like keyword highlighting for WebCRT stdout (ANSI inject). */
export type KeywordRule = {
id: string;
pattern: string;
regex: boolean;
};
export type KeywordHighlightConfig = {
enabled: boolean;
caseSensitive: boolean;
/** Foreground (character) highlight color (hex). */
color: string;
keywords: KeywordRule[];
};
export const KEYWORD_HL_STORAGE_KEY = "netx.webcrt.keywordHighlight";
const DEFAULT_COLOR = "#ffff00";
export function defaultKeywordHighlightConfig(): KeywordHighlightConfig {
return {
enabled: false,
caseSensitive: false,
color: DEFAULT_COLOR,
keywords: [],
};
}
export function newKeywordId(): string {
return `kw_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
function normalizeHexColor(hex: string): string | null {
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex || "").trim());
return m ? `#${m[1].toLowerCase()}` : null;
}
export function loadKeywordHighlightConfig(): KeywordHighlightConfig {
const base = defaultKeywordHighlightConfig();
try {
const raw = localStorage.getItem(KEYWORD_HL_STORAGE_KEY);
if (!raw) return base;
const j = JSON.parse(raw) as Partial<KeywordHighlightConfig>;
const keywords = Array.isArray(j.keywords)
? j.keywords
.map((k) => ({
id: String((k as KeywordRule).id || newKeywordId()),
pattern: String((k as KeywordRule).pattern || ""),
regex: Boolean((k as KeywordRule).regex),
}))
.filter((k) => k.pattern.trim())
: [];
return {
// Keywords imply active highlighting (avoids stale enabled:false in localStorage).
enabled: keywords.length > 0,
caseSensitive: Boolean(j.caseSensitive),
color: normalizeHexColor(String(j.color || base.color)) || DEFAULT_COLOR,
keywords,
};
} catch {
return base;
}
}
export function saveKeywordHighlightConfig(cfg: KeywordHighlightConfig): void {
const keywords = (cfg.keywords || []).filter((k) => String(k.pattern || "").trim());
localStorage.setItem(
KEYWORD_HL_STORAGE_KEY,
JSON.stringify({
...cfg,
enabled: keywords.length > 0 && cfg.enabled !== false,
keywords,
}),
);
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const n = normalizeHexColor(hex) || DEFAULT_COLOR;
const v = parseInt(n.slice(1), 16);
return { r: (v >> 16) & 255, g: (v >> 8) & 255, b: v & 255 };
}
/** Map highlight color to bold foreground (character color), not background. */
function sgrForColor(hex: string): { open: string; close: string } {
const { r, g, b } = hexToRgb(hex);
// 16-color bright fg fallback + truecolor fg for xterm.
let brightFg = 93; // bright yellow
if (g >= r && g >= b && g > 80) brightFg = 92; // bright green
else if (r >= g && r >= b && r > 80 && g < 100) brightFg = 91; // bright red
else if (b >= r && b >= g && b > 80) brightFg = 94; // bright blue
else if (r > 180 && g > 180 && b < 120) brightFg = 93; // yellow
const open = `\x1b[1;${brightFg}m\x1b[38;2;${r};${g};${b}m`;
const close = "\x1b[22;39m";
return { open, close };
}
type CompiledRule = {
re: RegExp;
open: string;
close: string;
};
export function compileKeywordRules(config: KeywordHighlightConfig | null | undefined): CompiledRule[] {
if (!config?.keywords?.length) return [];
if (!config.enabled) return [];
const { open, close } = sgrForColor(config.color || DEFAULT_COLOR);
const flags = config.caseSensitive ? "g" : "gi";
const out: CompiledRule[] = [];
for (const kw of config.keywords) {
const pattern = String(kw.pattern || "").trim();
if (!pattern) continue;
try {
const source = kw.regex ? pattern : escapeRegExp(pattern);
out.push({ re: new RegExp(source, flags), open, close });
} catch {
/* skip invalid regex */
}
}
return out;
}
/** CSI / OSC / simple charset sequences — leave untouched. */
const ANSI_RE =
/\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[()][0-2AB]|[>=])/g;
function highlightPlain(text: string, rules: CompiledRule[]): string {
if (!text || !rules.length) return text;
type Match = { start: number; end: number; open: string; close: string };
const matches: Match[] = [];
for (const rule of rules) {
rule.re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = rule.re.exec(text)) !== null) {
if (!m[0]) {
rule.re.lastIndex += 1;
continue;
}
matches.push({
start: m.index,
end: m.index + m[0].length,
open: rule.open,
close: rule.close,
});
if (!rule.re.global) break;
}
}
if (!matches.length) return text;
matches.sort((a, b) => a.start - b.start || b.end - a.end);
const picked: Match[] = [];
let cursor = 0;
for (const m of matches) {
if (m.start < cursor) continue;
picked.push(m);
cursor = m.end;
}
let out = "";
let i = 0;
for (const m of picked) {
out += text.slice(i, m.start) + m.open + text.slice(m.start, m.end) + m.close;
i = m.end;
}
out += text.slice(i);
return out;
}
export function applyKeywordHighlight(
text: string,
config: KeywordHighlightConfig | null | undefined,
): string {
if (!text) return text;
const rules = compileKeywordRules(config);
if (!rules.length) return text;
let out = "";
let last = 0;
ANSI_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ANSI_RE.exec(text)) !== null) {
if (m.index > last) out += highlightPlain(text.slice(last, m.index), rules);
out += m[0];
last = m.index + m[0].length;
}
if (last < text.length) out += highlightPlain(text.slice(last), rules);
return out;
}