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传递
191 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|