Harden WebCRT interactive sessions for more CRT-like device login.

Improve async connect, binary stdout coalescing, paste throttling with progress, SSH keepalive, login prompt settle, and optional SFTP while keeping core terminal UX focused.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 19:01:44 +08:00
parent cb865254e4
commit accd28c9a1
13 changed files with 1884 additions and 224 deletions

View file

@ -139,12 +139,14 @@ src/
## WebCRT
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`;可选 `encoding`/`post_login_commands`/`async_connect`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`
- SFTP(直连 SSH):`POST /v1/webcrt/sftp/list|download|upload`
- 目标列表复用 `/v1/cli/targets`(托管 + UME,搜索分页;`source=all|managed|ume`)
- 凭据:托管走网元自身账号;UME 走 CLI 连接模板(`resolve_cli_target`)
- 前端:CRT 风格左右分栏(会话管理 + 多标签终端)
- 审计:`NETX_WEBCRT_DATA_DIR`(默认 `data/webcrt/audit.jsonl`)
- 限流:`NETX_WEBCRT_MAX_SESSIONS`、`NETX_WEBCRT_IDLE_TIMEOUT_SEC`
- 前端:CRT 风格左右分栏(收藏/最近 + 多标签终端);粘贴节流、选区复制、Break、Ctrl+F、Button Bar、广播、编码/字号
- WS:stdout 二进制帧合批;stdin 短合批;建连可异步(POST 立即返回,进度经 WS `connecting`→`connected`)
- 审计 / 会话日志:`NETX_WEBCRT_DATA_DIR`(`audit.jsonl` + `sessions/*.log`)
- 限流 / 保活:`NETX_WEBCRT_MAX_SESSIONS`、`NETX_WEBCRT_IDLE_TIMEOUT_SEC`、`NETX_WEBCRT_KEEPALIVE_SEC`、`NETX_WEBCRT_ANTI_IDLE_SEC`
## Toast

View file

@ -1,4 +1,4 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
@ -6,9 +6,14 @@ import "@xterm/xterm/css/xterm.css";
export type WebTerminalHandle = {
clear: () => void;
copyAll: () => Promise<string>;
copySelection: () => Promise<string>;
getText: () => string;
fit: () => void;
focus: () => void;
sendBreak: () => void;
sendText: (data: string, opts?: { throttle?: boolean }) => void;
findNext: (term: string) => void;
findPrevious: (term: string) => void;
};
type Props = {
@ -16,6 +21,9 @@ type Props = {
title?: string;
recording?: boolean;
autoFocus?: boolean;
encoding?: string;
pasteDelayMs?: number;
copyOnSelect?: boolean;
onStatus?: (state: string, message?: string) => void;
onReady?: () => void;
onStdout?: (data: string) => void;
@ -35,11 +43,10 @@ function serializeTerminal(term: Terminal): string {
function isSidebarSearchTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") {
// xterm's hidden textarea must still receive keys normally.
if (target.classList.contains("xterm-helper-textarea")) return false;
return true;
}
return Boolean(target.closest(".webcrt-sidebar__search"));
return Boolean(target.closest(".webcrt-sidebar__search") || target.closest(".webcrt-ctx") || target.closest(".webcrt-find"));
}
function isXtermTextarea(target: EventTarget | null): boolean {
@ -49,7 +56,7 @@ function isXtermTextarea(target: EventTarget | null): boolean {
/** Map a browser key event to the bytes xterm/onData would normally emit. */
function keyEventToStdin(e: KeyboardEvent): string | null {
if (e.ctrlKey || e.altKey || e.metaKey) return null;
if (e.key === "Backspace") return "\x08"; // BS — common SecureCRT/VT default
if (e.key === "Backspace") return "\x08";
if (e.key === "Enter") return "\r";
if (e.key === "Tab") return "\t";
if (e.key === "Escape") return "\x1b";
@ -64,19 +71,65 @@ function keyEventToStdin(e: KeyboardEvent): string | null {
return null;
}
function decodeBytes(buf: ArrayBuffer, _encoding: string): string {
// Server normalizes device encodings (e.g. GBK) to UTF-8 on the wire.
try {
return new TextDecoder("utf-8").decode(buf);
} catch {
return "";
}
}
const PREF_KEY = "netx.webcrt.termPrefs";
function loadPrefs(): { copyOnSelect: boolean; pasteDelayMs: number } {
try {
const raw = localStorage.getItem(PREF_KEY);
if (!raw) return { copyOnSelect: true, pasteDelayMs: 40 };
const j = JSON.parse(raw) as Partial<{ copyOnSelect: boolean; pasteDelayMs: number }>;
return {
copyOnSelect: j.copyOnSelect !== false,
pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs) || 40)),
};
} catch {
return { copyOnSelect: true, pasteDelayMs: 40 };
}
}
export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerminal(
{ wsUrl, title, recording, autoFocus = true, onStatus, onReady, onStdout },
{
wsUrl,
title,
recording,
autoFocus = true,
encoding = "utf-8",
pasteDelayMs,
copyOnSelect,
onStatus,
onReady,
onStdout,
},
ref,
) {
const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const findIndexRef = useRef(0);
const onStatusRef = useRef(onStatus);
const onReadyRef = useRef(onReady);
const onStdoutRef = useRef(onStdout);
const recordingRef = useRef(!!recording);
const autoFocusRef = useRef(autoFocus);
const encodingRef = useRef(encoding);
const pasteDelayRef = useRef(pasteDelayMs ?? loadPrefs().pasteDelayMs);
const copyOnSelectRef = useRef(copyOnSelect ?? loadPrefs().copyOnSelect);
const pasteQueueRef = useRef<Promise<void>>(Promise.resolve());
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const [findOpen, setFindOpen] = useState(false);
const [findQuery, setFindQuery] = useState("");
const [pasteStatus, setPasteStatus] = useState<{ done: number; total: number } | null>(null);
const findInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
onStatusRef.current = onStatus;
@ -92,6 +145,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
autoFocusRef.current = autoFocus;
}, [autoFocus]);
useEffect(() => {
encodingRef.current = encoding;
}, [encoding]);
useEffect(() => {
if (pasteDelayMs != null) pasteDelayRef.current = pasteDelayMs;
}, [pasteDelayMs]);
useEffect(() => {
if (copyOnSelect != null) copyOnSelectRef.current = copyOnSelect;
}, [copyOnSelect]);
const focusTerminal = () => {
try {
termRef.current?.focus();
@ -100,6 +165,46 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
};
const sendJson = (payload: Record<string, unknown>) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
};
const sendStdinImmediate = (data: string) => {
if (!data) return;
sendJson({ type: "stdin", data });
};
/** SecureCRT-like paste: send line-by-line with delay; show progress while sending. */
const sendStdinThrottled = (data: string) => {
if (!data) return;
const delay = pasteDelayRef.current;
if (delay <= 0 || data.length < 8) {
sendStdinImmediate(data);
return;
}
pasteQueueRef.current = pasteQueueRef.current.then(async () => {
const lines = data.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const total = Math.max(1, lines.length);
setPasteStatus({ done: 0, total });
try {
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const chunk = i < lines.length - 1 ? `${line}\r` : line;
if (chunk) sendStdinImmediate(chunk);
setPasteStatus({ done: i + 1, total });
if (i < lines.length - 1) {
await new Promise((r) => window.setTimeout(r, delay));
}
}
} finally {
setPasteStatus(null);
}
});
};
useImperativeHandle(ref, () => ({
clear: () => {
termRef.current?.clear();
@ -112,6 +217,13 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
return text;
},
copySelection: async () => {
const text = termRef.current?.getSelection() || "";
if (text && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
}
return text;
},
getText: () => (termRef.current ? serializeTerminal(termRef.current) : ""),
fit: () => {
try {
@ -121,6 +233,39 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
},
focus: focusTerminal,
sendBreak: () => sendJson({ type: "break" }),
sendText: (data: string, opts?: { throttle?: boolean }) => {
if (opts?.throttle) sendStdinThrottled(data);
else sendStdinImmediate(data);
},
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));
}
},
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));
}
},
}));
useEffect(() => {
@ -131,10 +276,12 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
cursorBlink: true,
fontSize: 13,
fontFamily: 'Consolas, "Courier New", monospace',
scrollback: 10000,
theme: {
background: "#0b1220",
foreground: "#e2e8f0",
cursor: "#e2e8f0",
selectionBackground: "#334155",
},
convertEol: true,
});
@ -155,7 +302,6 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
if (!autoFocusRef.current) return;
focusTerminal();
};
// Focus immediately so the first keystroke is not lost to the sidebar/body.
maybeFocus();
requestAnimationFrame(() => {
doFit();
@ -167,22 +313,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
});
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
onStatusRef.current?.("connecting");
const sendJson = (payload: Record<string, unknown>) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
};
const sendStdin = (data: string) => {
if (!data) return;
sendJson({ type: "stdin", data });
};
// Display only what the device echoes (chars, Tab completion, BS erase, etc.).
// No local echo / local erase — those desync on Tab complete and prompt redraw.
const writeStdout = (raw: string) => {
if (raw) term.write(raw);
if (recordingRef.current) onStdoutRef.current?.(raw);
@ -201,6 +335,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
};
ws.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) {
writeStdout(decodeBytes(ev.data, encodingRef.current));
maybeFocus();
return;
}
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
void ev.data.arrayBuffer().then((buf) => {
writeStdout(decodeBytes(buf, encodingRef.current));
maybeFocus();
});
return;
}
try {
const msg = JSON.parse(String(ev.data || "{}")) as {
type?: string;
@ -215,7 +361,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
if (msg.type === "status") {
onStatusRef.current?.(String(msg.state || ""), msg.message);
if (msg.state === "connected") {
if (msg.state === "connected" || msg.state === "connecting") {
maybeFocus();
return;
}
@ -243,27 +389,41 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
};
// When focused, xterm onData sends keystrokes.
const dataDisposable = term.onData((data) => {
// Normalize Backspace: xterm emits DEL(0x7f); devices expect BS(0x08) like default SecureCRT.
const normalized = data.replace(/\x7f/g, "\x08");
sendStdin(normalized);
// Large pastes from xterm arrive as one onData blob.
if (normalized.length > 32 || normalized.includes("\r") || normalized.includes("\n")) {
sendStdinThrottled(normalized);
} else {
sendStdinImmediate(normalized);
}
});
const selDisposable = term.onSelectionChange(() => {
if (!copyOnSelectRef.current) return;
const sel = term.getSelection();
if (sel && navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(sel).catch(() => undefined);
}
});
// When NOT focused (sidebar still focused after click), capture keys and forward
// so the first character / Backspace are not lost to the browser.
const onKeyDownCapture = (e: KeyboardEvent) => {
if (!autoFocusRef.current) return;
if (host.closest("[hidden]")) return;
if (isSidebarSearchTarget(e.target)) return;
// Always block browser "Backspace = history back" while this session pane is active.
if ((e.ctrlKey || e.metaKey) && (e.key === "f" || e.key === "F")) {
e.preventDefault();
setFindOpen(true);
window.setTimeout(() => findInputRef.current?.focus(), 0);
return;
}
if (e.key === "Backspace") {
e.preventDefault();
}
if (isXtermTextarea(e.target)) {
// Let xterm onData handle it; we only prevented browser back above.
maybeFocus();
return;
}
@ -274,10 +434,31 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
e.preventDefault();
e.stopPropagation();
maybeFocus();
sendStdin(data);
sendStdinImmediate(data);
};
window.addEventListener("keydown", onKeyDownCapture, true);
const onContextMenu = (e: MouseEvent) => {
e.preventDefault();
setCtxMenu({ x: e.clientX, y: e.clientY });
};
host.addEventListener("contextmenu", onContextMenu);
const onPasteCapture = (e: ClipboardEvent) => {
if (!autoFocusRef.current) return;
if (host.closest("[hidden]")) return;
if (isSidebarSearchTarget(e.target)) return;
const text = e.clipboardData?.getData("text") || "";
if (!text) return;
// Intercept paste so we always apply delay (even if focus is odd).
if (document.activeElement === term.textarea || host.contains(document.activeElement) || autoFocusRef.current) {
e.preventDefault();
sendStdinThrottled(text);
maybeFocus();
}
};
window.addEventListener("paste", onPasteCapture, true);
const pingTimer = window.setInterval(() => {
sendJson({ type: "ping" });
}, 25000);
@ -297,8 +478,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
window.clearInterval(pingTimer);
window.removeEventListener("resize", onWinResize);
window.removeEventListener("keydown", onKeyDownCapture, true);
window.removeEventListener("paste", onPasteCapture, true);
host.removeEventListener("contextmenu", onContextMenu);
ro?.disconnect();
dataDisposable.dispose();
selDisposable.dispose();
try {
ws.close();
} catch {
@ -311,13 +495,147 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
};
}, [wsUrl, title]);
const pasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
sendStdinThrottled(text);
} catch {
/* ignore */
}
setCtxMenu(null);
focusTerminal();
};
const copySelection = async () => {
const text = termRef.current?.getSelection() || "";
if (text && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
}
setCtxMenu(null);
};
return (
<div
className="webcrt-term"
ref={hostRef}
onMouseDown={() => {
focusTerminal();
}}
/>
<div className="webcrt-term-wrap">
{pasteStatus ? (
<div className="webcrt-paste-status" role="status" aria-live="polite">
粘贴中 {pasteStatus.done}/{pasteStatus.total} 行…
</div>
) : null}
{findOpen ? (
<div className="webcrt-find">
<input
ref={findInputRef}
type="search"
value={findQuery}
placeholder="Find…"
onChange={(e) => setFindQuery(e.target.value)}
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));
}
}
} else if (e.key === "Escape") {
setFindOpen(false);
focusTerminal();
}
}}
/>
<button
type="button"
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));
}
}}
>
↑
</button>
<button
type="button"
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));
}
}}
>
↓
</button>
<button
type="button"
onClick={() => {
setFindOpen(false);
focusTerminal();
}}
>
×
</button>
</div>
) : null}
<div
className="webcrt-term"
ref={hostRef}
onMouseDown={() => {
focusTerminal();
setCtxMenu(null);
}}
/>
{ctxMenu ? (
<div
className="webcrt-ctx"
style={{ left: ctxMenu.x, top: ctxMenu.y }}
onMouseDown={(e) => e.stopPropagation()}
>
<button type="button" onClick={() => void copySelection()}>
Copy
</button>
<button type="button" onClick={() => void pasteFromClipboard()}>
Paste
</button>
<button
type="button"
onClick={() => {
termRef.current?.clear();
termRef.current?.write("\x1b[H\x1b[2J");
setCtxMenu(null);
}}
>
Clear
</button>
</div>
) : null}
</div>
);
});

View file

@ -939,6 +939,25 @@ const en = {
copied: "Copied to clipboard",
copyEmpty: "Terminal is empty",
copyFailed: "Copy failed",
break: "Break",
breakSent: "Break sent",
broadcast: "Broadcast",
broadcastSent: "Sent to all sessions",
postLogin: "Post-login cmds",
},
favorites: "Favorites",
recent: "Recent",
favorite: "Toggle favorite",
encoding: "Encoding",
fontSize: "Font",
broadcastPh: "Send to all tabs…",
buttonBarHint: "One per line: label|command",
postLoginPrompt: "Post-login commands (one per line, this device)",
postLoginSaved: "Post-login commands saved",
sftp: {
refresh: "Refresh",
upload: "Upload",
uploaded: "Upload complete",
},
err: {
sessionLimit: "Session limit reached; close other terminals first",
@ -946,6 +965,8 @@ const en = {
credsIncomplete: "Incomplete username/password on this NE",
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)",
sftpSsh: "SFTP requires SSH",
},
},
topology: {

View file

@ -936,6 +936,25 @@ const zh = {
copied: "已复制到剪贴板",
copyEmpty: "终端内容为空",
copyFailed: "复制失败",
break: "Break",
breakSent: "已发送 Break",
broadcast: "广播",
broadcastSent: "已广播到全部会话",
postLogin: "登录后命令",
},
favorites: "收藏",
recent: "最近",
favorite: "收藏/取消",
encoding: "编码",
fontSize: "字号",
broadcastPh: "发送到全部标签…",
buttonBarHint: "每行:按钮名|命令",
postLoginPrompt: "登录后自动执行(每行一条,仅本设备)",
postLoginSaved: "登录后命令已保存",
sftp: {
refresh: "刷新",
upload: "上传",
uploaded: "上传成功",
},
err: {
sessionLimit: "并发会话已达上限,请先关闭其他终端",
@ -943,6 +962,8 @@ const zh = {
credsIncomplete: "网元账号或密码不完整",
cliProfile: "UME 网元需要先配置 CLI 连接模板(默认账号/跳板)",
connectFailed: "登录设备失败:{{detail}}",
sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH",
sftpSsh: "SFTP 仅支持 SSH 协议",
},
},
topology: {

View file

@ -2289,7 +2289,8 @@ pre {
display: none !important;
}
.webcrt-main__pane .webcrt-term {
.webcrt-main__pane .webcrt-term,
.webcrt-main__pane .webcrt-term-wrap {
flex: 1;
width: 100%;
height: auto !important;
@ -2332,6 +2333,284 @@ pre {
display: none;
}
.webcrt-term-wrap {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.webcrt-term-wrap .webcrt-term {
flex: 1;
min-height: 0;
}
.webcrt-paste-status {
position: absolute;
top: 8px;
right: 10px;
z-index: 4;
padding: 4px 10px;
font-size: 12px;
color: #e2e8f0;
background: rgba(15, 23, 42, 0.92);
border: 1px solid #475569;
border-radius: 2px;
pointer-events: none;
}
.webcrt-find {
display: flex;
gap: 4px;
align-items: center;
padding: 4px 8px;
background: #1e293b;
border-bottom: 1px solid #334155;
z-index: 2;
}
.webcrt-find input {
flex: 1;
min-width: 0;
background: #0f172a;
border: 1px solid #475569;
color: #e2e8f0;
padding: 2px 8px;
font-size: 12px;
}
.webcrt-find button {
background: #334155;
border: 1px solid #475569;
color: #e2e8f0;
padding: 2px 8px;
cursor: pointer;
}
.webcrt-ctx {
position: fixed;
z-index: 50;
min-width: 120px;
background: #1e293b;
border: 1px solid #475569;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
display: flex;
flex-direction: column;
padding: 4px 0;
}
.webcrt-ctx button {
background: transparent;
border: 0;
color: #e2e8f0;
text-align: left;
padding: 6px 14px;
cursor: pointer;
font-size: 12px;
}
.webcrt-ctx button:hover {
background: #334155;
}
.webcrt-tree__row {
display: flex;
align-items: stretch;
gap: 0;
}
.webcrt-tree__fav {
flex: 0 0 22px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 0;
color: #94a3b8;
cursor: pointer;
padding: 0;
}
.webcrt-tree__fav:hover {
color: #fbbf24;
}
.webcrt-tree__row .webcrt-tree__item {
flex: 1;
min-width: 0;
}
.webcrt-sidebar__pins {
max-height: 28%;
overflow: auto;
border-bottom: 1px solid #334155;
padding-bottom: 4px;
}
.webcrt-sidebar__pin-title {
font-size: 11px;
color: #94a3b8;
padding: 6px 10px 2px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.webcrt-sidebar__prefs {
display: flex;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid #334155;
font-size: 12px;
color: #cbd5e1;
}
.webcrt-sidebar__prefs label {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.webcrt-sidebar__prefs select,
.webcrt-sidebar__prefs input {
background: #0f172a;
border: 1px solid #475569;
color: #e2e8f0;
padding: 2px 6px;
font-size: 12px;
}
.webcrt-buttonbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
padding: 4px 8px;
background: #1e293b;
border-bottom: 1px solid #334155;
}
.webcrt-buttonbar__btn {
background: #334155;
border: 1px solid #475569;
color: #e2e8f0;
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
border-radius: 2px;
}
.webcrt-buttonbar__btn:hover {
background: #475569;
}
.webcrt-broadcast {
display: flex;
gap: 4px;
margin-left: auto;
align-items: center;
}
.webcrt-broadcast input {
width: 160px;
background: #0f172a;
border: 1px solid #475569;
color: #e2e8f0;
padding: 2px 6px;
font-size: 11px;
}
.webcrt-broadcast button {
background: #0ea5e9;
border: 0;
color: #0f172a;
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
}
.webcrt-buttonbar-edit {
padding: 6px 8px;
background: #0f172a;
border-bottom: 1px solid #334155;
}
.webcrt-buttonbar-edit textarea {
width: 100%;
background: #1e293b;
border: 1px solid #475569;
color: #e2e8f0;
font-family: Consolas, monospace;
font-size: 12px;
padding: 6px;
}
.webcrt-sftp {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: min(320px, 40%);
background: #0f172a;
border-left: 1px solid #334155;
z-index: 3;
display: flex;
flex-direction: column;
}
.webcrt-sftp__bar {
display: flex;
gap: 4px;
padding: 6px;
border-bottom: 1px solid #334155;
}
.webcrt-sftp__bar input {
flex: 1;
min-width: 0;
background: #1e293b;
border: 1px solid #475569;
color: #e2e8f0;
padding: 2px 6px;
font-size: 12px;
}
.webcrt-sftp__bar button,
.webcrt-sftp__upload {
background: #334155;
border: 1px solid #475569;
color: #e2e8f0;
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
}
.webcrt-sftp__list {
list-style: none;
margin: 0;
padding: 0;
overflow: auto;
flex: 1;
}
.webcrt-sftp__list button {
width: 100%;
text-align: left;
background: transparent;
border: 0;
border-bottom: 1px solid #1e293b;
color: #e2e8f0;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
}
.webcrt-sftp__list button:hover {
background: #1e293b;
}
@media (max-width: 900px) {
.webcrt-shell {
grid-template-columns: 1fr;

View file

@ -9,6 +9,9 @@ import {
createWebcrtSession,
fetchCliTargets,
fetchManagedNeById,
webcrtSftpDownload,
webcrtSftpList,
webcrtSftpUpload,
webcrtWsUrl,
} from "../services/api";
import { pageCount } from "../utils/display";
@ -50,6 +53,8 @@ function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record<string,
if (raw.includes("credentials_incomplete") || raw.includes("cli_username_required")) return t("webcrt.err.credsIncomplete");
if (raw.includes("cli_connect_profile_not_configured")) return t("webcrt.err.cliProfile");
if (raw.includes("connect_failed")) return t("webcrt.err.connectFailed", { detail: raw.replace(/^.*connect_failed:/, "") });
if (raw.includes("sftp_hop_not_supported")) return t("webcrt.err.sftpHop");
if (raw.includes("sftp_requires_ssh")) return t("webcrt.err.sftpSsh");
return raw;
}
@ -76,6 +81,9 @@ export function WebcrtPage() {
const [page, setPage] = useState(1);
const [tabs, setTabs] = useState<TermTab[]>([]);
const [activeTabKey, setActiveTabKey] = useState("");
const [sftpOpen, setSftpOpen] = useState(false);
const [sftpPath, setSftpPath] = useState(".");
const [sftpItems, setSftpItems] = useState<Array<{ name: string; size: number; mtime: number; is_dir: boolean }>>([]);
const connectingKeysRef = useRef<Set<string>>(new Set());
const tabsRef = useRef<TermTab[]>([]);
tabsRef.current = tabs;
@ -145,8 +153,18 @@ export function WebcrtPage() {
const rows = Math.max(24, Math.floor((window.innerHeight - 180) / 18));
const body =
target.source === "ume"
? { ume_ne_id: target.ume_ne_id || target.id, cols, rows }
: { ne_id: target.id, cols, rows };
? {
ume_ne_id: target.ume_ne_id || target.id,
cols,
rows,
async_connect: true,
}
: {
ne_id: target.id,
cols,
rows,
async_connect: true,
};
const sess = await createWebcrtSession(body);
const wsUrl = webcrtWsUrl(sess.session_id);
updateTab(key, {
@ -228,7 +246,9 @@ export function WebcrtPage() {
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
if (!tab) return;
try {
const text = await termRefs.current.get(tab.key)?.copyAll();
const handle = termRefs.current.get(tab.key);
const sel = await handle?.copySelection();
const text = sel || (await handle?.copyAll());
if (text) showOk(t("webcrt.actions.copied"));
else showError(t("webcrt.actions.copyEmpty"));
} catch {
@ -236,6 +256,22 @@ export function WebcrtPage() {
}
}, [activeTabKey, showOk, showError, t]);
const refreshSftp = useCallback(async () => {
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
if (!tab) return;
try {
const body =
tab.target.source === "ume"
? { ume_ne_id: tab.target.ume_ne_id || tab.target.id, path: sftpPath }
: { ne_id: tab.target.id, path: sftpPath };
const res = await webcrtSftpList(body);
setSftpItems(res.items || []);
setSftpPath(res.path || sftpPath);
} catch (err) {
showError(webcrtErrorMessage(err, t));
}
}, [activeTabKey, showError, sftpPath, t]);
// Auto-connect from /webcrt?ne_id=...
useEffect(() => {
if (!presetNeId) return;
@ -292,6 +328,35 @@ export function WebcrtPage() {
const activeTab = tabs.find((x) => x.key === activeTabKey) || null;
const renderDeviceRow = (row: CliTargetItem) => {
const key = targetKey(row);
const tab = tabs.find((x) => x.key === key);
const isConnecting = tab?.status === "connecting" || connectingKeysRef.current.has(key);
return (
<li key={key}>
<button
type="button"
className={`webcrt-tree__item${activeTabKey === key ? " is-active" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => void openTarget(row)}
title={`${deviceLabel(row)}\n${row.ip_address}\n${row.source}`}
>
<span className="webcrt-tree__icon" aria-hidden>
<ComputerIcon />
</span>
<span className="webcrt-tree__label">
<span className="webcrt-tree__name">{deviceLabel(row)}</span>
<span className="webcrt-tree__meta">
{row.ip_address || row.source}
{isConnecting ? ` · ${t("webcrt.status.connecting")}` : ""}
{tab?.status === "connected" ? ` · ${t("webcrt.status.connected")}` : ""}
</span>
</span>
</button>
</li>
);
};
return (
<div className="webcrt-shell">
<aside className="webcrt-sidebar">
@ -326,39 +391,7 @@ export function WebcrtPage() {
{!targetsQuery.isLoading && items.length === 0 ? (
<div className="webcrt-tree__empty">{t("webcrt.empty")}</div>
) : null}
<ul className="webcrt-tree__list">
{items.map((row) => {
const key = targetKey(row);
const tab = tabs.find((x) => x.key === key);
const isConnecting = tab?.status === "connecting" || connectingKeysRef.current.has(key);
return (
<li key={key}>
<button
type="button"
className={`webcrt-tree__item${activeTabKey === key ? " is-active" : ""}`}
onMouseDown={(e) => {
// Keep focus off the list button so Enter/Backspace go to the CRT.
e.preventDefault();
}}
onClick={() => void openTarget(row)}
title={`${deviceLabel(row)}\n${row.ip_address}\n${row.source}`}
>
<span className="webcrt-tree__icon" aria-hidden>
<ComputerIcon />
</span>
<span className="webcrt-tree__label">
<span className="webcrt-tree__name">{deviceLabel(row)}</span>
<span className="webcrt-tree__meta">
{row.ip_address || row.source}
{isConnecting ? ` · ${t("webcrt.status.connecting")}` : ""}
{tab?.status === "connected" ? ` · ${t("webcrt.status.connected")}` : ""}
</span>
</span>
</button>
</li>
);
})}
</ul>
<ul className="webcrt-tree__list">{items.map((row) => renderDeviceRow(row))}</ul>
</div>
<div className="webcrt-sidebar__pager">
<span>{t("common.pagerMeta", { total, page, pages })}</span>
@ -435,6 +468,16 @@ export function WebcrtPage() {
<span aria-hidden>⧉</span>
{t("webcrt.actions.copy")}
</button>
<button
type="button"
className={`webcrt-action-btn${sftpOpen ? " is-active" : ""}`}
onClick={() => {
setSftpOpen((v) => !v);
if (!sftpOpen) void refreshSftp();
}}
>
SFTP
</button>
</div>
) : null}
<div className="webcrt-main__body">
@ -476,6 +519,8 @@ export function WebcrtPage() {
if (activeTabKey === tab.key) {
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
}
} else if (state === "connecting") {
updateTab(tab.key, { status: "connecting" });
} else if (state === "error") updateTab(tab.key, { status: "error" });
else if (state === "closed") updateTab(tab.key, { status: "closed" });
}}
@ -483,6 +528,89 @@ export function WebcrtPage() {
) : null}
</div>
))}
{sftpOpen && activeTab ? (
<div className="webcrt-sftp">
<div className="webcrt-sftp__bar">
<input value={sftpPath} onChange={(e) => setSftpPath(e.target.value)} />
<button type="button" onClick={() => void refreshSftp()}>
{t("webcrt.sftp.refresh")}
</button>
<label className="webcrt-sftp__upload">
{t("webcrt.sftp.upload")}
<input
type="file"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (!file || !activeTab) return;
const remote = `${sftpPath.replace(/\/$/, "")}/${file.name}`.replace(/^\.\//, "");
void (async () => {
try {
const body =
activeTab.target.source === "ume"
? {
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
remote_path: remote,
file,
}
: { ne_id: activeTab.target.id, remote_path: remote, file };
await webcrtSftpUpload(body);
showOk(t("webcrt.sftp.uploaded"));
await refreshSftp();
} catch (err) {
showError(webcrtErrorMessage(err, t));
}
})();
e.target.value = "";
}}
/>
</label>
</div>
<ul className="webcrt-sftp__list">
{sftpItems.map((it) => (
<li key={it.name}>
<button
type="button"
onClick={() => {
if (it.is_dir) {
const next = `${sftpPath.replace(/\/$/, "")}/${it.name}`.replace(/^\.\//, "");
setSftpPath(next);
window.setTimeout(() => void refreshSftp(), 0);
return;
}
void (async () => {
try {
const body =
activeTab.target.source === "ume"
? {
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
path: `${sftpPath.replace(/\/$/, "")}/${it.name}`,
}
: {
ne_id: activeTab.target.id,
path: `${sftpPath.replace(/\/$/, "")}/${it.name}`,
};
const blob = await webcrtSftpDownload(body);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = it.name;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
showError(webcrtErrorMessage(err, t));
}
})();
}}
>
{it.is_dir ? "📁" : "📄"} {it.name}
{!it.is_dir ? ` (${it.size})` : ""}
</button>
</li>
))}
</ul>
</div>
) : null}
</div>
</>
) : (

View file

@ -459,6 +459,8 @@ export type WebcrtSessionCreateResult = {
cols: number;
rows: number;
ws_path: string;
encoding?: string;
state?: string;
/** True when session used a vendor CLI hop (nested stelnet/telnet). */
cli_hop?: boolean;
};
@ -468,6 +470,9 @@ export const createWebcrtSession = (body: {
ume_ne_id?: string;
cols?: number;
rows?: number;
encoding?: string;
post_login_commands?: string[];
async_connect?: boolean;
}) => apiPost<WebcrtSessionCreateResult>("/v1/webcrt/sessions", body);
export const closeWebcrtSession = (sessionId: string) =>
@ -475,6 +480,66 @@ export const closeWebcrtSession = (sessionId: string) =>
`/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`,
);
export type WebcrtSftpListResult = {
ne_id: string;
ne_name: string;
path: string;
items: Array<{ name: string; size: number; mtime: number; is_dir: boolean }>;
};
export const webcrtSftpList = (body: { ne_id?: string; ume_ne_id?: string; path?: string }) =>
apiPost<WebcrtSftpListResult>("/v1/webcrt/sftp/list", body);
export async function webcrtSftpDownload(body: {
ne_id?: string;
ume_ne_id?: string;
path: string;
}): Promise<Blob> {
const path = "/v1/webcrt/sftp/download";
const res = await fetch(path, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(body),
});
if (res.status === 401) {
handleUnauthorized(path);
throw new Error("401 unauthorized");
}
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
return res.blob();
}
export async function webcrtSftpUpload(body: {
ne_id?: string;
ume_ne_id?: string;
remote_path: string;
file: File;
}): Promise<{ ok: boolean; path: string; size: number }> {
const path = "/v1/webcrt/sftp/upload";
const fd = new FormData();
if (body.ne_id) fd.append("ne_id", body.ne_id);
if (body.ume_ne_id) fd.append("ume_ne_id", body.ume_ne_id);
fd.append("remote_path", body.remote_path);
fd.append("file", body.file);
const res = await fetch(path, {
method: "POST",
headers: authHeaders(),
body: fd,
});
if (res.status === 401) {
handleUnauthorized(path);
throw new Error("401 unauthorized");
}
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
return (await res.json()) as { ok: boolean; path: string; size: number };
}
export const webcrtWsUrl = (sessionId: string): string => {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const path = `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`;