- Agent CLI: 多轮对话、SSH 长连接、流式推理、Evolver 按用户隔离、历史侧栏 - 终端审计: 回放播放器、命令书签、筛选分页导出、命令输出统计、录制落盘修复 - 修复 session_commands 表迁移、Kill 竞态导致录制丢失、无录制会话回放提示 - 审计历史页只读(移除删除),移除回放弹框录制搜索
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
import { useState } 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 { useTerminalSessions, useTerminalSessionMutations } from '@/hooks/api/useTerminalSessions';
|
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
|
import { ReplayDialog } from './ReplayDialog';
|
|
|
|
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 { data, isLoading: loading, refetch } = useTerminalSessions({ page: 1, page_size: 100 });
|
|
const sessions = data?.list ?? [];
|
|
const { deleteMutation, batchDeleteMutation } = useTerminalSessionMutations();
|
|
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 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 deleteMutation.mutateAsync(id);
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(id);
|
|
return next;
|
|
});
|
|
} 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 batchDeleteMutation.mutateAsync(Array.from(selected));
|
|
setSelected(new Set());
|
|
} 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={() => refetch()}
|
|
>
|
|
<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} />
|
|
</>
|
|
);
|
|
}
|