import { useState, useRef, useEffect, useCallback } from 'react'; import { Dialog, DialogContent, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Terminal as XTerm } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; import 'xterm/css/xterm.css'; import { Terminal, Maximize2, Minimize2, X, Loader2, RotateCw, Box, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { kubernetesApi } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; /** * Pod 级 exec 终端 — 进入容器执行命令 (kubectl exec -it) * * 复用 KubectlTerminal.tsx 的 xterm.js 模式,连接 Pod exec WebSocket 通道: * ws://host/ws/kubernetes-exec/:clusterId/namespaces/:ns/pods/:pod?container=&token= * * 支持: * - 容器选择下拉(默认第一个容器,可切换) * - 自定义 shell(默认 bash,可切 sh) * - Dialog/Modal 形式,全屏切换 * - 终端 resize 透传(ResizeObserver + fit) * * @author Logan */ interface PodExecTerminalProps { open: boolean; onOpenChange: (open: boolean) => void; /** 集群 ID(用于 ws 路由 :clusterId) */ clusterId: number | string; /** 集群 code(备用,部分后端用 code) */ clusterCode?: string; namespace: string; podName: string; /** 可选:Pod 容器名列表,缺省时仅显示 shell 选择 */ containers?: string[]; /** 初始容器 */ initialContainer?: string; } type ConnState = 'idle' | 'connecting' | 'connected' | 'error' | 'closed'; const SHELLS = [ { value: 'bash', label: '/bin/bash' }, { value: 'sh', label: '/bin/sh' }, { value: 'sh', label: '/bin/ash' }, ]; export function PodExecTerminal({ open, onOpenChange, clusterId, namespace, podName, containers = [], initialContainer, }: PodExecTerminalProps) { const { toast } = useToast(); const containerRef = useRef(null); const termRef = useRef(null); const wsRef = useRef(null); const fitRef = useRef(null); const [connState, setConnState] = useState('idle'); const [fullscreen, setFullscreen] = useState(false); const [container, setContainer] = useState(initialContainer || containers[0] || ''); const [shell, setShell] = useState('bash'); // 重置全屏状态 useEffect(() => { if (!open) setFullscreen(false); }, [open]); // 容器列表变化时同步默认值 useEffect(() => { if (open && !container && containers.length > 0) { setContainer(initialContainer || containers[0]); } }, [open, containers, initialContainer, container]); // 创建 xterm 实例(仅在 open 时创建一次,容器/shell 变化时重连 ws) const connect = useCallback(() => { if (!termRef.current) return; const term = termRef.current; // 关闭旧连接 if (wsRef.current) { try { wsRef.current.close(); } catch { /* noop */ } wsRef.current = null; } setConnState('connecting'); term.clear(); term.writeln(`\x1b[36m[正在连接 ${podName}${container ? '/' + container : ''} ...]\x1b[0m`); const wsUrl = kubernetesApi.buildPodExecWsUrl(clusterId, namespace, podName, container || undefined, shell); const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { setConnState('connected'); term.reset(); // 发送初始 resize(command/shell 已在 WS URL 参数中指定) try { const { cols, rows } = term; ws.send(JSON.stringify({ status: 1, cols, rows })); } catch { /* noop */ } setTimeout(() => { try { term.focus(); } catch { /* noop */ } }, 100); }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.status === 0 && typeof msg.data === 'string') { term.write(msg.data); } else if (msg.status === 1) { // 服务端 resize 确认,忽略 } else if (msg.status === 2 || msg.error) { const errMsg = msg.data || msg.message || msg.error || '执行错误'; term.write(errMsg.includes('\\x1b') ? errMsg : `\\r\\n\\x1b[31m${errMsg}\\x1b[0m\\r\\n`); } } catch { if (typeof event.data === 'string') term.write(event.data); } }; ws.onclose = () => { setConnState((s) => (s === 'error' ? s : 'closed')); term.write('\r\n\x1b[33m[连接已断开]\x1b[0m\r\n'); }; ws.onerror = () => { setConnState('error'); term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n'); }; }, [clusterId, namespace, podName, container, shell]); // 创建/销毁 xterm + 绑定 onData(生命周期跟随 open) useEffect(() => { if (!open || !clusterId || !podName) return; const term = new XTerm({ cursorBlink: true, fontSize: 14, fontFamily: 'Menlo, Monaco, "Courier New", monospace', allowProposedApi: true, scrollback: 5000, theme: { background: '#0f172a', foreground: '#e2e8f0', cursor: '#38bdf8', selectionBackground: '#1e40af', black: '#1e293b', red: '#ef4444', green: '#10b981', yellow: '#f59e0b', blue: '#3b82f6', magenta: '#a855f7', cyan: '#06b6d4', white: '#e2e8f0', brightBlack: '#475569', brightRed: '#f87171', brightGreen: '#34d399', brightYellow: '#fbbf24', brightBlue: '#60a5fa', brightMagenta: '#c084fc', brightCyan: '#22d3ee', brightWhite: '#f8fafc', }, }); const fit = new FitAddon(); term.loadAddon(fit); termRef.current = term; fitRef.current = fit; const onData = (data: string) => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ status: 0, data })); } }; term.onData(onData); if (containerRef.current) { term.open(containerRef.current); requestAnimationFrame(() => { try { fit.fit(); } catch { /* noop */ } }); } // 首次连接 connect(); return () => { try { if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) { wsRef.current.close(); } } catch { /* noop */ } try { term.dispose(); } catch { /* noop */ } termRef.current = null; wsRef.current = null; fitRef.current = null; setConnState('idle'); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, clusterId, podName]); // 容器 / shell 变化时重连(需 term 已就绪) useEffect(() => { if (open && termRef.current) { connect(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [container, shell]); // ResizeObserver 监听容器变化自动 fit + 透传 resize useEffect(() => { if (!open) return; const containerEl = containerRef.current; if (!containerEl) return; let resizeTimer: ReturnType; const ro = new ResizeObserver(() => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { if (fitRef.current && termRef.current && wsRef.current) { try { fitRef.current.fit(); termRef.current.focus(); const { cols, rows } = termRef.current; if (wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ status: 1, cols, rows })); } } catch { /* noop */ } } }, 60); }); ro.observe(containerEl); return () => { clearTimeout(resizeTimer); ro.disconnect(); }; }, [open, fullscreen]); const statusColor = connState === 'connected' ? 'bg-emerald-400' : connState === 'error' ? 'bg-red-400' : connState === 'closed' ? 'bg-slate-500' : 'bg-amber-400'; const statusText = connState === 'connected' ? '已连接' : connState === 'connecting' ? '连接中' : connState === 'error' ? '连接错误' : connState === 'closed' ? '已断开' : '空闲'; return ( e.preventDefault()} className={cn( 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0', '!duration-0 !transition-none', fullscreen ? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0' : '' )} style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '960px', width: '92vw' }} > exec — {podName} 进入 Pod 容器执行命令 {/* 头部工具栏 */}
exec — {podName} {connState === 'connecting' && } {statusText}
{/* 容器选择 */} {containers.length > 0 && ( )} {/* Shell 选择 */} {/* 重连 */}
{/* 终端容器 */}
); }