远程 i18n 全量改造 + 堡垒机合并后引入两类质量回归,本次修复:
1. ESLint errors 8→0:
- GrafanaDashboard/PrometheusDashboard: 未使用的 const { t } 改为 useTranslation()
- ServerTableRow: 移除未使用 cn import
- replay-utils: no-control-regex 标注 disable (终端回放有意检测控制字符)
2. React Hooks 6 大关键规则 82→0 (恢复 U7 治理水平):
- exhaustive-deps 52→0 (mount-only effects 标注 disable)
- set-state-in-effect 18→0 (effect 初始化模式)
- purity 5→0 (AlertCenter 提取 nowMs / EnhancedLogResults 骨架屏确定性公式)
- incompatible-library 3→0 (@tanstack/react-virtual 兼容性)
- immutability 2→0 (AlertCenter TDZ)
- preserve-manual-memoization 2→0 (StatChartPanel useMemo)
验证: tsc -b=0, npm build✓(18.04s), go build=0, ESLint errors=0, 17/17 测试通过
357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
// 终端审计 — 在线会话子面板 // 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';
|
|
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, 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';
|
|
|
|
const REFRESH_SEC = 10;
|
|
|
|
interface ActiveSessionsPanelProps {
|
|
userToggledOff: boolean;
|
|
onUserToggledOff: () => void;
|
|
}
|
|
|
|
function useElapsedTimer(startTime: string | undefined): number {
|
|
const [elapsed, setElapsed] = useState(0);
|
|
useEffect(() => {
|
|
if (!startTime) return;
|
|
const start = new Date(startTime).getTime();
|
|
const tick = () => setElapsed(Math.floor((Date.now() - start) / 1000));
|
|
tick();
|
|
const id = setInterval(tick, 1000);
|
|
return () => clearInterval(id);
|
|
}, [startTime]);
|
|
return elapsed;
|
|
}
|
|
|
|
function ActiveSessionRow({
|
|
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.server_port}</span>
|
|
</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>{getStatusBadge(session.status)}</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 hover:bg-[var(--dg-bg-hover)]"
|
|
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 hover:bg-[var(--dg-bg-hover)]"
|
|
onClick={() => onMonitor(session.id)}
|
|
title={t('audit.terminal.liveMonitor')}
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
}
|
|
|
|
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();
|
|
const { killMutation } = useTerminalSessionMutations();
|
|
const { data, isLoading, isFetching, refetch } = useTerminalSessions({
|
|
page: 1,
|
|
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(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
if (!userToggledOff) setAutoRefresh(true);
|
|
}, [userToggledOff]);
|
|
|
|
useEffect(() => {
|
|
if (!autoRefresh) return;
|
|
const id = setInterval(() => { refetch(); rdpQuery.refetch(); }, REFRESH_SEC * 1000);
|
|
return () => clearInterval(id);
|
|
}, [autoRefresh, refetch, rdpQuery]);
|
|
|
|
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 handleKill = 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;
|
|
try {
|
|
await killMutation.mutateAsync(id);
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
};
|
|
|
|
const openMonitor = (sessionId: number) => {
|
|
setMonitorSessionId(sessionId);
|
|
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">
|
|
<div className="relative w-64">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
|
<Input
|
|
placeholder={t('audit.terminal.searchUserServer')}
|
|
value={keyword}
|
|
onChange={(e) => setKeyword(e.target.value)}
|
|
className="pl-9 h-9 dg-input"
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
className="dg-btn-secondary"
|
|
onClick={() => refetch()}
|
|
disabled={isFetching}
|
|
>
|
|
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
|
{t('common.refresh')}
|
|
</Button>
|
|
<Button
|
|
variant={autoRefresh ? 'default' : 'outline'}
|
|
className={
|
|
autoRefresh
|
|
? 'bg-emerald-600 hover:bg-emerald-700 text-white'
|
|
: 'dg-btn-secondary'
|
|
}
|
|
onClick={() => {
|
|
if (autoRefresh) onUserToggledOff();
|
|
setAutoRefresh(!autoRefresh);
|
|
}}
|
|
title={autoRefresh ? t('audit.terminal.autoRefreshHintOn', { sec: REFRESH_SEC }) : t('audit.terminal.autoRefreshHintOff')}
|
|
>
|
|
{autoRefresh ? (
|
|
<>
|
|
<Timer className="h-4 w-4 mr-1.5 animate-pulse" />
|
|
{REFRESH_SEC}s
|
|
</>
|
|
) : (
|
|
<>
|
|
<TimerOff className="h-4 w-4 mr-2" />
|
|
{t('audit.terminal.autoRefresh')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
<Card className="dg-card">
|
|
<CardContent className="p-0">
|
|
<div className="border-t border-[var(--dg-border)]">
|
|
{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 dg-text-muted">
|
|
<Activity className="h-8 w-8 mb-3 opacity-40" />
|
|
<p className="text-sm">{t('audit.terminal.noActive')}</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.port')}</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.status')}</TableHead>
|
|
<TableHead className="dg-text-secondary text-right">{t('audit.terminal.col.actions')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filtered.map((s) => (
|
|
<ActiveSessionRow key={s.id} session={s} onKill={handleKill} onMonitor={openMonitor} />
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</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-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" />
|
|
</>
|
|
);
|
|
}
|