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:
1 parent
4797059be2
commit
bdc7a507c0
17 files changed
+985
-34
No files matched your search
@@ -88,6 +88,7 @@ function App() {
|
||||
<Route path="users" element={<SystemManager />} />
|
||||
<Route path="roles" element={<SystemManager />} />
|
||||
<Route path="logs-audit" element={<SystemManager />} />
|
||||
<Route path="terminal-audit" element={<SystemManager />} />
|
||||
<Route path="settings" element={<SystemManager />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -123,6 +123,7 @@ const menuItems: MenuItem[] = [
|
||||
{ id: 'users', name: '用户管理', icon: Users, path: '/users' },
|
||||
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles' },
|
||||
{ id: 'logs-audit', name: '操作审计', icon: FileCode, path: '/logs-audit' },
|
||||
{ id: 'terminal-audit', name: '终端审计', icon: Terminal, path: '/terminal-audit' },
|
||||
{ id: 'settings', name: '系统设置', icon: Settings, path: '/settings' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -61,7 +62,8 @@ export function TerminalDialog({
|
||||
|
||||
// WebSocket 连接
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const wsUrl = `${proto}://${location.host}/ws/terminal?id=${serverId}&cols=80&rows=24`;
|
||||
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;
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ 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;
|
||||
@@ -116,7 +117,8 @@ export function WebShell() {
|
||||
term.loadAddon(fit);
|
||||
|
||||
const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const wsUrl = `${proto}://${location.host}/ws/terminal?id=${server.id}&cols=${cols}&rows=${rows}`;
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle, XCircle, Users, Shield, FileCode, Settings } from 'lucide-react';
|
||||
import { CheckCircle, XCircle, Users, Shield, FileCode, Settings, TerminalSquare } from 'lucide-react';
|
||||
import { userApi } from '@/services/api';
|
||||
import type { Role } from '@/types';
|
||||
import { SystemUserTab } from './SystemUserTab';
|
||||
import { SystemRolesTab } from './SystemRolesTab';
|
||||
import { SystemLogsTab } from './SystemLogsTab';
|
||||
import { SystemSettingsTab } from './SystemSettingsTab';
|
||||
import { TerminalSessionsTab } from './TerminalSessionsTab';
|
||||
import { useLdapManagement } from './useLdapManagement';
|
||||
import type { UserFormData } from './UserFormDialog';
|
||||
|
||||
@@ -36,7 +38,24 @@ const emptyForm: UserFormData = {
|
||||
|
||||
export function SystemManager() {
|
||||
const confirm = useConfirm();
|
||||
const [activeTab, setActiveTab] = useState('users');
|
||||
const location = useLocation();
|
||||
// 侧边栏菜单路径映射到 Tab // Logan.Li
|
||||
const pathToTab: Record<string, string> = {
|
||||
'/users': 'users',
|
||||
'/roles': 'roles',
|
||||
'/logs-audit': 'logs',
|
||||
'/terminal-audit': 'terminal',
|
||||
'/settings': 'settings',
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState(pathToTab[location.pathname] || 'users');
|
||||
|
||||
// URL 变化时同步 Tab(侧边栏菜单点击触发) // Logan.Li
|
||||
useEffect(() => {
|
||||
const tab = pathToTab[location.pathname];
|
||||
if (tab && tab !== activeTab) {
|
||||
setActiveTab(tab);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -238,6 +257,10 @@ export function SystemManager() {
|
||||
<FileCode className="h-4 w-4 mr-2" />
|
||||
操作审计
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="terminal" className="data-[state=active]:bg-slate-700">
|
||||
<TerminalSquare className="h-4 w-4 mr-2" />
|
||||
终端审计
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="data-[state=active]:bg-slate-700">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
系统设置
|
||||
@@ -299,6 +322,10 @@ export function SystemManager() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="terminal" className="mt-4 space-y-4">
|
||||
<TerminalSessionsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="mt-4">
|
||||
<SystemSettingsTab {...ldap.settingsTabProps} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -34,14 +34,15 @@ function normalizeRole(raw: any): Role {
|
||||
name: raw.name ?? '',
|
||||
code: raw.code ?? '',
|
||||
description: raw.description ?? '',
|
||||
// 后端可能返回权限对象数组或字符串数组,统一映射为编码字符串数组
|
||||
permissions: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.code ?? '')).filter(Boolean),
|
||||
// 后端返回权限对象数组(含 name/code/type),映射为显示名数组
|
||||
permissions: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.name ?? p?.code ?? '')).filter(Boolean),
|
||||
permissionCodes: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.code ?? '')).filter(Boolean),
|
||||
createdAt: raw.createdAt ?? raw.created_at ?? '',
|
||||
// 保留原始权限 ID 供编辑使用
|
||||
_permissionIds: Array.isArray(raw.permissions)
|
||||
? raw.permissions.map((p: any) => (typeof p === 'object' ? Number(p.id) : 0)).filter(Boolean)
|
||||
: [],
|
||||
} as Role & { _permissionIds: number[] };
|
||||
_permissionIds: rawPerms
|
||||
.map((p: any) => (typeof p === 'object' ? Number(p.id) : 0))
|
||||
.filter(Boolean),
|
||||
} as Role & { _permissionIds: number[]; permissionCodes: string[] };
|
||||
}
|
||||
|
||||
const emptyForm: RoleFormData = {
|
||||
@@ -185,7 +186,7 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{roles.map((role) => (
|
||||
{roles.map((role: any) => (
|
||||
<Card key={role.id} className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -228,21 +229,25 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-400 mb-3">{role.description || '暂无描述'}</p>
|
||||
{/* 权限按模块分组显示 // Logan.Li */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{role.permissions.slice(0, 5).map((permission) => (
|
||||
<Badge key={permission} variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
||||
{(role.permissions as string[]).slice(0, 8).map((permission, idx) => (
|
||||
<Badge key={idx} variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
||||
{permission}
|
||||
</Badge>
|
||||
))}
|
||||
{role.permissions.length > 5 && (
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
||||
+{role.permissions.length - 5}
|
||||
{(role.permissions as string[]).length > 8 && (
|
||||
<Badge variant="outline" className="border-blue-500/30 text-blue-400 text-xs">
|
||||
+{(role.permissions as string[]).length - 8} 项
|
||||
</Badge>
|
||||
)}
|
||||
{role.permissions.length === 0 && (
|
||||
{(role.permissions as string[]).length === 0 && (
|
||||
<span className="text-xs text-slate-600">未分配权限</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-600">
|
||||
共 {role.permissions?.length || 0} 项权限
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -294,11 +299,11 @@ function RoleDetailDialog({
|
||||
<p className="text-sm text-slate-200">{role?.description || '暂无描述'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">权限列表</h4>
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">权限列表 ({role?.permissions?.length || 0} 项)</h4>
|
||||
{role && role.permissions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{role.permissions.map((p) => (
|
||||
<Badge key={p} variant="outline" className="border-slate-700 text-slate-400">
|
||||
<div className="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
|
||||
{role.permissions.map((p: string, idx: number) => (
|
||||
<Badge key={idx} variant="outline" className="border-slate-700 text-slate-300">
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Search, RefreshCw, Trash2, Play } from 'lucide-react';
|
||||
import { terminalSessionApi } from '@/services/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { ReplayDialog } from './ReplayDialog';
|
||||
|
||||
interface TerminalSession {
|
||||
id: number;
|
||||
user_id?: number;
|
||||
username: string;
|
||||
server_id: number;
|
||||
server_name: string;
|
||||
server_ip: string;
|
||||
server_port: number;
|
||||
client_ip: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
record_size: number;
|
||||
start_time: string;
|
||||
end_time?: string | null;
|
||||
}
|
||||
|
||||
function formatDuration(sec: number): string {
|
||||
if (!sec || sec < 0) return '--';
|
||||
if (sec < 60) return `${sec}秒`;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
if (m < 60) return `${m}分${s > 0 ? `${s}秒` : ''}`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rm = m % 60;
|
||||
return `${h}时${rm > 0 ? `${rm}分` : ''}`;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 终端审计 Tab — WebSSH 会话录制列表与回放(JumpServer 式审计) // Logan.Li
|
||||
*/
|
||||
export function TerminalSessionsTab() {
|
||||
const confirm = useConfirm();
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [replayId, setReplayId] = useState<number | null>(null);
|
||||
const [replayOpen, setReplayOpen] = useState(false);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await terminalSessionApi.list({ page: 1, page_size: 100 });
|
||||
setSessions(resp?.list ?? []);
|
||||
} catch {
|
||||
setSessions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
}, []);
|
||||
|
||||
const filtered = sessions.filter((s) => {
|
||||
const kw = keyword.toLowerCase();
|
||||
const kwMatch =
|
||||
!kw ||
|
||||
(s.username || '').toLowerCase().includes(kw) ||
|
||||
(s.server_name || '').toLowerCase().includes(kw) ||
|
||||
(s.server_ip || '').toLowerCase().includes(kw) ||
|
||||
(s.client_ip || '').toLowerCase().includes(kw);
|
||||
const stMatch = !statusFilter || s.status === statusFilter;
|
||||
return kwMatch && stMatch;
|
||||
});
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
if (status === 'active') {
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
活动中
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
||||
失败
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
|
||||
已关闭
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
setSelected((prev) =>
|
||||
prev.size === filtered.length ? new Set() : new Set(filtered.map((s) => s.id))
|
||||
);
|
||||
};
|
||||
|
||||
const openReplay = (id: number) => {
|
||||
setReplayId(id);
|
||||
setReplayOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const ok = await confirm({
|
||||
title: '删除会话记录',
|
||||
description: '确认删除该终端会话记录?删除后无法恢复。',
|
||||
confirmText: '删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await terminalSessionApi.delete(id);
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
await fetchSessions();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selected.size === 0) return;
|
||||
const ok = await confirm({
|
||||
title: '批量删除',
|
||||
description: `确认删除选中的 ${selected.size} 条终端会话记录?`,
|
||||
confirmText: '删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await terminalSessionApi.batchDelete(Array.from(selected));
|
||||
setSelected(new Set());
|
||||
await fetchSessions();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
const allChecked = filtered.length > 0 && selected.size === filtered.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索用户/服务器/IP..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{['', 'active', 'closed', 'failed'].map((st) => (
|
||||
<Button
|
||||
key={st || 'all'}
|
||||
variant={statusFilter === st ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={
|
||||
statusFilter === st
|
||||
? 'h-8 bg-blue-600 hover:bg-blue-700 text-white'
|
||||
: 'h-8 border-slate-700 text-slate-400 hover:text-slate-100 hover:bg-slate-800'
|
||||
}
|
||||
onClick={() => setStatusFilter(st)}
|
||||
>
|
||||
{st === '' ? '全部' : st === 'active' ? '活动中' : st === 'closed' ? '已关闭' : '失败'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={fetchSessions}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
{selected.size > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={handleBatchDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
批量删除({selected.size})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sessions Table */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<Search className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无终端会话记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
onChange={toggleAll}
|
||||
className="accent-blue-600 cursor-pointer"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-400">用户</TableHead>
|
||||
<TableHead className="text-slate-400">服务器</TableHead>
|
||||
<TableHead className="text-slate-400">来源 IP</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">时长</TableHead>
|
||||
<TableHead className="text-slate-400">录制</TableHead>
|
||||
<TableHead className="text-slate-400">开始时间</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((s) => (
|
||||
<TableRow key={s.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(s.id)}
|
||||
onChange={() => toggleSelect(s.id)}
|
||||
className="accent-blue-600 cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200">{s.username || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-slate-200">{s.server_name || '--'}</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{s.server_ip}:{s.server_port}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{s.client_ip || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(s.status)}</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{formatDuration(s.duration)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-slate-500">{formatSize(s.record_size)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{s.start_time ? new Date(s.start_time).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
|
||||
onClick={() => openReplay(s.id)}
|
||||
title="回放"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400 hover:bg-slate-800"
|
||||
onClick={() => handleDelete(s.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ReplayDialog open={replayOpen} onOpenChange={setReplayOpen} sessionId={replayId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -527,6 +527,25 @@ export const auditApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 终端会话审计 API(WebSSH 录制) ==================== // Logan.Li
|
||||
export const terminalSessionApi = {
|
||||
list: async (params?: { page?: number; page_size?: number; server_id?: number; username?: string; status?: string }) => {
|
||||
return http.get<ListResponse>('/terminal-sessions', params);
|
||||
},
|
||||
get: async (id: number) => {
|
||||
return http.get(`/terminal-sessions/${id}`);
|
||||
},
|
||||
getRecord: async (id: number) => {
|
||||
return http.get<string>(`/terminal-sessions/${id}/record`);
|
||||
},
|
||||
delete: async (id: number) => {
|
||||
return http.delete(`/terminal-sessions/${id}`);
|
||||
},
|
||||
batchDelete: async (ids: number[]) => {
|
||||
return http.post('/terminal-sessions/batch-delete', { ids });
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== Ansible 自动化 API ====================
|
||||
export const ansibleApi = {
|
||||
// Playbook 管理
|
||||
|
||||
Reference in new issue
Block a user