Improve WebCRT UX: real login transcript, session tools, and first-input sync.

Capture Netmiko session_log for login replay, open NE terminals in a fresh tab, tidy the device list UI with reconnect/log/clear/copy, and paint a live prompt after attach so the first keystrokes behave normally.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-29 19:53:41 +08:00
parent 30f2db9708
commit 89ff0a7212
11 changed files with 593 additions and 183 deletions

View file

@ -1,27 +1,79 @@
import { useEffect, useRef } from "react";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
export type WebTerminalHandle = {
clear: () => void;
copyAll: () => Promise<string>;
getText: () => string;
fit: () => void;
};
type Props = {
wsUrl: string;
title?: string;
recording?: boolean;
onStatus?: (state: string, message?: string) => void;
onReady?: () => void;
onStdout?: (data: string) => void;
};
export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
function serializeTerminal(term: Terminal): string {
const buf = term.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buf.length; i += 1) {
const line = buf.getLine(i);
if (!line) continue;
lines.push(line.translateToString(true));
}
return lines.join("\n").replace(/\s+$/g, "");
}
export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerminal(
{ wsUrl, title, recording, 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 onStatusRef = useRef(onStatus);
const onReadyRef = useRef(onReady);
const onStdoutRef = useRef(onStdout);
const recordingRef = useRef(!!recording);
useEffect(() => {
onStatusRef.current = onStatus;
onReadyRef.current = onReady;
}, [onStatus, onReady]);
onStdoutRef.current = onStdout;
}, [onStatus, onReady, onStdout]);
useEffect(() => {
recordingRef.current = !!recording;
}, [recording]);
useImperativeHandle(ref, () => ({
clear: () => {
termRef.current?.clear();
termRef.current?.write("\x1b[H\x1b[2J");
},
copyAll: async () => {
const text = termRef.current ? serializeTerminal(termRef.current) : "";
if (text && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
}
return text;
},
getText: () => (termRef.current ? serializeTerminal(termRef.current) : ""),
fit: () => {
try {
fitRef.current?.fit();
} catch {
/* ignore */
}
},
}));
useEffect(() => {
const host = hostRef.current;
@ -32,9 +84,9 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
fontSize: 13,
fontFamily: 'Consolas, "Courier New", monospace',
theme: {
background: "#0f172a",
background: "#0b1220",
foreground: "#e2e8f0",
cursor: "#93c5fd",
cursor: "#e2e8f0",
},
convertEol: true,
});
@ -51,7 +103,6 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
/* ignore */
}
};
// Fit after layout; hidden/zero-size parents need a deferred pass.
requestAnimationFrame(() => {
doFit();
window.setTimeout(doFit, 50);
@ -60,7 +111,6 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
onStatusRef.current?.("connecting");
term.writeln(`\x1b[90mConnecting${title ? ` ${title}` : ""}…\x1b[0m`);
const sendJson = (payload: Record<string, unknown>) => {
if (ws.readyState === WebSocket.OPEN) {
@ -86,21 +136,15 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
data?: string;
state?: string;
message?: string;
ne_name?: string;
ne_ip?: string;
protocol?: string;
};
if (msg.type === "stdout" && typeof msg.data === "string") {
term.write(msg.data);
if (recordingRef.current) onStdoutRef.current?.(msg.data);
return;
}
if (msg.type === "status") {
onStatusRef.current?.(String(msg.state || ""), msg.message);
if (msg.state === "connected") {
const where = [msg.ne_name || title, msg.ne_ip, msg.protocol].filter(Boolean).join(" · ");
term.writeln(`\x1b[90m--- session ready${where ? `: ${where}` : ""} ---\x1b[0m`);
return;
}
if (msg.state === "connected") return;
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`);
@ -109,14 +153,15 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
}
if (msg.type === "pong") return;
} catch {
term.write(String(ev.data || ""));
const raw = String(ev.data || "");
term.write(raw);
if (recordingRef.current) onStdoutRef.current?.(raw);
}
};
ws.onerror = () => {
onStatusRef.current?.("error", "websocket_error");
term.writeln("\r\n\x1b[31m[websocket error]\x1b[0m");
term.writeln("\x1b[33mHint: ensure API has package 'websockets' and is restarted; Vite UI should reach ws://127.0.0.1:8890\x1b[0m");
};
ws.onclose = (ev) => {
@ -150,7 +195,6 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
window.removeEventListener("resize", onWinResize);
ro?.disconnect();
dataDisposable.dispose();
// Do not send {type:"close"} here — React StrictMode remounts and needs reconnect.
try {
ws.close();
} catch {
@ -164,4 +208,4 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
}, [wsUrl, title]);
return <div className="webcrt-term" ref={hostRef} />;
}
});