**kubectl WebShell 修复:** - 引入 github.com/creack/pty 库实现真正的伪终端 - 替换 bash --noediting + TERM=dumb 方案为 PTY-backed bash - 支持 xterm-256color、窗口自适应、readline 交互 - 修复键盘输入无响应问题 **侧边栏优化:** - 独立菜单项高度从 py-1.5 改为 h-8,与父菜单对齐 - 独立菜单图标从 h-3 w-3 改为 h-3.5 w-3.5,统一 14px **构建优化:** - run.sh docker up 增加 docker image prune -f 清理悬空镜像 - 每次部署自动清理 builder cache + dangling images **认证修复(历史):** - Token 存储:Zustand + localStorage 双写,http.ts 双源 fallback - YamlViewer/DeploymentDetail token 来源统一为 Zustand store
212 lines
7.0 KiB
TypeScript
212 lines
7.0 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Terminal as XTerm } from 'xterm';
|
|
import { FitAddon } from 'xterm-addon-fit';
|
|
import 'xterm/css/xterm.css';
|
|
import { Terminal, Maximize2, Minimize2, X, Loader2 } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useUserStore } from '@/store';
|
|
|
|
interface KubectlTerminalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
clusterId: number | string;
|
|
clusterName: string;
|
|
}
|
|
|
|
export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }: KubectlTerminalProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const termRef = useRef<XTerm | null>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const fitRef = useRef<FitAddon | null>(null);
|
|
const [connected, setConnected] = useState(false);
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
// 重置全屏状态
|
|
useEffect(() => {
|
|
if (!open) setFullscreen(false);
|
|
}, [open]);
|
|
|
|
// 连接终端
|
|
useEffect(() => {
|
|
if (!open || !clusterId) return;
|
|
|
|
const term = new XTerm({
|
|
cursorBlink: true,
|
|
fontSize: 14,
|
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
allowProposedApi: true,
|
|
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;
|
|
|
|
// WebSocket
|
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${proto}//${window.location.host}`;
|
|
const token = useUserStore.getState().token || '';
|
|
const wsUrl = `${baseUrl.replace(/^http/, 'ws').replace('/api/v1', '')}/ws/kubectl?token=${encodeURIComponent(token)}&id=${clusterId}`;
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
term.onData((data) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ status: 0, data }));
|
|
}
|
|
});
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.status === 0 && typeof msg.data === 'string') {
|
|
term.write(msg.data);
|
|
}
|
|
} catch {
|
|
if (typeof event.data === 'string') term.write(event.data);
|
|
}
|
|
};
|
|
|
|
ws.onopen = () => {
|
|
setConnected(true);
|
|
setError('');
|
|
if (containerRef.current) {
|
|
term.open(containerRef.current);
|
|
requestAnimationFrame(() => {
|
|
try {
|
|
fit.fit();
|
|
} catch { /* noop */ }
|
|
});
|
|
// 延迟 focus:确保 Dialog 渲染完成后再聚焦终端
|
|
setTimeout(() => {
|
|
try {
|
|
term.focus();
|
|
} catch { /* noop */ }
|
|
}, 150);
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
setConnected(false);
|
|
term.write('\r\n\x1b[31m[连接已断开]\x1b[0m\r\n');
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
setError('WebSocket 连接失败');
|
|
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
|
|
};
|
|
|
|
return () => {
|
|
try {
|
|
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
ws.close();
|
|
}
|
|
term.dispose();
|
|
} catch { /* noop */ }
|
|
termRef.current = null;
|
|
wsRef.current = null;
|
|
setConnected(false);
|
|
};
|
|
}, [open, clusterId]);
|
|
|
|
// ResizeObserver 监听容器变化自动 fit
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
let resizeTimer: ReturnType<typeof setTimeout>;
|
|
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(container);
|
|
return () => {
|
|
clearTimeout(resizeTimer);
|
|
ro.disconnect();
|
|
};
|
|
}, [open]);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
onOpenAutoFocus={(e) => 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: '900px', width: '90vw' }}
|
|
>
|
|
<DialogTitle className="sr-only">kubectl — {clusterName}</DialogTitle>
|
|
<DialogDescription className="sr-only">Kubernetes 集群命令行终端</DialogDescription>
|
|
|
|
{/* 头部 */}
|
|
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<div className={cn('w-2 h-2 rounded-full', connected ? 'bg-emerald-400' : error ? 'bg-red-400' : 'bg-yellow-400')} />
|
|
<Terminal className="h-4 w-4 text-blue-400" />
|
|
<span className="text-[var(--dg-text-primary)] font-medium">kubectl — {clusterName}</span>
|
|
{!connected && !error && <Loader2 className="h-3 w-3 animate-spin text-[var(--dg-text-muted)]" />}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setFullscreen(!fullscreen)}
|
|
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
|
>
|
|
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
|
</button>
|
|
<button
|
|
onClick={() => onOpenChange(false)}
|
|
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 终端容器 */}
|
|
<div
|
|
ref={containerRef}
|
|
className="bg-[#0f172a]"
|
|
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|