主要变更: - RDP 远程桌面:新增 backend/pkg/rdp(RDP 引擎 + credssp/display/rle/trace)、 backend/controller/rdp*(连接/凭证/文件/会话/实时)、frontend RDP 组件与 API - Windows SMB 文件传输:backend/pkg/winsmb - 云账单同步:backend/pkg/billingsync + billing_sync/aliyun_billing,前端账单详情 - Agent CLI Kubernetes 工具:kubernetes_action/get、pod_execute/logs - 服务器导入助手:server_import_helpers、ImportCredentialSettings - 国际化与组件重构(cloud/deploy/monitor/server/system 等多模块) - 清理编译产物二进制:移除 backend/server、backend/agent/bin/aiops-agent-* - .gitignore:新增 backend/agent/ 与 .cache/(避免 334MB go-build 缓存误入库) Co-Authored-By: Claude <noreply@anthropic.com>
327 lines
18 KiB
TypeScript
327 lines
18 KiB
TypeScript
// 终端审计 — 历史会话子面板(筛选 / 分页 / 回放 / 导出,只读无删除) // Logan.Li
|
|
|
|
import { useState, useMemo } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { cn } from '@/lib/utils';
|
|
import { useTranslation } from 'react-i18next';
|
|
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, FileText, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
import { terminalSessionApi, sessionCommandApi } from '@/api';
|
|
import { rdpSessionApi } from '@/api';
|
|
import { ReplayDialog } from '@/components/system/ReplayDialog';
|
|
import { RdpAuditViewer } from '@/components/system/RdpAuditViewer';
|
|
import { useTerminalSessions } from '@/hooks/api';
|
|
import { unwrapList, unwrapTotal } from '@/hooks/api/unwrap';
|
|
import { toast } from 'sonner';
|
|
import { formatDuration, formatSize, getStatusBadge, presetAuditRange } from './helpers';
|
|
import { buildCommandBookmarks } from '@/components/system/replay/replay-utils';
|
|
import { exportAuditReportCsv } from '@/components/system/replay/export-audit';
|
|
import type { ReplaySessionMeta } from '@/components/system/replay/replay-utils';
|
|
import type { TerminalSession } from './types';
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
export function HistorySessionsPanel() {
|
|
const { t } = useTranslation();
|
|
|
|
const [page, setPage] = useState(1);
|
|
const [rdpPage, setRdpPage] = useState(1);
|
|
const [keyword, setKeyword] = useState('');
|
|
const [username, setUsername] = useState('');
|
|
const [startDate, setStartDate] = useState('');
|
|
const [endDate, setEndDate] = useState('');
|
|
const [hasRecordOnly, setHasRecordOnly] = useState(false);
|
|
|
|
const [applied, setApplied] = useState({
|
|
keyword: '', username: '', startDate: '', endDate: '', hasRecordOnly: false,
|
|
});
|
|
|
|
const queryParams = useMemo(() => ({
|
|
page,
|
|
page_size: PAGE_SIZE,
|
|
status: 'closed',
|
|
...(applied.keyword ? { keyword: applied.keyword } : {}),
|
|
...(applied.username ? { username: applied.username } : {}),
|
|
...(applied.startDate ? { start_date: applied.startDate } : {}),
|
|
...(applied.endDate ? { end_date: applied.endDate } : {}),
|
|
...(applied.hasRecordOnly ? { has_record: '1' } : {}),
|
|
}), [page, applied]);
|
|
|
|
const rdpQueryParams = useMemo(() => ({
|
|
...queryParams,
|
|
page: rdpPage,
|
|
}), [queryParams, rdpPage]);
|
|
|
|
const { data, isLoading, isFetching, refetch } = useTerminalSessions(queryParams);
|
|
const rdpQuery = useQuery({
|
|
queryKey: ['rdp-sessions', 'history', rdpQueryParams],
|
|
queryFn: async () => {
|
|
const resp = await rdpSessionApi.list(rdpQueryParams);
|
|
return {
|
|
list: unwrapList<TerminalSession>(resp),
|
|
total: unwrapTotal(resp),
|
|
};
|
|
},
|
|
});
|
|
const sessions = data?.list ?? [];
|
|
const total = data?.total ?? 0;
|
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
const rdpSessions = rdpQuery.data?.list ?? [];
|
|
const rdpTotal = rdpQuery.data?.total ?? 0;
|
|
const rdpTotalPages = Math.max(1, Math.ceil(rdpTotal / PAGE_SIZE));
|
|
|
|
const [replayId, setReplayId] = useState<number | null>(null);
|
|
const [replayOpen, setReplayOpen] = useState(false);
|
|
const [rdpReplayId, setRdpReplayId] = useState<number | null>(null);
|
|
const [rdpReplayOpen, setRdpReplayOpen] = useState(false);
|
|
|
|
const applyFilters = () => {
|
|
setApplied({ keyword, username, startDate, endDate, hasRecordOnly });
|
|
setPage(1);
|
|
setRdpPage(1);
|
|
};
|
|
|
|
const openReplay = (id: number) => {
|
|
setReplayId(id);
|
|
setReplayOpen(true);
|
|
};
|
|
|
|
const openRdpReplay = (id: number) => {
|
|
setRdpReplayId(id);
|
|
setRdpReplayOpen(true);
|
|
};
|
|
|
|
const handleDownload = (id: number) => {
|
|
terminalSessionApi.download(id).catch(() => toast.error(t('audit.terminal.exportFailed')));
|
|
};
|
|
|
|
// 导出审计报告:命令列表失败时仍导出会话元信息 // Logan.Li
|
|
const handleExportReport = async (id: number) => {
|
|
try {
|
|
const sess = await terminalSessionApi.get(id);
|
|
const meta = sess as ReplaySessionMeta;
|
|
let cmdList: { id: number; command: string; timestamp?: string; created_at?: string }[] = [];
|
|
try {
|
|
const cmds = await sessionCommandApi.list({ session_id: id, page: 1, page_size: 500 });
|
|
cmdList = unwrapList(cmds);
|
|
} catch {
|
|
/* 无命令表数据时仅导出会话信息 */
|
|
}
|
|
const duration = meta.duration || 0;
|
|
const bookmarks = buildCommandBookmarks(cmdList, meta.start_time, duration);
|
|
exportAuditReportCsv({ ...meta, id }, bookmarks);
|
|
toast.success(t('audit.terminal.exportReport'));
|
|
} catch {
|
|
toast.error(t('audit.terminal.exportFailed'));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<div className="relative w-52">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
|
<Input
|
|
placeholder={t('audit.terminal.searchUserServerIp')}
|
|
value={keyword}
|
|
onChange={(e) => setKeyword(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
|
className="pl-9 h-9 dg-input"
|
|
/>
|
|
</div>
|
|
<Input
|
|
placeholder={t('audit.terminal.filterUsername')}
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
|
className="w-32 h-9 dg-input"
|
|
/>
|
|
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="w-36 h-9 dg-input" />
|
|
<span className="dg-text-muted text-sm">—</span>
|
|
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="w-36 h-9 dg-input" />
|
|
<Button variant="outline" size="sm" className="dg-btn-secondary h-9" onClick={() => { const r = presetAuditRange(1); setStartDate(r.start); setEndDate(r.end); }}>
|
|
{t('audit.terminal.presetToday')}
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="dg-btn-secondary h-9" onClick={() => { const r = presetAuditRange(7); setStartDate(r.start); setEndDate(r.end); }}>
|
|
{t('audit.terminal.preset7d')}
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="dg-btn-secondary h-9" onClick={() => { const r = presetAuditRange(30); setStartDate(r.start); setEndDate(r.end); }}>
|
|
{t('audit.terminal.preset30d')}
|
|
</Button>
|
|
<label className="flex items-center gap-1.5 text-xs dg-text-secondary cursor-pointer">
|
|
<input type="checkbox" checked={hasRecordOnly} onChange={(e) => setHasRecordOnly(e.target.checked)} className="rounded" />
|
|
{t('audit.terminal.hasRecordOnly')}
|
|
</label>
|
|
<Button size="sm" className="h-9" onClick={applyFilters}>{t('audit.terminal.applyFilter')}</Button>
|
|
<Button variant="outline" size="sm" className="dg-btn-secondary h-9" onClick={() => { refetch(); rdpQuery.refetch(); }} disabled={isFetching || rdpQuery.isFetching}>
|
|
<RefreshCw className={cn('h-4 w-4 mr-1', (isFetching || rdpQuery.isFetching) && 'animate-spin')} />{t('common.refresh')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Card className="dg-card">
|
|
<CardContent className="p-0">
|
|
{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>
|
|
) : sessions.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 dg-text-muted">
|
|
<Monitor className="h-8 w-8 mb-3 opacity-40" />
|
|
<p className="text-sm">{t('audit.terminal.noHistory')}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
|
<TableHead className="dg-text-secondary">{t('audit.terminal.col.id')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('audit.terminal.col.user')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('audit.terminal.col.targetServer')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('audit.terminal.col.startTime')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('audit.terminal.col.duration')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('audit.terminal.col.recordSize')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('audit.terminal.col.status')}</TableHead>
|
|
<TableHead className="dg-text-secondary text-right">{t('audit.terminal.col.actions')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{sessions.map((s) => (
|
|
<TableRow key={s.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
|
<TableCell><span className="text-sm dg-text-secondary">{s.id}</span></TableCell>
|
|
<TableCell><span className="text-sm text-[var(--dg-text-primary)]">{s.username || '--'}</span></TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-col">
|
|
<span className="text-sm text-[var(--dg-text-primary)]">{s.server_name || '--'}</span>
|
|
<span className="text-xs dg-text-muted">{s.server_ip}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="text-sm dg-text-secondary">
|
|
{s.start_time ? new Date(s.start_time).toLocaleString() : '--'}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell><span className="text-sm text-[var(--dg-text-secondary)]">{formatDuration(s.duration)}</span></TableCell>
|
|
<TableCell>
|
|
<span className="text-sm dg-text-secondary">{s.record_size > 0 ? formatSize(s.record_size) : '--'}</span>
|
|
</TableCell>
|
|
<TableCell>{getStatusBadge(s.status)}</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center justify-end gap-0.5">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-blue" onClick={() => openReplay(s.id)} disabled={!s.record_size} title={t('audit.terminal.replay')}>
|
|
<Play className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-yellow" onClick={() => handleExportReport(s.id)} title={t('audit.terminal.exportReport')}>
|
|
<FileText className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-green" onClick={() => handleDownload(s.id)} disabled={!s.record_size} title={t('audit.terminal.downloadRecord')}>
|
|
<Download className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--dg-border)]">
|
|
<span className="text-xs dg-text-muted">{t('audit.terminal.totalSessions', { count: total })}</span>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<span className="text-xs dg-text-secondary">{page} / {totalPages}</span>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="dg-card">
|
|
<CardContent className="p-0">
|
|
<div className="px-4 py-2 border-b border-[var(--dg-border)] text-xs font-semibold dg-text-secondary">RDP 历史会话</div>
|
|
{rdpQuery.isLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="w-7 h-7 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
) : rdpSessions.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 dg-text-muted">
|
|
<Monitor className="h-7 w-7 mb-2 opacity-40" />
|
|
<p className="text-sm">暂无 RDP 历史会话</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
|
<TableHead className="dg-text-secondary">{t('audit.terminal.col.id')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('audit.terminal.col.user')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('audit.terminal.col.targetServer')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('audit.terminal.col.startTime')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('audit.terminal.col.duration')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('audit.terminal.col.recordSize')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('audit.terminal.col.status')}</TableHead>
|
|
<TableHead className="dg-text-secondary text-right">{t('audit.terminal.col.actions')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{rdpSessions.map((s) => (
|
|
<TableRow key={`rdp-history-${s.id}`} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
|
<TableCell><span className="text-sm dg-text-secondary">{s.id}</span></TableCell>
|
|
<TableCell><span className="text-sm text-[var(--dg-text-primary)]">{s.username || '--'}</span></TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-col">
|
|
<span className="text-sm text-[var(--dg-text-primary)]">{s.server_name || '--'}</span>
|
|
<span className="text-xs dg-text-muted">{s.server_ip}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell><span className="text-sm dg-text-secondary">{s.start_time ? new Date(s.start_time).toLocaleString() : '--'}</span></TableCell>
|
|
<TableCell><span className="text-sm text-[var(--dg-text-secondary)]">{formatDuration(s.duration)}</span></TableCell>
|
|
<TableCell><span className="text-sm dg-text-secondary">{s.record_size > 0 ? formatSize(s.record_size) : '--'}</span></TableCell>
|
|
<TableCell>{getStatusBadge(s.status)}</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center justify-end gap-0.5">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-blue" onClick={() => openRdpReplay(s.id)} disabled={!s.record_size} title="RDP 历史回放">
|
|
<Play className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-green" onClick={() => rdpSessionApi.download(s.id)} disabled={!s.record_size} title={t('audit.terminal.downloadRecord')}>
|
|
<Download className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--dg-border)]">
|
|
<span className="text-xs dg-text-muted">RDP 会话共 {rdpTotal} 条</span>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={rdpPage <= 1} onClick={() => setRdpPage((p) => p - 1)}>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<span className="text-xs dg-text-secondary">{rdpPage} / {rdpTotalPages}</span>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={rdpPage >= rdpTotalPages} onClick={() => setRdpPage((p) => p + 1)}>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<ReplayDialog open={replayOpen} onOpenChange={setReplayOpen} sessionId={replayId} />
|
|
<RdpAuditViewer open={rdpReplayOpen} onOpenChange={setRdpReplayOpen} sessionId={rdpReplayId} mode="replay" />
|
|
</>
|
|
);
|
|
}
|