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 { attachTerminalResize, mountXterm } from '@/lib/xterm-utils'; import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay'; import { kubernetesApi } from '@/api'; import { useToast } from '@/hooks/use-toast'; import i18n from '@/i18n'; export interface PodExecTerminalProps { open: boolean; onOpenChange: (open: boolean) => void; clusterId: number | string; namespace: string; podName: string; containers?: string[]; initialContainer?: string; } type ConnState = 'idle' | 'connecting' | 'connected' | 'error' | 'closed'; const SHELLS = [ { value: 'sh', label: '/bin/sh' }, { value: 'bash', label: '/bin/bash' }, ]; 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 mountedRef = useRef(false); const errorShownRef = useRef(false); const [connState, setConnState] = useState('idle'); const [fullscreen, setFullscreen] = useState(false); const [container, setContainer] = useState(initialContainer || containers[0] || ''); const [shell, setShell] = useState('sh'); const [hasOutput, setHasOutput] = useState(false); // ─── 重置 ─── useEffect(() => { if (!open) { setFullscreen(false); mountedRef.current = false; } }, [open]); useEffect(() => { if (open && !container && containers.length > 0) { setContainer(initialContainer || containers[0]); } }, [open, containers, initialContainer, container]); // ─── 连接 WebSocket ─── const connect = useCallback(() => { if (!termRef.current) { console.error('[PodExec:connect] ABORT: termRef.current is null'); return; } const term = termRef.current; if (wsRef.current) { try { wsRef.current.close(); } catch { /* noop */ } wsRef.current = null; } setConnState('connecting'); setHasOutput(false); term.clear(); term.writeln(`\x1b[36m[${i18n.t('k8s.podExec.connecting')} ${podName}${container ? '/' + container : ''} shell=${shell} ...]\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(); try { const { cols, rows } = term; ws.send(JSON.stringify({ status: 1, cols, rows })); } catch (e) { console.error('[PodExec:connect] resize error:', e); } const tryFocus = () => { try { term.focus(); } catch { /* noop */ } }; tryFocus(); setTimeout(tryFocus, 100); setTimeout(tryFocus, 300); }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.status === 0 && typeof msg.data === 'string') { setHasOutput(true); term.write(msg.data); } else if (msg.status === 2 || msg.error) { setHasOutput(true); const errMsg = msg.data || msg.message || msg.error || i18n.t('k8s.podExec.execError'); if (errMsg.includes('not found') && shell === 'bash') { term.writeln(i18n.t('k8s.podExec.reconnect')); errorShownRef.current = true; return; } term.write(errMsg.includes('\x1b') ? errMsg : `\r\n\x1b[31m${errMsg}\x1b[0m\r\n`); } } catch { if (typeof event.data === 'string') { setHasOutput(true); term.write(event.data); } } }; ws.onclose = (_ev) => { setConnState((s) => (s === 'error' ? s : 'closed')); setHasOutput(true); if (!errorShownRef.current) { term.write(i18n.t('k8s.podExec.close')); } }; ws.onerror = (ev) => { console.error('[PodExec:connect] WS error event fired, readyState=', ws.readyState, 'type=', ev.type); setConnState('error'); setHasOutput(true); term.write(i18n.t('k8s.podExec.sc8107726')); }; }, [clusterId, namespace, podName, container, shell]); // DEBUG: 组件入口 // ─── 创建 xterm + 连接(用 rAF 重试等 Dialog 渲染完)─── useEffect(() => { if (!open) { return; } if (!clusterId) { console.error('[PodExec:initEffect] ABORT: clusterId is falsy:', clusterId); return; } if (!podName) { console.error('[PodExec:initEffect] ABORT: podName is falsy:', podName); return; } let disposed = false; let retryCount = 0; const startTime = performance.now(); const tryInit = () => { if (disposed) { return; } const el = containerRef.current; if (!el) { retryCount++; if (retryCount < 20) { requestAnimationFrame(tryInit); } else { console.error('[PodExec:tryInit] FAILED: containerRef still null after 20 retries (', Math.round(performance.now()-startTime), 'ms)'); } 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; term.onData((data: string) => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ status: 0, data })); } else { console.warn('[PodExec:term] TX dropped — WS not open, readyState=', wsRef.current?.readyState); } }); mountXterm(term, fit, () => el, wsRef.current); mountedRef.current = true; connect(); }; requestAnimationFrame(tryInit); return () => { disposed = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, clusterId, podName]); // ─── cleanup on close ─── useEffect(() => { if (!open) { try { wsRef.current?.close(); } catch { /* noop */ } try { termRef.current?.dispose(); } catch { /* noop */ } termRef.current = null; wsRef.current = null; fitRef.current = null; mountedRef.current = false; setConnState('idle'); } }, [open]); // ─── 容器/shell 变化时重连 ─── useEffect(() => { if (open && termRef.current && mountedRef.current) { connect(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [container, shell]); // ─── ResizeObserver:同步容器尺寸和远端 PTY 尺寸 // Logan.Li ─── useEffect(() => { if (!open) return; let cleanup: (() => void) | undefined; let retry = 0; const attach = () => { const container = containerRef.current; const fit = fitRef.current; const term = termRef.current; if (!container || !fit || !term) { if (retry++ < 30) requestAnimationFrame(attach); return; } cleanup = attachTerminalResize(container, fit, term, wsRef.current); }; attach(); return () => cleanup?.(); }, [open, fullscreen]); // ─── UI ─── const statusColor = connState === 'connected' ? 'bg-emerald-400' : connState === 'error' ? 'bg-red-400' : connState === 'closed' ? 'bg-[var(--dg-bg-active)]' : 'bg-amber-400'; const statusText = connState === 'connected' ? i18n.t('k8s.podExec.connected') : connState === 'connecting' ? i18n.t('k8s.podExec.connecting') : connState === 'error' ? i18n.t('k8s.podExec.connectionError') : connState === 'closed' ? i18n.t('k8s.podExec.disconnected1') : i18n.t('k8s.podExec.idle'); const handleTerminalClick = useCallback(() => { try { termRef.current?.focus(); } catch { /* noop */ } }, []); 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} {i18n.t('k8s.podExec.enterPod')} {/* 工具栏 */}
exec — {podName} {connState === 'connecting' && } {statusText}
{containers.length > 0 && ( )}
{/* 终端容器 */}
); }