import { useEffect, useRef, useState } from 'react' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import '@xterm/xterm/css/xterm.css' import { RefreshCw, TerminalSquare, X } from 'lucide-react' import { createWebSSHTicket } from '../services/api' interface WebSSHViewerProps { containerName: string onClose: () => void } export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerProps) { const terminalRef = useRef(null) const wsRef = useRef(null) const termRef = useRef(null) const fitRef = useRef(null) const resizeObserverRef = useRef(null) const [status, setStatus] = useState<'connecting' | 'preparing' | 'connected' | 'disconnected' | 'error'>('connecting') const [errorMsg, setErrorMsg] = useState('') const buildWebSSHUrl = () => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const params = new URLSearchParams({ container: containerName, }) return `${protocol}//${window.location.host}/api/ssh?${params.toString()}` } const sendResize = () => { const ws = wsRef.current const term = termRef.current if (!ws || !term || ws.readyState !== WebSocket.OPEN) return ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })) } const cleanup = () => { resizeObserverRef.current?.disconnect() resizeObserverRef.current = null if (wsRef.current) { wsRef.current.close() wsRef.current = null } if (termRef.current) { termRef.current.dispose() termRef.current = null fitRef.current = null } } const connect = async () => { if (!terminalRef.current) return cleanup() setStatus('connecting') setErrorMsg('') const term = new Terminal({ cursorBlink: true, convertEol: true, fontFamily: 'Consolas, Menlo, Monaco, monospace', fontSize: 13, theme: { background: '#050505', foreground: '#f3f4f6', cursor: '#ffffff', selectionBackground: '#374151', }, }) const fitAddon = new FitAddon() term.loadAddon(fitAddon) term.open(terminalRef.current) termRef.current = term fitRef.current = fitAddon const fitTerminal = () => { try { fitAddon.fit() sendResize() } catch { // The modal may report zero size during the first paint. Retry below. } } requestAnimationFrame(() => { fitTerminal() window.setTimeout(fitTerminal, 80) window.setTimeout(fitTerminal, 250) }) let ticket = '' try { const response = await createWebSSHTicket(containerName) ticket = response.data.data?.ticket || '' } catch { setStatus('error') setErrorMsg('WebSSH ticket 创建失败,请重新登录后再试') return } if (!ticket) { setStatus('error') setErrorMsg('WebSSH ticket 为空,请重新登录后再试') return } const ws = new WebSocket(buildWebSSHUrl(), [`clicd-ticket.${ticket}`]) ws.binaryType = 'arraybuffer' wsRef.current = ws term.writeln(`Connecting to ${containerName} as root...`) ws.onopen = () => { setStatus('preparing') term.writeln('\r\nWebSocket connected. Preparing SSH shell...') sendResize() term.focus() } ws.onmessage = async (event) => { setStatus('connected') if (event.data instanceof ArrayBuffer) { term.write(new Uint8Array(event.data)) return } if (event.data instanceof Blob) { const buffer = await event.data.arrayBuffer() term.write(new Uint8Array(buffer)) return } term.write(String(event.data)) } ws.onerror = () => { setStatus('error') setErrorMsg('WebSSH 连接失败,请确认容器已运行且 SSH 服务可用') } ws.onclose = () => { if (status !== 'error') { setStatus((current) => current === 'connected' ? 'disconnected' : current) } } term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(new TextEncoder().encode(data)) } }) const observer = new ResizeObserver(() => { fitTerminal() }) observer.observe(terminalRef.current) resizeObserverRef.current = observer } useEffect(() => { const timer = window.setTimeout(connect, 100) return () => { window.clearTimeout(timer) cleanup() } }, [containerName]) return (
WebSSH - {containerName} {status === 'connected' && ( 已连接 )} {status === 'connecting' && ( 连接中... )} {status === 'preparing' && ( SSH preparing... )} {status === 'disconnected' && ( 已断开 )} {status === 'error' && ( 连接失败 )}
{status === 'error' && (
{errorMsg}
)}
) }