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传递
449 lines
15 KiB
TypeScript
449 lines
15 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
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 { serverApi } from '@/services/api';
|
|
import { useUserStore } from '@/store';
|
|
|
|
interface TerminalSession {
|
|
id: string;
|
|
serverId: string;
|
|
serverName: string;
|
|
serverIp: string;
|
|
connected: boolean;
|
|
}
|
|
|
|
interface TermEntry {
|
|
term: XTerm;
|
|
ws: WebSocket;
|
|
fit: FitAddon;
|
|
opened: boolean;
|
|
}
|
|
|
|
export function WebShell() {
|
|
const [servers, setServers] = useState<any[]>([]);
|
|
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: any = await serverApi.getServers();
|
|
setServers(res?.list || []);
|
|
} catch (e) {
|
|
console.error('加载服务器列表失败:', e);
|
|
setServers([]);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadServers();
|
|
}, []);
|
|
|
|
// 卸载时关闭所有连接
|
|
useEffect(() => {
|
|
return () => {
|
|
termsRef.current.forEach(({ ws, term }) => {
|
|
try {
|
|
ws.close();
|
|
term.dispose();
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
});
|
|
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') {
|
|
term.write(msg.data);
|
|
}
|
|
} catch {
|
|
// 非 JSON,直接写入原始数据
|
|
term.write(typeof event.data === 'string' ? event.data : '');
|
|
}
|
|
};
|
|
|
|
ws.onopen = () => {
|
|
term.write(`\x1b[32m正在连接 ${server.hostname} (${server.ip})...\x1b[0m\r\n`);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
|
|
setSessions((prev) =>
|
|
prev.map((s) => (s.id === sessionId ? { ...s, connected: false } : s))
|
|
);
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\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,
|
|
};
|
|
setSessions((prev) => [...prev, newSession]);
|
|
setActiveSession(sessionId);
|
|
setSelectedServer('');
|
|
setDialogOpen(false);
|
|
};
|
|
|
|
const closeSession = (sessionId: string) => {
|
|
const entry = termsRef.current.get(sessionId);
|
|
if (entry) {
|
|
try {
|
|
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 {
|
|
entry.fit.fit();
|
|
const { cols, rows } = entry.term;
|
|
entry.term.resize(cols, rows);
|
|
if (entry.ws.readyState === WebSocket.OPEN) {
|
|
entry.ws.send(JSON.stringify({ status: 1, cols, rows }));
|
|
}
|
|
} 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 {
|
|
entry.fit.fit();
|
|
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();
|
|
}
|
|
};
|
|
|
|
// 注册容器并首次挂载终端
|
|
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 {
|
|
entry.term.open(el);
|
|
entry.opened = true;
|
|
requestAnimationFrame(() => {
|
|
try {
|
|
entry.fit.fit();
|
|
entry.term.focus();
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error('打开终端失败:', e);
|
|
}
|
|
}
|
|
} else {
|
|
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">远程服务器终端管理</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" />
|
|
新建连接
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-[var(--dg-text-primary)]">新建终端连接</DialogTitle>
|
|
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
|
选择要连接的服务器
|
|
</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)]">选择服务器</label>
|
|
<Select value={selectedServer} onValueChange={setSelectedServer}>
|
|
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
|
<SelectValue placeholder="选择服务器" />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
|
{runningServers.length === 0 ? (
|
|
<SelectItem value="__none" disabled>暂无运行中的服务器</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" />
|
|
连接
|
|
</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)]">暂无连接</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 p-1',
|
|
activeSession === session.id ? 'block' : 'hidden'
|
|
)}
|
|
/>
|
|
))}
|
|
</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">暂无活动连接</p>
|
|
<p className="text-sm mt-1">点击“新建连接”开始远程会话</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)]">快捷命令</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>
|
|
);
|
|
}
|