Files
aiops/frontend/src/components/kubernetes/PodExecTerminal.tsx
T
attacker 174382ff81 fix(k8s): PodExecTerminal error msg 字段对齐 + command 参数传递
- PodExecTerminal 传 shell 作为 command 到 WS URL
- 去掉发送 shell\r 的旧行为(K8s exec URL 已含 command)
- error 消息检查 msg.data 字段(与后端 WSMessage.Data 对齐)
- 已验证: WebSocket exec 端到端可用 (id 命令在 argocd pod 成功执行)
2026-06-23 02:27:43 +08:00

353 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <admin@attacker.club>
*/
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<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const [connState, setConnState] = useState<ConnState>('idle');
const [fullscreen, setFullscreen] = useState(false);
const [container, setContainer] = useState<string>(initialContainer || containers[0] || '');
const [shell, setShell] = useState<string>('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<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(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 (
<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: '960px', width: '92vw' }}
>
<DialogTitle className="sr-only">exec — {podName}</DialogTitle>
<DialogDescription className="sr-only">进入 Pod 容器执行命令</DialogDescription>
{/* 头部工具栏 */}
<div className="flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--dg-border)] flex-wrap">
<div className="flex items-center gap-2 text-sm min-w-0">
<div className={cn('w-2 h-2 rounded-full shrink-0', statusColor)} title={statusText} />
<Terminal className="h-4 w-4 text-blue-400 shrink-0" />
<span className="text-[var(--dg-text-primary)] font-medium truncate">
exec — {podName}
</span>
{connState === 'connecting' && <Loader2 className="h-3 w-3 animate-spin text-[var(--dg-text-muted)]" />}
<span className="text-xs text-[var(--dg-text-muted)]">{statusText}</span>
</div>
<div className="flex items-center gap-2">
{/* 容器选择 */}
{containers.length > 0 && (
<Select value={container} onValueChange={setContainer}>
<SelectTrigger className="h-8 w-[150px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<Box className="h-3 w-3 mr-1 text-[var(--dg-text-muted)]" />
<SelectValue placeholder="容器" />
</SelectTrigger>
<SelectContent>
{containers.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* Shell 选择 */}
<Select value={shell} onValueChange={setShell}>
<SelectTrigger className="h-8 w-[100px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bash">/bin/bash</SelectItem>
<SelectItem value="sh">/bin/sh</SelectItem>
</SelectContent>
</Select>
{/* 重连 */}
<Button
variant="ghost" size="sm"
className="h-8 px-2 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
onClick={() => { connect(); toast({ title: '正在重连…' }); }}
title="重连"
>
<RotateCw className="h-3.5 w-3.5" />
</Button>
<button
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title={fullscreen ? '退出全屏' : '全屏'}
>
{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)]"
title="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 终端容器 */}
<div
ref={containerRef}
className="bg-[#0f172a]"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
/>
</DialogContent>
</Dialog>
);
}