- Agent CLI: 多轮对话、SSH 长连接、流式推理、Evolver 按用户隔离、历史侧栏 - 终端审计: 回放播放器、命令书签、筛选分页导出、命令输出统计、录制落盘修复 - 修复 session_commands 表迁移、Kill 竞态导致录制丢失、无录制会话回放提示 - 审计历史页只读(移除删除),移除回放弹框录制搜索
343 lines
12 KiB
TypeScript
343 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 '@/api';
|
|
import { useToast } from '@/hooks/use-toast';
|
|
|
|
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<HTMLDivElement>(null);
|
|
const termRef = useRef<XTerm | null>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const fitRef = useRef<FitAddon | null>(null);
|
|
const mountedRef = useRef(false);
|
|
const errorShownRef = 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>('sh');
|
|
|
|
// ─── 重置 ───
|
|
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');
|
|
term.clear();
|
|
term.writeln(`\x1b[36m[正在连接 ${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') {
|
|
term.write(msg.data);
|
|
} else if (msg.status === 2 || msg.error) {
|
|
const errMsg = msg.data || msg.message || msg.error || '执行错误';
|
|
if (errMsg.includes('not found') && shell === 'bash') {
|
|
term.writeln('\r\n\x1b[33m⚠ bash 不可用,请切换 /bin/sh 后重连。\x1b[0m');
|
|
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') term.write(event.data);
|
|
}
|
|
};
|
|
|
|
ws.onclose = (_ev) => {
|
|
setConnState((s) => (s === 'error' ? s : 'closed'));
|
|
if (!errorShownRef.current) {
|
|
term.write('\r\n\x1b[33m[连接已关闭]\x1b[0m\r\n');
|
|
}
|
|
};
|
|
|
|
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]);
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
|
|
term.open(el);
|
|
requestAnimationFrame(() => { try { fit.fit(); } catch { /* noop */ } });
|
|
|
|
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();
|
|
} else {
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [container, shell]);
|
|
|
|
// ─── ResizeObserver ───
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
|
|
let timer: ReturnType<typeof setTimeout>;
|
|
const ro = new ResizeObserver(() => {
|
|
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) {
|
|
wsRef.current.send(JSON.stringify({ status: 1, cols, rows }));
|
|
}
|
|
} catch { /* noop */ }
|
|
}
|
|
}, 60);
|
|
});
|
|
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';
|
|
const statusText = connState === 'connected' ? '已连接'
|
|
: connState === 'connecting' ? '连接中'
|
|
: connState === 'error' ? '连接错误'
|
|
: connState === 'closed' ? '已断开' : '空闲';
|
|
|
|
const handleTerminalClick = useCallback(() => {
|
|
try { termRef.current?.focus(); } catch { /* noop */ }
|
|
}, []);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
// 禁用 Radix Dialog 默认的 ESC 关闭:Pod exec 终端会用到 ESC,关闭只能点头部 X 按钮 // Logan.Li
|
|
onEscapeKeyDown={(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>
|
|
)}
|
|
<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] cursor-text"
|
|
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
|
|
onClick={handleTerminalClick}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|