feat(rdp): 新增 RDP 远程桌面/SMB/账单同步能力 + 清理编译产物

主要变更:
- 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>
This commit is contained in:
LoganandClaude committed 2026-07-04 01:50:36 +08:00
1 parent 777d9e4e71
commit 76ec9387f0
202 files changed
+16555 -2543

No files matched your search

@@ -1,6 +1,7 @@
// 终端审计 — 在线会话子面板 // Logan.Li
import { useState, useEffect } 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';
@@ -17,7 +18,10 @@ import {
import { Search, RefreshCw, XOctagon, Eye, Activity, Timer, TimerOff } from 'lucide-react';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { LiveMonitorDialog } from '@/components/system/LiveMonitorDialog';
import { RdpAuditViewer } from '@/components/system/RdpAuditViewer';
import { useTerminalSessions, useTerminalSessionMutations } from '@/hooks/api';
import { rdpSessionApi } from '@/api';
import { unwrapList } from '@/hooks/api/unwrap';
import type { TerminalSession } from './types';
import { formatDuration, getStatusBadge } from './helpers';
@@ -105,6 +109,44 @@ function ActiveSessionRow({
);
}
function RdpActiveSessionRow({
session,
onKill,
onMonitor,
}: {
session: TerminalSession;
onKill: (id: number, username: string) => void;
onMonitor: (id: number) => void;
}) {
const { t } = useTranslation();
const elapsed = useElapsedTimer(session.start_time);
return (
<TableRow className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<TableCell><span className="text-sm dg-text-secondary">{session.id}</span></TableCell>
<TableCell><span className="text-sm text-[var(--dg-text-primary)]">{session.username || '--'}</span></TableCell>
<TableCell>
<div className="flex flex-col">
<span className="text-sm text-[var(--dg-text-primary)]">{session.server_name || '--'}</span>
<span className="text-xs dg-text-muted">{session.server_ip}</span>
</div>
</TableCell>
<TableCell><span className="text-sm dg-text-secondary">{session.start_time ? new Date(session.start_time).toLocaleString() : '--'}</span></TableCell>
<TableCell><span className="text-sm dg-text-green font-mono">{formatDuration(elapsed)}</span></TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-red" onClick={() => onKill(session.id, session.username)} title={t('audit.terminal.killTitle')}>
<XOctagon className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-blue" onClick={() => onMonitor(session.id)} title="RDP 实时审计">
<Eye className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
}
export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: ActiveSessionsPanelProps) {
const { t } = useTranslation();
const confirm = useConfirm();
@@ -114,12 +156,22 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
page_size: 100,
status: 'active',
});
const rdpQuery = useQuery({
queryKey: ['rdp-sessions', 'active'],
queryFn: async () => {
const resp = await rdpSessionApi.list({ page: 1, page_size: 100, status: 'active' });
return unwrapList<TerminalSession>(resp);
},
});
const sessions = data?.list ?? [];
const rdpSessions = rdpQuery.data ?? [];
const [keyword, setKeyword] = useState('');
const [autoRefresh, setAutoRefresh] = useState(() => !userToggledOff);
const [monitorOpen, setMonitorOpen] = useState(false);
const [monitorSessionId, setMonitorSessionId] = useState<number | null>(null);
const [rdpOpen, setRdpOpen] = useState(false);
const [rdpSessionId, setRdpSessionId] = useState<number | null>(null);
// 用户未手动关闭时,回到在线会话自动开启刷新
useEffect(() => {
@@ -128,9 +180,9 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
useEffect(() => {
if (!autoRefresh) return;
const id = setInterval(() => refetch(), REFRESH_SEC * 1000);
const id = setInterval(() => { refetch(); rdpQuery.refetch(); }, REFRESH_SEC * 1000);
return () => clearInterval(id);
}, [autoRefresh, refetch]);
}, [autoRefresh, refetch, rdpQuery]);
const filtered = sessions.filter((s) => {
const kw = keyword.toLowerCase();
@@ -162,6 +214,23 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
setMonitorOpen(true);
};
const openRdpMonitor = (sessionId: number) => {
setRdpSessionId(sessionId);
setRdpOpen(true);
};
const killRdp = async (id: number, username: string) => {
const ok = await confirm({
title: t('audit.terminal.killTitle'),
description: t('audit.terminal.killConfirm', { user: username }),
confirmText: t('audit.terminal.killConfirmBtn'),
variant: 'destructive',
});
if (!ok) return;
await rdpSessionApi.kill(id).catch(() => undefined);
rdpQuery.refetch();
};
return (
<>
<div className="flex items-center gap-3 flex-wrap">
@@ -247,7 +316,40 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
</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-10"><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-10 dg-text-muted">
<Activity 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 text-right">{t('audit.terminal.col.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rdpSessions.map((s) => (
<RdpActiveSessionRow key={`rdp-${s.id}`} session={s} onKill={killRdp} onMonitor={openRdpMonitor} />
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<LiveMonitorDialog open={monitorOpen} onOpenChange={setMonitorOpen} sessionId={monitorSessionId} />
<RdpAuditViewer open={rdpOpen} onOpenChange={setRdpOpen} sessionId={rdpSessionId} mode="live" />
</>
);
}
@@ -1,6 +1,7 @@
// 终端审计 — 历史会话子面板(筛选 / 分页 / 回放 / 导出,只读无删除) // 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';
@@ -11,14 +12,17 @@ import {
} 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 } from '@/hooks/api/unwrap';
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;
@@ -26,6 +30,7 @@ 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('');
@@ -47,17 +52,38 @@ export function HistorySessionsPanel() {
...(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) => {
@@ -65,6 +91,11 @@ export function HistorySessionsPanel() {
setReplayOpen(true);
};
const openRdpReplay = (id: number) => {
setRdpReplayId(id);
setRdpReplayOpen(true);
};
const handleDownload = (id: number) => {
terminalSessionApi.download(id).catch(() => toast.error(t('audit.terminal.exportFailed')));
};
@@ -128,8 +159,8 @@ export function HistorySessionsPanel() {
{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()} disabled={isFetching}>
<RefreshCw className={cn('h-4 w-4 mr-1', isFetching && 'animate-spin')} />{t('common.refresh')}
<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>
@@ -215,7 +246,81 @@ export function HistorySessionsPanel() {
</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" />
</>
);
}
}
@@ -8,8 +8,9 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Search, RefreshCw, Play, FileText } from 'lucide-react';
import { useSessionCommands } from '@/hooks/api';
import { terminalSessionApi, sessionCommandApi } from '@/api';
import { terminalSessionApi, sessionCommandApi, rdpSessionApi } from '@/api';
import { ReplayDialog } from '@/components/system/ReplayDialog';
import { RdpAuditViewer } from '@/components/system/RdpAuditViewer';
import { formatCommandOutput, presetAuditRange } from './helpers';
import { isHighRiskCommand } from '@/components/system/replay/replay-utils';
import { exportCommandsListCsv } from '@/components/system/replay/export-audit';
@@ -35,6 +36,8 @@ export function SessionCommandsPanel() {
const [replayOpen, setReplayOpen] = useState(false);
const [replayId, setReplayId] = useState<number | null>(null);
const [replaySeek, setReplaySeek] = useState<number | undefined>(undefined);
const [rdpReplayOpen, setRdpReplayOpen] = useState(false);
const [rdpReplayId, setRdpReplayId] = useState<number | null>(null);
const [exporting, setExporting] = useState(false);
const queryParams = useMemo(() => ({
@@ -93,6 +96,16 @@ export function SessionCommandsPanel() {
toast.error(t('audit.terminal.replayPlayer.noRecord'));
return;
}
if (cmd.session_type === 'rdp') {
try {
await rdpSessionApi.get(cmd.session_id);
setRdpReplayId(cmd.session_id);
setRdpReplayOpen(true);
} catch {
toast.error(t('audit.terminal.replayPlayer.loadFailed'));
}
return;
}
try {
const sess = await terminalSessionApi.get(cmd.session_id) as { start_time?: string };
const ts = cmd.timestamp || cmd.created_at;
@@ -233,7 +246,7 @@ export function SessionCommandsPanel() {
'text-xs',
c.session_record_size ? 'dg-text-muted' : 'text-amber-500/80',
)} title={!c.session_record_size ? t('audit.terminal.replayPlayer.noRecord') : undefined}>
#{c.session_id}
{c.session_type === 'rdp' ? 'RDP' : 'SSH'} #{c.session_id}
</span>
<Button
variant="ghost"
@@ -270,6 +283,7 @@ export function SessionCommandsPanel() {
sessionId={replayId}
initialSeekSec={replaySeek}
/>
<RdpAuditViewer open={rdpReplayOpen} onOpenChange={setRdpReplayOpen} sessionId={rdpReplayId} mode="replay" />
</>
);
}
}
@@ -19,6 +19,7 @@ export interface TerminalSession {
export interface SessionCommand {
id: number;
session_id: number;
session_type?: 'terminal' | 'rdp';
username: string;
server_name?: string;
command: string;
@@ -28,4 +29,4 @@ export interface SessionCommand {
created_at: string;
}
export type SubTabId = 'active' | 'history' | 'commands';
export type SubTabId = 'active' | 'history' | 'commands';