feat(k8s): M11 StatefulSet/DaemonSet 详情页 — WorkloadViewPage 重写 + YAML 路由冲突修复

后端:
- routes.go: 为 statefulsets/daemonsets 注册专门 YAML 子路由(Gin radix tree 冲突)
- kubernetes.go: GetResourceYAMLNS/UpdateResourceYAMLNS 添加 URL path fallback

前端:
- WorkloadViewPage.tsx 重写: 修复 kind 提取 bug(useLocation) + 更新策略/选择器/PVC模板/InitContainers
- NamespaceResourcePage.tsx: handleDetail 添加 STS/DS 点击导航

验证: DS YAML API 200 ✅ | DS Detail 200 ✅ | Dep YAML 200 ✅ | SPA路由 200 ✅ | TS零错误 ✅
This commit is contained in:
attacker committed 2026-06-24 00:39:51 +08:00
1 parent 87b7755094
commit d1c557bb6c
14 files changed
+1103 -178

No files matched your search

@@ -23,38 +23,21 @@ 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' },
{ value: 'bash', label: '/bin/bash' },
];
export function PodExecTerminal({
@@ -71,268 +54,287 @@ export function PodExecTerminal({
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const mountedRef = useRef(false);
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');
const [shell, setShell] = useState<string>('sh');
// 重置全屏状态
// ─── 重置 ───
useEffect(() => {
if (!open) setFullscreen(false);
if (!open) {
setFullscreen(false);
mountedRef.current = false;
}
}, [open]);
// 容器列表变化时同步默认值
useEffect(() => {
if (open && !container && containers.length > 0) {
setContainer(initialContainer || containers[0]);
}
}, [open, containers, initialContainer, container]);
// 创建 xterm 实例(仅在 open 时创建一次,容器/shell 变化时重连 ws)
// ─── 连接 WebSocket ───
const connect = useCallback(() => {
if (!termRef.current) return;
console.log('[PodExec:connect] START clusterId=', clusterId, 'namespace=', namespace, 'podName=', podName, 'container=', container, 'shell=', shell);
if (!termRef.current) {
console.error('[PodExec:connect] ABORT: termRef.current is null');
return;
}
const term = termRef.current;
// 关闭旧连接
if (wsRef.current) {
console.log('[PodExec:connect] closing previous WS');
try { wsRef.current.close(); } catch { /* noop */ }
wsRef.current = null;
}
setConnState('connecting');
term.clear();
term.writeln(`\x1b[36m[正在连接 ${podName}${container ? '/' + container : ''} ...]\x1b[0m`);
term.writeln(`\x1b[36m[正在连接 ${podName}${container ? '/' + container : ''} shell=${shell} ...]\x1b[0m`);
const wsUrl = kubernetesApi.buildPodExecWsUrl(clusterId, namespace, podName, container || undefined, shell);
console.log('[PodExec:connect] WS URL:', wsUrl, '\n location.protocol=', window.location.protocol, '\n location.host=', window.location.host, '\n VITE_API_BASE_URL=', import.meta.env.VITE_API_BASE_URL);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
console.log('[PodExec:connect] WS onopen — connected, readyState=', ws.readyState);
setConnState('connected');
term.reset();
// 发送初始 resize(command/shell 已在 WS URL 参数中指定)
try {
const { cols, rows } = term;
console.log('[PodExec:connect] sending resize:', cols, 'x', rows);
ws.send(JSON.stringify({ status: 1, cols, rows }));
} catch { /* noop */ }
setTimeout(() => { try { term.focus(); } catch { /* noop */ } }, 100);
} 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') {
console.debug('[PodExec:ws] RX stdout, len=', msg.data.length);
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`);
console.warn('[PodExec] error:', errMsg.substring(0, 200));
if (errMsg.includes('not found') && shell === 'bash') {
term.writeln('\r\n\x1b[33m⚠ bash 不可用,请切换 /bin/sh 后重连。\x1b[0m');
return;
}
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 = () => {
ws.onclose = (ev) => {
console.log('[PodExec:connect] WS closed: code=', ev.code, 'reason=', ev.reason, 'wasClean=', ev.wasClean);
setConnState((s) => (s === 'error' ? s : 'closed'));
term.write('\r\n\x1b[33m[连接已断开]\x1b[0m\r\n');
};
ws.onerror = () => {
ws.onerror = (ev) => {
console.error('[PodExec:connect] WS error event fired, readyState=', ws.readyState, 'type=', ev.type);
setConnState('error');
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
}, [clusterId, namespace, podName, container, shell]);
// 创建/销毁 xterm + 绑定 onData(生命周期跟随 open)
// DEBUG: 组件入口
console.log('[PodExec:render] open=', open, 'clusterId=', clusterId, 'podName=', podName, 'namespace=', namespace, 'containers=', containers.length, 'container=', container, 'shell=', shell, 'connState=', connState);
// ─── 创建 xterm + 连接(用 rAF 重试等 Dialog 渲染完)───
useEffect(() => {
if (!open || !clusterId || !podName) return;
console.log('[PodExec:initEffect] TRIGGERED open=', open, 'clusterId=', clusterId, 'podName=', podName, 'namespace=', namespace);
if (!open) { console.log('[PodExec:initEffect] ABORT: open=false'); 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; }
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;
let disposed = false;
let retryCount = 0;
const startTime = performance.now();
const onData = (data: string) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ status: 0, data }));
const tryInit = () => {
if (disposed) { console.log('[PodExec:tryInit] disposed, aborting'); return; }
const el = containerRef.current;
console.log('[PodExec:tryInit] retry=', retryCount, 'el=', el ? `DIV ${el.offsetWidth}x${el.offsetHeight}` : 'NULL', 'el.parentElement=', el?.parentElement ? 'YES' : 'NO');
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;
}
};
term.onData(onData);
if (containerRef.current) {
term.open(containerRef.current);
requestAnimationFrame(() => {
try { fit.fit(); } catch { /* noop */ }
console.log('[PodExec:tryInit] SUCCESS: container ready after', retryCount, 'retries, size:', el.offsetWidth, 'x', el.offsetHeight, 'elapsed:', Math.round(performance.now()-startTime), 'ms');
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;
// 首次连接
connect();
term.onData((data: string) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
console.debug('[PodExec:term] TX stdin, len=', data.length, 'bytes=', [...data].map(c => c.charCodeAt(0)).slice(0, 10));
wsRef.current.send(JSON.stringify({ status: 0, data }));
} else {
console.warn('[PodExec:term] TX dropped — WS not open, readyState=', wsRef.current?.readyState);
}
});
term.open(el);
console.log('[PodExec:tryInit] xterm.open() done, rows=', term.rows, 'cols=', term.cols);
requestAnimationFrame(() => { try { fit.fit(); } catch { /* noop */ } });
mountedRef.current = true;
connect();
};
requestAnimationFrame(tryInit);
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');
disposed = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, clusterId, podName]);
// 容器 / shell 变化时重连(需 term 已就绪)
// ─── cleanup on close ───
useEffect(() => {
if (open && termRef.current) {
if (!open) {
console.log('[PodExec:cleanup] dialog closed, cleaning up WS + xterm');
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) {
console.log('[PodExec:reconnect] container=', container, 'shell=', shell, 'mounted=', mountedRef.current);
connect();
} else {
console.log('[PodExec:reconnect] SKIP: open=', open, 'hasTerm=', !!termRef.current, 'mounted=', mountedRef.current);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [container, shell]);
// ResizeObserver 监听容器变化自动 fit + 透传 resize
// ─── ResizeObserver ───
useEffect(() => {
if (!open) return;
const containerEl = containerRef.current;
if (!containerEl) return;
const el = containerRef.current;
if (!el) return;
let resizeTimer: ReturnType<typeof setTimeout>;
let timer: ReturnType<typeof setTimeout>;
const ro = new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (fitRef.current && termRef.current && wsRef.current) {
clearTimeout(timer);
timer = setTimeout(() => {
if (fitRef.current && termRef.current) {
try {
fitRef.current.fit();
termRef.current.focus();
const { cols, rows } = termRef.current;
if (wsRef.current.readyState === WebSocket.OPEN) {
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();
};
ro.observe(el);
return () => { clearTimeout(timer); ro.disconnect(); };
}, [open, fullscreen]);
// ─── UI ───
const statusColor = connState === 'connected' ? 'bg-emerald-400'
: connState === 'error' ? 'bg-red-400'
: connState === 'closed' ? 'bg-slate-500'
: 'bg-amber-400';
: connState === 'closed' ? 'bg-slate-500' : 'bg-amber-400';
const statusText = connState === 'connected' ? '已连接'
: connState === 'connecting' ? '连接中'
: connState === 'error' ? '连接错误'
: connState === 'closed' ? '已断开'
: '空闲';
: connState === 'closed' ? '已断开' : '空闲';
const handleTerminalClick = useCallback(() => {
try { termRef.current?.focus(); } catch { /* noop */ }
}, []);
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'
: ''
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>
<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>
<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>
<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="重连"
>
<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 ? '退出全屏' : '全屏'}
>
<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="关闭"
>
<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>
@@ -341,8 +343,9 @@ export function PodExecTerminal({
{/* 终端容器 */}
<div
ref={containerRef}
className="bg-[#0f172a]"
className="bg-[#0f172a] cursor-text"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
onClick={handleTerminalClick}
/>
</DialogContent>
</Dialog>