Files
aiops/frontend/src/components/server/TerminalDialog.tsx
T
attacker bdc7a507c0 feat: 修复菜单路由+角色权限展示+操作审计+WebSSH终端审计
1. 菜单路由修复: /roles /users /logs-audit /terminal-audit 等系统管理子菜单
   通过 useLocation 同步 URL 路径到 Tab 选中状态

2. WebSSH错误提示优化: Go error → 友好中文提示
   i/o timeout → 连接超时(防火墙或网络不通)
   connection refused → 端口未开放/SSH服务未启动
   cannot find session key → 密码或密钥认证失败
   连接提示增加端口号显示

3. 角色权限展示: 31项权限种子数据 (资产管理/云资源/K8s/发布/监控/AIOps/系统管理)
   4个角色权限分配: admin(31) ops(27) developer(16) viewer(3)
   前端角色卡片显示权限名称而非原始code

4. 操作审计修复: 删除/创建/更新服务器均记录审计日志
   批量删除逐条记录审计

5. WebSSH终端审计 (JumpServer模式):
   - 新增 TerminalSession 模型记录会话(用户/服务器/时间/状态)
   - webshell.go 录制终端输出(1MB上限)
   - 终端审计页面: 会话列表+状态筛选+批量删除
   - 操作回放: xterm.js 重放录制的终端输出
   - WS连接增加JWT鉴权+token传递
2026-06-17 08:55:09 +08:00

191 lines
5.8 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 { Maximize2, Minimize2, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useUserStore } from '@/store';
interface TerminalDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
serverId: number | null;
serverName: string;
serverIp: string;
}
/**
* 终端 Dialog — WebSocket SSH 终端 // Logan.Li
* 打开时自动连接,关闭时自动断开
*/
export function TerminalDialog({
open,
onOpenChange,
serverId,
serverName,
serverIp,
}: TerminalDialogProps) {
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);
// 连接终端
useEffect(() => {
if (!open || !serverId) return;
// 创建终端
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);
termRef.current = term;
fitRef.current = fit;
// WebSocket 连接
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const token = useUserStore.getState().token || '';
const wsUrl = `${proto}://${location.host}/ws/terminal?id=${serverId}&cols=80&rows=24&token=${encodeURIComponent(token)}`;
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') {
term.write(msg.data);
}
} catch {
term.write(typeof event.data === 'string' ? event.data : '');
}
};
ws.onopen = () => {
setConnected(true);
// 挂载终端到容器
if (containerRef.current) {
term.open(containerRef.current);
requestAnimationFrame(() => {
try {
fit.fit();
term.focus();
} catch { /* noop */ }
});
}
};
ws.onclose = () => {
setConnected(false);
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
};
ws.onerror = () => {
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
// resize 监听
const handleResize = () => {
if (fitRef.current && termRef.current) {
try {
fit.fit();
const { cols, rows } = termRef.current;
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ status: 1, cols, rows }));
}
} catch { /* noop */ }
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
try {
ws.close();
term.dispose();
} catch { /* noop */ }
termRef.current = null;
wsRef.current = null;
setConnected(false);
};
}, [open, serverId]);
// fullscreen 切换时重新 fit
useEffect(() => {
if (open && fitRef.current) {
requestAnimationFrame(() => {
try {
fitRef.current?.fit();
termRef.current?.focus();
} catch { /* noop */ }
});
}
}, [fullscreen, open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-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: '900px', width: '90vw' }}
>
<DialogTitle className="sr-only">终端 - {serverName}</DialogTitle>
<DialogDescription className="sr-only">SSH 终端连接</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' : 'bg-red-400')} />
<span className="text-[var(--dg-text-primary)] font-medium">{serverName}</span>
<span className="text-[var(--dg-text-muted)]">({serverIp})</span>
</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
ref={containerRef}
className="bg-[#0a0e14]"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
/>
</DialogContent>
</Dialog>
);
}