import { useEffect, useRef, useState, useCallback } from 'react'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { Terminal as TerminalIcon, X } from 'lucide-react'; import { Button } from './ui/button'; import '@xterm/xterm/css/xterm.css'; import { useNodes } from '@/context/NodeContext'; interface HostConsoleProps { stackName?: string | null; onClose: () => void; } export default function HostConsole({ stackName, onClose }: HostConsoleProps) { const { activeNode } = useNodes(); const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const wsRef = useRef(null); const [isConnected, setIsConnected] = useState(false); const cleanup = useCallback(() => { if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } if (xtermRef.current) { xtermRef.current.dispose(); xtermRef.current = null; } fitAddonRef.current = null; setIsConnected(false); }, []); useEffect(() => { const container = terminalRef.current; if (!container) return; let mounted = true; const term = new Terminal({ theme: { background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#ffffff', cursorAccent: '#000000', selectionBackground: 'rgba(255, 255, 255, 0.3)', }, fontFamily: "'Geist Mono', monospace", fontSize: 14, cursorBlink: true, }); const fitAddon = new FitAddon(); term.loadAddon(fitAddon); term.open(container); xtermRef.current = term; fitAddonRef.current = fitAddon; requestAnimationFrame(() => { try { if (mounted) fitAddon.fit(); } catch { // Ignore fit errors during initial render } }); const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const activeNodeId = localStorage.getItem('sencho-active-node') || ''; const nodeParam = activeNodeId ? `nodeId=${activeNodeId}` : ''; const stackParam = stackName ? `stack=${encodeURIComponent(stackName)}` : ''; const queryString = [nodeParam, stackParam].filter(Boolean).join('&'); const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${queryString ? `?${queryString}` : ''}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { if (!mounted) return; setIsConnected(true); term.focus(); setTimeout(() => { try { if (mounted) fitAddon.fit(); } catch { // Ignore } if (ws.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) { ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows, })); } }, 100); }; ws.onmessage = (event) => { if (!mounted) return; const text = typeof event.data === 'string' ? event.data : event.data.toString(); term.write(text); }; ws.onerror = () => { if (!mounted) return; term.write('\r\n\x1b[31mConnection error\x1b[0m\r\n'); setIsConnected(false); }; ws.onclose = () => { if (!mounted) return; term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n'); setIsConnected(false); }; term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'input', payload: data, })); } }); let resizeTimeout: ReturnType; const resizeObserver = new ResizeObserver(() => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { if (!mounted || !fitAddonRef.current || !wsRef.current) return; try { fitAddonRef.current.fit(); } catch { return; } if (wsRef.current.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) { wsRef.current.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows, })); } }, 50); }); resizeObserver.observe(container); return () => { mounted = false; resizeObserver.disconnect(); clearTimeout(resizeTimeout); cleanup(); }; }, [stackName, cleanup]); return (
Host Console {activeNode && ( - {activeNode.name} )} {stackName && ( ({stackName}) )} {isConnected && ( Connected )}
); }