mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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:
parent
30f2db9708
commit
89ff0a7212
11 changed files with 593 additions and 183 deletions
|
|
@ -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} />;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue