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传递
This commit is contained in:
attacker committed 2026-06-17 08:55:09 +08:00
1 parent 4797059be2
commit bdc7a507c0
17 files changed
+985 -34

No files matched your search

@@ -0,0 +1,105 @@
import { useState, useEffect, useRef } from 'react';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Loader2 } from 'lucide-react';
import { terminalSessionApi } from '@/services/api';
interface ReplayDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
sessionId: number | null;
}
/**
* 终端录制回放 Dialog — 用 xterm.js 重放录制的终端输出 // Logan.Li
*/
export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProps) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || !sessionId) return;
const term = new XTerm({
cursorBlink: false,
fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
disableStdin: true,
allowProposedApi: true,
theme: {
background: '#0a0e14',
foreground: '#d6d6d6',
cursor: '#0a0e14',
selectionBackground: 'rgba(255,255,255,0.2)',
},
});
const fit = new FitAddon();
term.loadAddon(fit);
termRef.current = term;
let cancelled = false;
setLoading(true);
terminalSessionApi
.getRecord(sessionId)
.then((data: any) => {
if (cancelled) return;
if (containerRef.current) {
term.open(containerRef.current);
try {
fit.fit();
} catch {
/* noop */
}
const output = typeof data === 'string' ? data : '';
term.write(output);
}
})
.catch(() => {
if (!cancelled && containerRef.current) {
term.open(containerRef.current);
term.write('\x1b[31m无录制数据\x1b[0m\r\n');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
try {
term.dispose();
} catch {
/* noop */
}
termRef.current = null;
};
}, [open, sessionId]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-slate-900 border-slate-800 p-0 gap-0" style={{ maxWidth: '900px', width: '90vw' }}>
<DialogHeader className="px-4 py-3 border-b border-slate-800">
<DialogTitle className="text-slate-100 text-base">终端录制回放 #{sessionId ?? ''}</DialogTitle>
<DialogDescription className="text-slate-500 sr-only">重放该会话录制的终端输出</DialogDescription>
</DialogHeader>
<div className="relative bg-[#0a0e14]" style={{ height: '460px' }}>
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
<Loader2 className="h-6 w-6 animate-spin text-blue-400" />
</div>
)}
<div ref={containerRef} className="absolute inset-0 p-1" />
</div>
</DialogContent>
</Dialog>
);
}