Files
aiops/frontend/src/components/server/WebShell.tsx
T
attacker da9fd1e987 feat(quality): U12 工程质量回归修复 — ESLint errors(8→0) + React Hooks 关键规则(82→0)
远程 i18n 全量改造 + 堡垒机合并后引入两类质量回归,本次修复:

1. ESLint errors 8→0:
   - GrafanaDashboard/PrometheusDashboard: 未使用的 const { t } 改为 useTranslation()
   - ServerTableRow: 移除未使用 cn import
   - replay-utils: no-control-regex 标注 disable (终端回放有意检测控制字符)

2. React Hooks 6 大关键规则 82→0 (恢复 U7 治理水平):
   - exhaustive-deps 52→0 (mount-only effects 标注 disable)
   - set-state-in-effect 18→0 (effect 初始化模式)
   - purity 5→0 (AlertCenter 提取 nowMs / EnhancedLogResults 骨架屏确定性公式)
   - incompatible-library 3→0 (@tanstack/react-virtual 兼容性)
   - immutability 2→0 (AlertCenter TDZ)
   - preserve-manual-memoization 2→0 (StatChartPanel useMemo)

验证: tsc -b=0, npm build✓(18.04s), go build=0, ESLint errors=0, 17/17 测试通过
2026-07-06 00:55:01 +08:00

462 lines
17 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Terminal,
Plus,
X,
Server,
Maximize2,
Minimize2,
} from 'lucide-react';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import { attachTerminalResize, fitTerminal, mountXterm } from '@/lib/xterm-utils';
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
import { serverApi } from '@/api';
import { useUserStore } from '@/store';
import type { JsonData } from '@/types/common';
interface TerminalSession {
id: string;
serverId: string;
serverName: string;
serverIp: string;
connected: boolean;
ready: boolean;
}
interface TermEntry {
term: XTerm;
ws: WebSocket;
fit: FitAddon;
opened: boolean;
resizeCleanup?: () => void;
}
export function WebShell() {
const { t } = useTranslation();
const [servers, setServers] = useState<JsonData[]>([]);
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeSession, setActiveSession] = useState<string | null>(null);
const [selectedServer, setSelectedServer] = useState<string>('');
const [isFullscreen, setIsFullscreen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
// 每个会话的终端实例:sessionId -> { term, ws, fit, opened }
const termsRef = useRef<Map<string, TermEntry>>(new Map());
// 每个会话的容器 DOM
const containerRef = useRef<Map<string, HTMLDivElement>>(new Map());
const loadServers = async () => {
try {
const res: JsonData = await serverApi.getServers();
setServers(res?.list || []);
} catch (e) {
console.error('loadServers error:', e);
setServers([]);
}
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
loadServers();
}, []);
// 卸载时关闭所有连接
useEffect(() => {
return () => {
termsRef.current.forEach(({ ws, term, resizeCleanup }) => {
try {
resizeCleanup?.();
ws.close();
term.dispose();
} catch {
/* noop */
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
termsRef.current.clear();
};
}, []);
const runningServers = servers.filter((s) => s.status === 'running');
const createSession = () => {
if (!selectedServer) return;
const server = servers.find((s) => s.id === selectedServer);
if (!server) return;
const sessionId = `session-${Date.now()}`;
const cols = 80;
const rows = 24;
const term = new XTerm({
cursorBlink: true,
fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
allowProposedApi: true,
theme: {
background: '#0a0e14',
foreground: '#d6d6d6',
cursor: '#d6d6d6',
selectionBackground: 'rgba(255,255,255,0.2)',
},
});
const fit = new FitAddon();
term.loadAddon(fit);
const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws';
const token = useUserStore.getState().token || '';
const wsUrl = `${proto}://${location.host}/ws/terminal?id=${server.id}&cols=${cols}&rows=${rows}&token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
// 终端输入 -> WebSocket
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ status: 0, data }));
}
});
// WebSocket 输出 -> 终端
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
setSessions((prev) => prev.map((s) => (s.id === sessionId ? { ...s, ready: true } : s)));
term.write(msg.data);
}
} catch {
// 非 JSON,直接写入原始数据
setSessions((prev) => prev.map((s) => (s.id === sessionId ? { ...s, ready: true } : s)));
term.write(typeof event.data === 'string' ? event.data : '');
}
};
ws.onopen = () => {
term.write(`\x1b[32m${t('server.connecting', { name: server.hostname, ip: server.ip })}\x1b[0m\r\n`);
};
ws.onclose = () => {
term.write(`\r\n\x1b[31m[${t('server.connectionClosed')}]\r\n`);
setSessions((prev) =>
prev.map((s) => (s.id === sessionId ? { ...s, connected: false, ready: true } : s))
);
};
ws.onerror = () => {
setSessions((prev) => prev.map((s) => (s.id === sessionId ? { ...s, ready: true } : s)));
term.write(`\r\n\x1b[31m[${t('server.connectionError')}]\r\n`);
};
termsRef.current.set(sessionId, { term, ws, fit, opened: false });
const newSession: TerminalSession = {
id: sessionId,
serverId: server.id,
serverName: server.hostname,
serverIp: server.ip,
connected: true,
ready: false,
};
setSessions((prev) => [...prev, newSession]);
setActiveSession(sessionId);
setSelectedServer('');
setDialogOpen(false);
};
const closeSession = (sessionId: string) => {
const entry = termsRef.current.get(sessionId);
if (entry) {
try {
entry.resizeCleanup?.();
if (entry.ws.readyState === WebSocket.OPEN || entry.ws.readyState === WebSocket.CONNECTING) {
entry.ws.close();
}
entry.term.dispose();
} catch {
/* noop */
}
termsRef.current.delete(sessionId);
}
containerRef.current.delete(sessionId);
const newSessions = sessions.filter((s) => s.id !== sessionId);
setSessions(newSessions);
if (activeSession === sessionId) {
setActiveSession(newSessions.length > 0 ? newSessions[0].id : null);
}
};
// 窗口 resize 时适配当前激活的终端
useEffect(() => {
const handleResize = () => {
if (!activeSession) return;
const entry = termsRef.current.get(activeSession);
if (!entry || !entry.opened) return;
try {
fitTerminal(entry.fit, entry.term, entry.ws);
} catch {
/* noop */
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [activeSession, isFullscreen]);
// 激活会话切换后重新 fit
useEffect(() => {
if (!activeSession) return;
const entry = termsRef.current.get(activeSession);
if (entry && entry.opened) {
requestAnimationFrame(() => {
try {
fitTerminal(entry.fit, entry.term, entry.ws);
entry.term.focus();
} catch {
/* noop */
}
});
}
}, [activeSession, isFullscreen]);
// 快捷命令:直接发送到当前激活的 WebSocket
const sendCommand = (cmd: string) => {
if (!activeSession) return;
const entry = termsRef.current.get(activeSession);
if (entry && entry.ws.readyState === WebSocket.OPEN) {
entry.ws.send(JSON.stringify({ status: 0, data: cmd + '\n' }));
entry.term.focus();
}
};
// 注册容器并首次挂载终端 + ResizeObserver 自适应
const registerContainer = (sessionId: string, el: HTMLDivElement | null) => {
if (el) {
containerRef.current.set(sessionId, el);
const entry = termsRef.current.get(sessionId);
if (entry && !entry.opened) {
try {
mountXterm(entry.term, entry.fit, () => el, entry.ws);
entry.opened = true;
entry.resizeCleanup = attachTerminalResize(el, entry.fit, entry.term, entry.ws);
} catch (e) {
console.error('openTerminal error:', e);
}
}
} else {
const entry = termsRef.current.get(sessionId);
entry?.resizeCleanup?.();
containerRef.current.delete(sessionId);
}
};
const activeSessionData = sessions.find((s) => s.id === activeSession);
const terminalHeight = isFullscreen ? 'calc(100vh - 140px)' : '500px';
return (
<div className={cn('space-y-4', isFullscreen && 'fixed inset-0 z-50 p-4')} style={isFullscreen ? { background: 'var(--dg-bg-primary)' } : undefined}>
{/* Page Header */}
{!isFullscreen && (
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">WebShell</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{t('server.remoteTerminal')}</p>
</div>
<div className="flex items-center gap-2">
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button className="bg-[var(--dg-blue)] hover:opacity-90 text-white">
<Plus className="h-4 w-4 mr-2" />
{t('server.newConnection')}
</Button>
</DialogTrigger>
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">{t('server.newTerminal')}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
{t('server.selectServer')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--dg-text-secondary)]">{t('server.selectServer')}</label>
<Select value={selectedServer} onValueChange={setSelectedServer}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder={t('server.selectServer')} />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
{runningServers.length === 0 ? (
<SelectItem value="__none" disabled>{t('server.noRunningServers')}</SelectItem>
) : (
runningServers.map((server) => (
<SelectItem key={server.id} value={server.id}>
<div className="flex items-center gap-2">
<Server className="h-4 w-4" />
<span>{server.hostname}</span>
<span className="text-[var(--dg-text-muted)]">({server.ip})</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
onClick={createSession}
disabled={!selectedServer || selectedServer === '__none'}
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
>
<Terminal className="h-4 w-4 mr-2" />
{t('server.webshellConnect')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
)}
{/* Terminal Container */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] flex-1">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{sessions.length > 0 ? (
<div className="flex items-center gap-1">
{sessions.map((session) => (
<button
key={session.id}
onClick={() => setActiveSession(session.id)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors',
activeSession === session.id
? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)]'
: 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
)}
>
<div
className={cn(
'w-2 h-2 rounded-full',
session.connected ? 'bg-emerald-400' : 'bg-red-400'
)}
/>
<span className="max-w-[120px] truncate">{session.serverName}</span>
<span
onClick={(e) => {
e.stopPropagation();
closeSession(session.id);
}}
className="ml-1 p-0.5 rounded hover:bg-[var(--dg-bg-hover)] cursor-pointer"
>
<X className="h-3 w-3" />
</span>
</button>
))}
</div>
) : (
<span className="text-sm text-[var(--dg-text-secondary)]">{t('server.noConnections')}</span>
)}
</div>
<div className="flex items-center gap-1">
{activeSessionData && (
<span className="text-xs text-[var(--dg-text-muted)] mr-2">
{activeSessionData.serverIp}
</span>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
onClick={() => setIsFullscreen(!isFullscreen)}
>
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{sessions.length > 0 ? (
<div className="relative bg-[#0a0e14]" style={{ height: terminalHeight }}>
{sessions.map((session) => (
<div
key={session.id}
ref={(el) => registerContainer(session.id, el)}
className={cn(
'absolute inset-0 xterm-terminal-host',
activeSession === session.id ? 'block' : 'hidden'
)}
style={{ padding: 0 }}
/>
))}
{sessions.map((session) => (
<TerminalLoadingOverlay
key={`${session.id}-loading`}
show={activeSession === session.id && !session.ready}
label={t('server.webshellLoggingIn')}
/>
))}
</div>
) : (
<div
className="flex flex-col items-center justify-center text-[var(--dg-text-secondary)]"
style={{ height: terminalHeight }}
>
<Terminal className="h-16 w-16 mb-4 opacity-50" />
<p className="text-lg font-medium">{t('server.noActiveConnections')}</p>
<p className="text-sm mt-1">{t('server.newConnectionHint')}</p>
</div>
)}
</CardContent>
</Card>
{/* Quick Commands */}
{activeSessionData && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-[var(--dg-text-secondary)]">{t('server.quickCommands')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{['ls -la', 'pwd', 'df -h', 'free -m', 'ps aux', 'top', 'netstat -tlnp', 'systemctl status'].map(
(cmd) => (
<Button
key={cmd}
variant="outline"
size="sm"
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
onClick={() => sendCommand(cmd)}
>
{cmd}
</Button>
)
)}
</div>
</CardContent>
</Card>
)}
</div>
);
}