feat(webcrt): add CRT-style browser terminal for managed and UME NEs

Ship an ops WebCRT module with xterm.js UI, WebSocket session bridge reusing hop/bastion login, searchable paged targets, and session audit limits.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-29 17:23:45 +08:00
parent 88479245bb
commit 873f2bfe34
21 changed files with 1868 additions and 45 deletions

View file

@ -0,0 +1,142 @@
import { useEffect, useRef } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
type Props = {
wsUrl: string;
onStatus?: (state: string, message?: string) => void;
onReady?: () => void;
};
export function WebTerminal({ wsUrl, onStatus, onReady }: Props) {
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 onStatusRef = useRef(onStatus);
const onReadyRef = useRef(onReady);
useEffect(() => {
onStatusRef.current = onStatus;
onReadyRef.current = onReady;
}, [onStatus, onReady]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: 'Consolas, "Courier New", monospace',
theme: {
background: "#0f172a",
foreground: "#e2e8f0",
cursor: "#93c5fd",
},
convertEol: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(host);
fit.fit();
termRef.current = term;
fitRef.current = fit;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
onStatusRef.current?.("connecting");
const sendJson = (payload: Record<string, unknown>) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
};
const sendResize = () => {
fit.fit();
sendJson({ type: "resize", cols: term.cols, rows: term.rows });
};
ws.onopen = () => {
onStatusRef.current?.("open");
sendResize();
onReadyRef.current?.();
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(String(ev.data || "{}")) as {
type?: string;
data?: string;
state?: string;
message?: string;
};
if (msg.type === "stdout" && typeof msg.data === "string") {
term.write(msg.data);
return;
}
if (msg.type === "status") {
onStatusRef.current?.(String(msg.state || ""), msg.message);
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`);
}
return;
}
if (msg.type === "pong") return;
} catch {
term.write(String(ev.data || ""));
}
};
ws.onerror = () => {
onStatusRef.current?.("error", "websocket_error");
};
ws.onclose = () => {
onStatusRef.current?.("closed", "websocket_closed");
};
const dataDisposable = term.onData((data) => {
sendJson({ type: "stdin", data });
});
const pingTimer = window.setInterval(() => {
sendJson({ type: "ping" });
}, 25000);
const onWinResize = () => sendResize();
window.addEventListener("resize", onWinResize);
const ro =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(() => {
sendResize();
})
: null;
ro?.observe(host);
return () => {
window.clearInterval(pingTimer);
window.removeEventListener("resize", onWinResize);
ro?.disconnect();
dataDisposable.dispose();
try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "close" }));
}
ws.close();
} catch {
/* ignore */
}
wsRef.current = null;
term.dispose();
termRef.current = null;
fitRef.current = null;
};
}, [wsUrl]);
return <div className="webcrt-term" ref={hostRef} />;
}