M8 存储挂载 Tab (DeploymentEditStorage.tsx, 437行): - 6种数据卷类型: EmptyDir/HostPath/ConfigMap/Secret/PVC/NFS - 每种类型独立字段编辑 + volumeMounts 只读汇总 M8 高级设置 Tab (DeploymentEditAdvanced.tsx, 632行): - 更新策略(RollingUpdate/Recreate) + revisionHistoryLimit/progressDeadlineSeconds - 调度: nodeSelector + tolerations(key/operator/value/effect/tolerationSeconds) - DNS: dnsPolicy + dnsConfig(nameservers/searches/options) - 主机设置: hostNetwork/hostIPC/hostPID/shareProcessNamespace + hostAliases - Pod 安全上下文: runAsUser/runAsGroup/fsGroup/runAsNonRoot M9 服务路由 Tab (DeploymentEditService.tsx): - selector 双路径关联 Service(spec.selector.matchLabels || selector) - Service/Ingress 创建/删除集成 EditModeContainer 5 Tab 全部 available: basic / containers / storage / advanced / service 验证: go build OK + tsc --noEmit OK + vite build OK + SPA edit路由200 + YAML API有效
351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
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>
|
||
*/
|
||
export interface PodExecTerminalProps {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
/** 集群 ID(用于 ws 路由 :clusterId) */
|
||
clusterId: number | 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' },
|
||
];
|
||
|
||
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 选择 — bash/sh 可选,默认 bash */}
|
||
<Select value={shell} onValueChange={setShell}>
|
||
<SelectTrigger className="h-8 w-[110px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{SHELLS.map(s => (
|
||
<SelectItem key={s.value} value={s.value}>{s.label}</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>
|
||
);
|
||
}
|