Files
aiops/frontend/src/components/kubernetes/KubectlTerminal.tsx
T
attacker bb2b50276a refactor(i18n): U19 i18n hash 键治理 — 删除 866 死键 + 重命名 21 高频键
死键清理: 从 zh-CN.json 和 en-US.json 移除 866 个重构遗弃的 unreferenced
hash keys (filename.hash 格式)。这些键在组件迁移到语义化键后成为孤儿。

Live 键重命名 (21 个):
  ui.datatable.s1a2b3c4d → selectedCount
  ui.datatable.s2b3c4d5e → columns
  ui.datatable.s3c4d5e6f → totalCount
  aiops.predictive.s{1-3} → metricsTab/capacityTab/costTab
  aiops.predictivecost.s{1-3} → aiFailed/overview/aiInsight
  task.systemtaskspanel.s{1-3} → monitor/cleanup/finops
  aiops.agentcli.s{1-3} → archLayer/archDesc/securityLayer
  system.useldapmanageme.s{1-2} → ouExists/createOuFailed
  common.requestts.sd3ab821c → common.request.tokenExpired
  k8s.editAdvanced.s0c1fec65 → secondsPlaceholder
  k8s.{kubectl,podExec}.sc8107726 → connectErrorAnsi

影响: zh/en 7597→6731 keys 对齐, 文件 328KB→276KB (-16%)
      仍有 172 个 live hash keys 待后续重命名

tsc -b=0, npm build✓ (17.45s), ESLint=0/66/0, go build=0, 17/17 测试通过
2026-07-11 08:08:07 +08:00

199 lines
7.0 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
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 { attachTerminalResize, mountXterm } from '@/lib/xterm-utils';
import { useUserStore } from '@/store';
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
import i18n from '@/i18n';
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('');
const [hasOutput, setHasOutput] = useState(false);
// 重置全屏状态
useEffect(() => {
if (!open) setFullscreen(false);
}, [open]);
// 连接终端
useEffect(() => {
if (!open || !clusterId) return;
setHasOutput(false);
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') {
setHasOutput(true);
term.write(msg.data);
}
} catch {
if (typeof event.data === 'string') {
setHasOutput(true);
term.write(event.data);
}
}
};
ws.onopen = () => {
setConnected(true);
setError('');
mountXterm(term, fit, () => containerRef.current, ws);
};
ws.onclose = () => {
setConnected(false);
setHasOutput(true);
term.write(i18n.t('k8s.kubectl.disconnected'));
};
ws.onerror = () => {
setError(i18n.t('k8s.kubectl.failed'));
setHasOutput(true);
term.write(i18n.t('k8s.kubectl.connectError'));
};
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,并同步远端 PTY 尺寸。 // Logan.Li
useEffect(() => {
if (!open) return;
let cleanup: (() => void) | undefined;
let retry = 0;
const attach = () => {
const container = containerRef.current;
const fit = fitRef.current;
const term = termRef.current;
if (!container || !fit || !term) {
if (retry++ < 30) requestAnimationFrame(attach);
return;
}
cleanup = attachTerminalResize(container, fit, term, wsRef.current);
};
attach();
return () => cleanup?.();
}, [open, fullscreen]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
onOpenAutoFocus={(e) => e.preventDefault()}
// 禁用 Radix Dialog 默认的 ESC 关闭:kubectl 终端会用到 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: '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 dg-text-blue" />
<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 className="relative bg-[#0f172a]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}>
<div ref={containerRef} className="xterm-terminal-host h-full" />
<TerminalLoadingOverlay show={!hasOutput && !error} label={i18n.t('k8s.kubectl.cluster')} />
</div>
</DialogContent>
</Dialog>
);
}