+ {role.permissions.map((p: string, idx: number) => (
+
{p}
))}
diff --git a/frontend/src/components/system/TerminalSessionsTab.tsx b/frontend/src/components/system/TerminalSessionsTab.tsx
new file mode 100644
index 0000000..2230062
--- /dev/null
+++ b/frontend/src/components/system/TerminalSessionsTab.tsx
@@ -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
([]);
+ const [loading, setLoading] = useState(true);
+ const [keyword, setKeyword] = useState('');
+ const [statusFilter, setStatusFilter] = useState('');
+ const [selected, setSelected] = useState>(new Set());
+ const [replayId, setReplayId] = useState(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 (
+
+ 活动中
+
+ );
+ }
+ if (status === 'failed') {
+ return (
+
+ 失败
+
+ );
+ }
+ return (
+
+ 已关闭
+
+ );
+ };
+
+ 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 */}
+
+
+
+ setKeyword(e.target.value)}
+ className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
+ />
+
+
+ {['', 'active', 'closed', 'failed'].map((st) => (
+
+ ))}
+
+
+ {selected.size > 0 && (
+
+ )}
+
+
+ {/* Sessions Table */}
+
+
+
+ {loading ? (
+
+ ) : filtered.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index ab2231a..5ef888f 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -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('/terminal-sessions', params);
+ },
+ get: async (id: number) => {
+ return http.get(`/terminal-sessions/${id}`);
+ },
+ getRecord: async (id: number) => {
+ return http.get(`/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 管理