Files
aiops/frontend/src/pages/audit/terminal-audit/ActiveSessionsPanel.tsx
T
attacker da9fd1e987 feat(quality): U12 工程质量回归修复 — ESLint errors(8→0) + React Hooks 关键规则(82→0)
远程 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 测试通过
2026-07-06 00:55:01 +08:00

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" />
</>
);
}