P1: - utils/storage.ts、theme.ts、validate.ts + index 统一出口 - request/store/useTheme 接入 storage;清零 confirm/alert - PipelineManager、TerminalAuditTab 拆分子模块(<700行红线) P2: - api/monitor、api/deploy 子目录化 - hooks/api 扩展:servers/pipelines/alerts/audit/sessionCommands - PipelineManager、AuditOperationsTab 接入 TanStack Query
170 lines
6.9 KiB
TypeScript
170 lines
6.9 KiB
TypeScript
// 终端审计 — 历史会话子面板 // Logan.Li
|
|
|
|
import { useState } from 'react';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import { Search, RefreshCw, Play, Download, Monitor } from 'lucide-react';
|
|
import { terminalSessionApi } from '@/api';
|
|
import { ReplayDialog } from '@/components/system/ReplayDialog';
|
|
import { useTerminalSessions } from '@/hooks/api';
|
|
import { formatDuration, getStatusBadge } from './helpers';
|
|
|
|
export function HistorySessionsPanel() {
|
|
const { data: sessions = [], isLoading, refetch } = useTerminalSessions({
|
|
page: 1,
|
|
page_size: 100,
|
|
status: 'closed',
|
|
});
|
|
|
|
const [keyword, setKeyword] = useState('');
|
|
const [replayId, setReplayId] = useState<number | null>(null);
|
|
const [replayOpen, setReplayOpen] = useState(false);
|
|
|
|
const filtered = sessions.filter((s) => {
|
|
const kw = keyword.toLowerCase();
|
|
return (
|
|
!kw ||
|
|
(s.username || '').toLowerCase().includes(kw) ||
|
|
(s.server_name || '').toLowerCase().includes(kw) ||
|
|
(s.server_ip || '').toLowerCase().includes(kw)
|
|
);
|
|
});
|
|
|
|
const openReplay = (id: number) => {
|
|
setReplayId(id);
|
|
setReplayOpen(true);
|
|
};
|
|
|
|
const handleDownload = async (id: number) => {
|
|
try {
|
|
await terminalSessionApi.download(id);
|
|
} catch {
|
|
// download error is handled in http.download
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<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>
|
|
<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>
|
|
</div>
|
|
|
|
<Card className="bg-slate-900 border-slate-800">
|
|
<CardContent className="p-0">
|
|
<div className="border-t border-slate-800">
|
|
{isLoading ? (
|
|
<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">
|
|
<Monitor 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">ID</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">结束时间</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>
|
|
<span className="text-sm text-slate-400">{s.id}</span>
|
|
</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}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="text-sm text-slate-400">{s.server_port}</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="text-sm text-slate-400">
|
|
{s.start_time ? new Date(s.start_time).toLocaleString() : '--'}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="text-sm text-slate-400">
|
|
{s.end_time ? new Date(s.end_time).toLocaleString() : '--'}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="text-sm text-slate-300">{formatDuration(s.duration)}</span>
|
|
</TableCell>
|
|
<TableCell>{getStatusBadge(s.status)}</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-green-400 hover:bg-slate-800"
|
|
onClick={() => handleDownload(s.id)}
|
|
title="下载录制"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<ReplayDialog open={replayOpen} onOpenChange={setReplayOpen} sessionId={replayId} />
|
|
</>
|
|
);
|
|
} |