mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 14:10:44 +08:00
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:
parent
88479245bb
commit
873f2bfe34
21 changed files with 1868 additions and 45 deletions
142
web/src/components/WebTerminal.tsx
Normal file
142
web/src/components/WebTerminal.tsx
Normal 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} />;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue