netx/web/src/components/WebTerminal.tsx
oliver 30f2db9708 fix(webcrt): replay login banner/prompt on attach
Drain and nudge the device prompt after Netmiko login, replay bootstrap stdout when the WebSocket attaches, and surface connect errors in the terminal pane.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 18:54:59 +08:00

167 lines
4.7 KiB
TypeScript

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;
title?: string;
onStatus?: (state: string, message?: string) => void;
onReady?: () => void;
};
export function WebTerminal({ wsUrl, title, 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);
termRef.current = term;
fitRef.current = fit;
const doFit = () => {
try {
fit.fit();
} catch {
/* ignore */
}
};
// Fit after layout; hidden/zero-size parents need a deferred pass.
requestAnimationFrame(() => {
doFit();
window.setTimeout(doFit, 50);
});
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) {
ws.send(JSON.stringify(payload));
}
};
const sendResize = () => {
doFit();
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;
ne_name?: string;
ne_ip?: string;
protocol?: 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 === "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 === "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");
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) => {
onStatusRef.current?.("closed", `websocket_closed:${ev.code}`);
if (!ev.wasClean) {
term.writeln(`\r\n\x1b[33m[websocket closed code=${ev.code}]\x1b[0m`);
}
};
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();
// Do not send {type:"close"} here — React StrictMode remounts and needs reconnect.
try {
ws.close();
} catch {
/* ignore */
}
wsRef.current = null;
term.dispose();
termRef.current = null;
fitRef.current = null;
};
}, [wsUrl, title]);
return <div className="webcrt-term" ref={hostRef} />;
}