feat: Agent CLI 与终端审计回放全面优化

- Agent CLI: 多轮对话、SSH 长连接、流式推理、Evolver 按用户隔离、历史侧栏
- 终端审计: 回放播放器、命令书签、筛选分页导出、命令输出统计、录制落盘修复
- 修复 session_commands 表迁移、Kill 竞态导致录制丢失、无录制会话回放提示
- 审计历史页只读(移除删除),移除回放弹框录制搜索
This commit is contained in:
Logan committed 2026-07-01 12:13:53 +08:00
1 parent 9a3dd304f7
commit 1acb0c49af
81 files changed
+3916 -1655

No files matched your search

+282 -106
View File
@@ -1,40 +1,82 @@
import { useState, useEffect, useRef } from 'react';
// ReplayDialog.tsx - 终端录制回放(暂停/时间轴/倍速/命令书签) // Logan.Li
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Loader2 } from 'lucide-react';
import { terminalSessionApi } from '@/api';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import {
Loader2, Play, Pause, RotateCcw, SkipBack, SkipForward,
ChevronsLeft, ChevronsRight, Download, FileText,
} from 'lucide-react';
import { exportAuditReportCsv, exportAuditReportJson } from './replay/export-audit';
import { terminalSessionApi, sessionCommandApi } from '@/api';
import { unwrapList } from '@/hooks/api/unwrap';
import { formatSize } from '@/pages/audit/terminal-audit/helpers';
import { ReplayEngine, type SpeedOption } from './replay/replay-engine';
import {
parseAsciicast, formatReplayTime, buildCommandBookmarks,
type CommandBookmark, type ReplaySessionMeta, type AsciicastRow,
} from './replay/replay-utils';
import { ReplayCommandSidebar } from './replay/ReplayCommandSidebar';
interface ReplayDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
sessionId: number | null;
/** 打开后自动跳转的时间点(秒),用于命令 Tab 联动 */ // Logan.Li
initialSeekSec?: number;
}
interface AsciicastRow {
time: number;
eventType: string;
data: string;
}
const SPEEDS: SpeedOption[] = [0.5, 1, 2, 4, 8];
const SKIP_SEC = 10;
/**
* 终端录制回放 Dialog — asciicast v2 定时回放 (KoKo-compatible)
*/
export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProps) {
export function ReplayDialog({ open, onOpenChange, sessionId, initialSeekSec }: ReplayDialogProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const engineRef = useRef<ReplayEngine | null>(null);
const rowsRef = useRef<AsciicastRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [progress, setProgress] = useState(0);
const [meta, setMeta] = useState<ReplaySessionMeta | null>(null);
const [commands, setCommands] = useState<CommandBookmark[]>([]);
const [currentTime, setCurrentTime] = useState(0);
const [totalTime, setTotalTime] = useState(0);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState<SpeedOption>(1);
const [scrubTime, setScrubTime] = useState<number | null>(null);
const [legacyMode, setLegacyMode] = useState(false);
const displayTime = scrubTime ?? currentTime;
const togglePlay = useCallback(() => {
const eng = engineRef.current;
if (!eng || legacyMode) return;
if (eng.isPlaying()) {
eng.pause();
setPlaying(false);
} else {
eng.play();
setPlaying(true);
}
}, [legacyMode]);
const seekTo = useCallback((time: number) => {
const eng = engineRef.current;
if (!eng || legacyMode) return;
eng.seekTo(time);
setCurrentTime(eng.getCurrentTime());
setScrubTime(null);
setPlaying(false);
}, [legacyMode]);
// 加载录制 + 会话元信息 + 命令书签 // Logan.Li
useEffect(() => {
if (!open || !sessionId) return;
@@ -56,133 +98,267 @@ export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProp
term.loadAddon(fit);
termRef.current = term;
const engine = new ReplayEngine({
onTimeUpdate: (time) => {
setCurrentTime(time);
setScrubTime(null);
},
onEnded: () => setPlaying(false),
});
engineRef.current = engine;
let cancelled = false;
setLoading(true);
setError('');
setProgress(0);
setMeta(null);
setCommands([]);
setCurrentTime(0);
setTotalTime(0);
setPlaying(false);
setSpeed(1);
setLegacyMode(false);
setScrubTime(null);
terminalSessionApi
.getRecord(sessionId)
.then((raw: any) => {
// 录制与元信息必须成功;命令书签失败不阻断回放 // Logan.Li
Promise.all([
terminalSessionApi.getRecord(sessionId),
terminalSessionApi.get(sessionId),
])
.then(async ([raw, sessRaw]) => {
if (cancelled) return;
const data = typeof raw === 'string' ? raw : (raw?.data || raw?.record_data || '');
if (!data || data.trim() === '') {
setError('No recording data available for this session');
const data = typeof raw === 'string' ? raw : String((raw as { data?: string })?.data || '');
if (!data.trim()) {
setError(t('audit.terminal.replayPlayer.noRecord'));
setLoading(false);
return;
}
const sess = (sessRaw || {}) as ReplaySessionMeta;
setMeta({ ...sess, id: sessionId });
let cmdList: { id: number; command: string; timestamp?: string; created_at?: string }[] = [];
try {
const cmdRaw = await sessionCommandApi.list({ session_id: sessionId, page: 1, page_size: 500 });
cmdList = unwrapList(cmdRaw);
} catch {
/* 命令表未就绪时仍可回放录制 */
}
const rows = parseAsciicast(data);
rowsRef.current = rows;
if (containerRef.current) {
term.open(containerRef.current);
try { fit.fit(); } catch { /* noop */ }
}
// Parse asciicast v2 format
const lines = data.split('\n').filter((l: string) => l.trim());
const rows: AsciicastRow[] = [];
for (const line of lines) {
try {
const parsed = JSON.parse(line);
if (Array.isArray(parsed) && parsed.length >= 3) {
// asciicast v2 row: [time, "o", "data"]
rows.push({ time: parsed[0], eventType: parsed[1], data: parsed[2] });
}
// Header is skipped (JSON object, not array)
} catch {
// Skip lines that can't be parsed
}
}
if (rows.length === 0) {
// Fallback: just write raw data (old format)
term.write(data);
setProgress(100);
setLegacyMode(true);
setTotalTime(0);
setLoading(false);
return;
}
// Timed playback
const total = rows[rows.length - 1].time;
setTotalTime(total);
setCommands(buildCommandBookmarks(cmdList, sess.start_time, total));
engine.load(term, rows);
const seek = initialSeekSec != null && initialSeekSec > 0 ? initialSeekSec : 0;
engine.seekTo(seek, false);
setCurrentTime(seek);
// 二次审计:记录回放查看 // Logan.Li
terminalSessionApi.logReplayView(sessionId).catch(() => {});
setLoading(false);
const totalTime = rows[rows.length - 1].time;
let rowIndex = 0;
let lastTime = 0;
const playNext = () => {
if (cancelled || rowIndex >= rows.length) {
setProgress(100);
return;
}
const row = rows[rowIndex];
const delay = (row.time - lastTime) * 1000; // convert to ms
lastTime = row.time;
rowIndex++;
// Clamp delay to avoid extremely long waits (max 2s between chunks)
const clampedDelay = Math.min(delay, 2000);
timerRef.current = setTimeout(() => {
if (cancelled) return;
term.write(row.data);
setProgress(Math.round((row.time / totalTime) * 100));
playNext();
}, clampedDelay);
};
playNext();
})
.catch((err: any) => {
.catch(() => {
if (!cancelled) {
console.error('[Replay] Load failed:', err);
setError('Failed to load recording data');
setError(t('audit.terminal.replayPlayer.loadFailed'));
setLoading(false);
}
});
return () => {
cancelled = true;
if (timerRef.current) clearTimeout(timerRef.current);
engine.dispose();
engineRef.current = null;
try { term.dispose(); } catch { /* noop */ }
termRef.current = null;
};
}, [open, sessionId]);
}, [open, sessionId, initialSeekSec, t]);
// 倍速变更 // Logan.Li
useEffect(() => {
engineRef.current?.setSpeed(speed);
}, [speed]);
// 空格键播放/暂停 // Logan.Li
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.code === 'Space' && (e.target as HTMLElement)?.tagName !== 'INPUT') {
e.preventDefault();
togglePlay();
}
if (e.code === 'ArrowLeft') seekTo(Math.max(0, (engineRef.current?.getCurrentTime() || 0) - 5));
if (e.code === 'ArrowRight') seekTo(Math.min(totalTime, (engineRef.current?.getCurrentTime() || 0) + 5));
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, togglePlay, seekTo, totalTime]);
const handleDownload = () => {
if (sessionId) terminalSessionApi.download(sessionId).catch(() => {});
};
const handleExportCsv = () => {
if (meta) exportAuditReportCsv(meta, commands);
};
const handleExportJson = () => {
if (meta) exportAuditReportJson(meta, commands);
};
const sliderMax = Math.max(totalTime, 0.01);
const markerPercents = commands.map((c) => ({
id: c.id,
left: (c.offsetSec / sliderMax) * 100,
highRisk: c.highRisk,
command: c.command,
}));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showOverlay={false} className="bg-slate-900 border-slate-800 p-0 gap-0" style={{ maxWidth: '900px', width: '90vw' }}>
<DialogHeader className="px-4 py-3 border-b border-slate-800">
<DialogTitle className="text-slate-100 text-base flex items-center gap-2">
Terminal Replay #{sessionId ?? ''}
{progress > 0 && progress < 100 && (
<span className="text-xs text-slate-500 font-normal ml-2">{progress}%</span>
)}
<DialogContent
showOverlay={false}
className="bg-slate-900 border-slate-800 p-0 gap-0 flex flex-col"
style={{ maxWidth: '1100px', width: '92vw' }}
>
<DialogHeader className="px-4 py-3 border-b border-slate-800 shrink-0">
<DialogTitle className="text-slate-100 text-base">
{t('audit.terminal.replayPlayer.title', { id: sessionId ?? '' })}
</DialogTitle>
<DialogDescription className="text-slate-500 sr-only">Replay recorded terminal output</DialogDescription>
{meta && (
<div className="text-xs text-slate-500 mt-1 flex flex-wrap gap-x-3 gap-y-0.5">
<span>{meta.username} → {meta.server_name} ({meta.server_ip}:{meta.server_port})</span>
{meta.client_ip && <span>{t('audit.terminal.replayPlayer.clientIp')}: {meta.client_ip}</span>}
{meta.start_time && <span>{new Date(meta.start_time).toLocaleString()}</span>}
{meta.record_size > 0 && <span>{formatSize(meta.record_size)}</span>}
</div>
)}
<DialogDescription className="sr-only">{t('audit.terminal.replayPlayer.desc')}</DialogDescription>
</DialogHeader>
<div className="relative bg-[#0a0e14]" style={{ height: '460px' }}>
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
<Loader2 className="h-6 w-6 animate-spin text-blue-400" />
</div>
<div className="flex min-h-0 flex-1" style={{ height: '480px' }}>
<div className="flex-1 min-w-0 flex flex-col relative bg-[#0a0e14]">
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
<Loader2 className="h-6 w-6 animate-spin text-blue-400" />
</div>
)}
{error && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
<div ref={containerRef} className="flex-1 min-h-0 p-1" />
{!legacyMode && totalTime > 0 && (
<div className="shrink-0 border-t border-slate-800 bg-slate-900/95 px-3 py-2.5 space-y-2">
{/* 控制按钮 */}
<div className="flex items-center gap-1 flex-wrap">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => seekTo(0)} title={t('audit.terminal.replayPlayer.toStart')}>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => engineRef.current?.skip(-SKIP_SEC)}>
<SkipBack className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-blue-400" onClick={togglePlay}>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => engineRef.current?.skip(SKIP_SEC)}>
<SkipForward className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => seekTo(totalTime)} title={t('audit.terminal.replayPlayer.toEnd')}>
<ChevronsRight className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => { seekTo(0); engineRef.current?.play(); setPlaying(true); }} title={t('audit.terminal.replayPlayer.restart')}>
<RotateCcw className="h-4 w-4" />
</Button>
<div className="w-px h-5 bg-slate-700 mx-1" />
{SPEEDS.map((s) => (
<Button
key={s}
variant="ghost"
size="sm"
className={`h-7 px-2 text-xs ${speed === s ? 'text-blue-400 bg-blue-500/10' : 'text-slate-500'}`}
onClick={() => setSpeed(s)}
>
{s}x
</Button>
))}
<div className="flex-1" />
<span className="text-xs text-slate-400 font-mono tabular-nums">
{formatReplayTime(displayTime)} / {formatReplayTime(totalTime)}
</span>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={handleExportCsv} disabled={!meta} title={t('audit.terminal.exportReportCsv')}>
<FileText className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-slate-500" onClick={handleExportJson} disabled={!meta}>JSON</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={handleDownload} title={t('audit.terminal.downloadRecord')}>
<Download className="h-4 w-4" />
</Button>
</div>
{/* 时间轴 + 命令书签标记 */}
<div className="relative px-1 pt-2">
{markerPercents.map((m) => (
<button
key={m.id}
type="button"
className={`absolute w-1 h-3 rounded-full -top-0.5 z-10 hover:scale-125 transition-transform ${m.highRisk ? 'bg-red-400' : 'bg-amber-400/80'}`}
style={{ left: `calc(${m.left}% - 2px)` }}
title={m.command}
onClick={() => seekTo(commands.find((c) => c.id === m.id)?.offsetSec || 0)}
/>
))}
<Slider
value={[displayTime]}
min={0}
max={sliderMax}
step={0.1}
onValueChange={(v) => setScrubTime(v[0])}
onValueCommit={(v) => seekTo(v[0])}
className="[&_[data-slot=slider-track]]:bg-slate-700 [&_[data-slot=slider-range]]:bg-blue-500"
/>
</div>
</div>
)}
{legacyMode && (
<div className="shrink-0 border-t border-slate-800 px-3 py-2 text-xs text-slate-500">
{t('audit.terminal.replayPlayer.legacyFormat')}
</div>
)}
</div>
{!legacyMode && commands.length > 0 && (
<ReplayCommandSidebar
commands={commands}
activeOffset={currentTime}
onSeek={seekTo}
/>
)}
{error && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{progress > 0 && progress < 100 && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-slate-800 z-10">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
)}
<div ref={containerRef} className="absolute inset-0 p-1" />
</div>
</DialogContent>
</Dialog>
);
}
}
@@ -110,12 +110,12 @@ export function SystemManager() {
const getUserStatusBadge = (status: 'active' | 'inactive') => {
return status === 'active' ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<Badge variant="outline" className="border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10">
<CheckCircle className="h-3 w-3 mr-1" />
正常
</Badge>
) : (
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
<Badge variant="outline" className="border-[var(--dg-text-muted)]/30 text-[var(--dg-text-muted)] bg-[var(--dg-text-muted)]/10">
<XCircle className="h-3 w-3 mr-1" />
禁用
</Badge>
@@ -236,23 +236,23 @@ export function SystemManager() {
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">系统管理</h1>
<p className="text-sm text-slate-500 mt-1">用户、权限与系统配置</p>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">系统管理</h1>
<p className="text-sm text-[var(--dg-text-muted)] mt-1">用户、权限与系统配置</p>
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="users" className="data-[state=active]:bg-slate-700">
<TabsList className="bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
<TabsTrigger value="users" className="data-[state=active]:bg-[var(--dg-bg-hover)]">
<Users className="h-4 w-4 mr-2" />
用户管理
</TabsTrigger>
<TabsTrigger value="roles" className="data-[state=active]:bg-slate-700">
<TabsTrigger value="roles" className="data-[state=active]:bg-[var(--dg-bg-hover)]">
<Shield className="h-4 w-4 mr-2" />
角色权限
</TabsTrigger>
<TabsTrigger value="settings" className="data-[state=active]:bg-slate-700">
<TabsTrigger value="settings" className="data-[state=active]:bg-[var(--dg-bg-hover)]">
<Settings className="h-4 w-4 mr-2" />
系统设置
</TabsTrigger>
@@ -118,29 +118,29 @@ export function SystemUserTab({
{/* Filters */}
<div className="flex items-center justify-between">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
<Input
placeholder="搜索用户..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
className="pl-9 h-9 bg-[var(--dg-bg-tertiary)]/50 border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="flex items-center gap-2">
{hasPermission('system:user') && (
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('csv')}>
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]" onClick={() => setImportDialogOpen('csv')}>
<Upload className="h-4 w-4 mr-2" />
批量导入
</Button>
)}
{hasPermission('system:user') && (
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('ldap')}>
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]" onClick={() => setImportDialogOpen('ldap')}>
<Network className="h-4 w-4 mr-2" />
LDAP 导入
</Button>
)}
{hasPermission('system:user') && (
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
<Button className="bg-[var(--dg-blue)] hover:opacity-90 text-white" onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
新建用户
</Button>
@@ -149,58 +149,58 @@ export function SystemUserTab({
</div>
{/* Users Table */}
<Card className="bg-slate-900 border-slate-800">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<div className="border-t border-[var(--dg-border)]">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<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 className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-[var(--dg-text-secondary)]">用户信息</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">联系方式</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">角色</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">来源</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">最后登录</TableHead>
<TableHead className="text-[var(--dg-text-secondary)] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow className="border-slate-800">
<TableRow className="border-[var(--dg-border)]">
<TableCell colSpan={7} className="text-center py-12">
<div className="flex items-center justify-center">
<div className="h-6 w-6 border-2 border-slate-700 border-t-blue-500 rounded-full animate-spin" />
<div className="h-6 w-6 border-2 border-[var(--dg-border)] border-t-[var(--dg-blue)] rounded-full animate-spin" />
</div>
</TableCell>
</TableRow>
) : filteredUsers.length === 0 ? (
<TableRow className="border-slate-800">
<TableCell colSpan={7} className="text-center py-12 text-sm text-slate-500">
<TableRow className="border-[var(--dg-border)]">
<TableCell colSpan={7} className="text-center py-12 text-sm text-[var(--dg-text-muted)]">
暂无用户数据
</TableCell>
</TableRow>
) : (
filteredUsers.map((user) => (
<TableRow key={user.id} className="border-slate-800 hover:bg-slate-800/50">
<TableRow key={user.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]/50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium">
<div className="w-8 h-8 rounded-full bg-[var(--dg-blue)] flex items-center justify-center text-white text-sm font-medium">
{(user.nickname || user.username || '?').charAt(0)}
</div>
<div>
<p className="text-sm font-medium text-slate-200">{user.nickname || user.username}</p>
<p className="text-xs text-slate-500">@{user.username}</p>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">{user.nickname || user.username}</p>
<p className="text-xs text-[var(--dg-text-muted)]">@{user.username}</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-1 text-sm text-slate-400">
<div className="flex items-center gap-1 text-sm text-[var(--dg-text-secondary)]">
<Mail className="h-3.5 w-3.5" />
{user.email || '--'}
</div>
{user.phone && (
<div className="flex items-center gap-1 text-sm text-slate-400">
<div className="flex items-center gap-1 text-sm text-[var(--dg-text-secondary)]">
<Phone className="h-3.5 w-3.5" />
{user.phone}
</div>
@@ -210,7 +210,7 @@ export function SystemUserTab({
<TableCell>
<div className="flex flex-wrap gap-1">
{(user.roles || []).map((role: string) => (
<Badge key={role} variant="outline" className="border-slate-700 text-slate-400">
<Badge key={role} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
{role}
</Badge>
))}
@@ -218,18 +218,18 @@ export function SystemUserTab({
</TableCell>
<TableCell>
{user.authSource === 'ldap' ? (
<Badge variant="outline" className="border-cyan-500/30 text-cyan-400 bg-cyan-500/10">
<Badge variant="outline" className="border-[var(--dg-cyan)]/30 text-[var(--dg-cyan)] bg-[var(--dg-cyan)]/10">
LDAP
</Badge>
) : (
<Badge variant="outline" className="border-slate-600/30 text-slate-400 bg-slate-600/10">
<Badge variant="outline" className="border-[var(--dg-text-muted)]/30 text-[var(--dg-text-muted)] bg-[var(--dg-text-muted)]/10">
本地
</Badge>
)}
</TableCell>
<TableCell>{getUserStatusBadge(user.status)}</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
<span className="text-sm text-[var(--dg-text-secondary)]">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : '--'}
</span>
</TableCell>
@@ -238,7 +238,7 @@ export function SystemUserTab({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
onClick={() => setSelectedUser(user)}
>
<Eye className="h-4 w-4" />
@@ -247,7 +247,7 @@ export function SystemUserTab({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
onClick={() => openEdit(user)}
>
<Edit className="h-4 w-4" />
@@ -257,7 +257,7 @@ export function SystemUserTab({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400"
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-red)]"
onClick={() => handleDelete(user)}
>
<Trash2 className="h-4 w-4" />
@@ -39,7 +39,8 @@ function formatSize(bytes: number): string {
*/
export function TerminalSessionsTab() {
const confirm = useConfirm();
const { data: sessions = [], isLoading: loading, refetch } = useTerminalSessions({ page: 1, page_size: 100 });
const { data, isLoading: loading, refetch } = useTerminalSessions({ page: 1, page_size: 100 });
const sessions = data?.list ?? [];
const { deleteMutation, batchDeleteMutation } = useTerminalSessionMutations();
const [keyword, setKeyword] = useState('');
const [statusFilter, setStatusFilter] = useState('');
@@ -0,0 +1,72 @@
// ReplayCommandSidebar.tsx - 回放命令书签侧栏 // Logan.Li
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Input } from '@/components/ui/input';
import { Search, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CommandBookmark } from './replay-utils';
interface Props {
commands: CommandBookmark[];
activeOffset?: number;
onSeek: (offsetSec: number) => void;
}
export function ReplayCommandSidebar({ commands, activeOffset, onSeek }: Props) {
const { t } = useTranslation();
const [kw, setKw] = useState('');
const filtered = commands.filter((c) => {
const q = kw.toLowerCase();
return !q || c.command.toLowerCase().includes(q);
});
return (
<div className="w-56 shrink-0 border-l border-slate-800 flex flex-col bg-slate-900/80">
<div className="px-2.5 py-2 border-b border-slate-800">
<div className="text-xs font-medium text-slate-300 mb-1.5">
{t('audit.terminal.replayPlayer.commands')} ({commands.length})
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-slate-500" />
<Input
value={kw}
onChange={(e) => setKw(e.target.value)}
placeholder={t('audit.terminal.replayPlayer.searchCommand')}
className="h-7 pl-7 text-xs bg-slate-800 border-slate-700"
/>
</div>
</div>
<ScrollArea className="flex-1 min-h-0">
{filtered.length === 0 ? (
<div className="p-3 text-xs text-slate-500 text-center">{t('audit.terminal.replayPlayer.noCommands')}</div>
) : (
<div className="p-1 space-y-0.5">
{filtered.map((c) => {
const active = activeOffset != null && Math.abs(activeOffset - c.offsetSec) < 2;
return (
<button
key={c.id}
type="button"
onClick={() => onSeek(c.offsetSec)}
className={cn(
'w-full text-left rounded px-2 py-1.5 transition-colors',
active ? 'bg-blue-500/15 border border-blue-500/30' : 'hover:bg-slate-800/60 border border-transparent',
)}
>
<div className="flex items-center gap-1">
<span className="text-[10px] text-slate-500 font-mono shrink-0">{c.label}</span>
{c.highRisk && <AlertTriangle className="w-3 h-3 text-red-400 shrink-0" />}
</div>
<code className="text-[11px] text-slate-300 block truncate mt-0.5">{c.command}</code>
</button>
);
})}
</div>
)}
</ScrollArea>
</div>
);
}
@@ -0,0 +1,88 @@
// export-audit.ts - 终端会话审计报告导出(CSV / JSON) // Logan.Li
import type { ReplaySessionMeta, CommandBookmark } from './replay-utils';
import { isHighRiskCommand } from './replay-utils';
// downloadBlob 触发浏览器下载 // Logan.Li
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// escapeCsv CSV 字段转义 // Logan.Li
function escapeCsv(s: string): string {
if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
// exportAuditReportCsv 导出会话审计报告 CSV // Logan.Li
export function exportAuditReportCsv(meta: ReplaySessionMeta, commands: CommandBookmark[]) {
const lines: string[] = [
'# 终端会话审计报告',
`会话ID,${meta.id}`,
`用户,${escapeCsv(meta.username || '')}`,
`目标主机,${escapeCsv(meta.server_name || '')}`,
`IP,${meta.server_ip}:${meta.server_port}`,
`客户端IP,${meta.client_ip || ''}`,
`开始时间,${meta.start_time || ''}`,
`时长(秒),${meta.duration || 0}`,
`录制大小,${meta.record_size || 0}`,
'',
'时间偏移,命令,高危',
];
for (const c of commands) {
lines.push(`${c.label},${escapeCsv(c.command)},${c.highRisk ? '是' : '否'}`);
}
const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
downloadBlob(blob, `terminal-audit-${meta.id}.csv`);
}
/** CommandRow 命令列表导出行 // Logan.Li */
export interface CommandExportRow {
id?: number;
session_id: number;
username?: string;
command?: string;
output_size?: number;
created_at?: string;
timestamp?: string;
}
// exportCommandsListCsv 导出命令列表 CSV // Logan.Li
export function exportCommandsListCsv(commands: CommandExportRow[]) {
const lines: string[] = ['时间,用户,命令,输出大小,会话ID,高危'];
for (const c of commands) {
const ts = c.timestamp || c.created_at || '';
const highRisk = isHighRiskCommand(c.command || '');
lines.push([
ts,
escapeCsv(c.username || ''),
escapeCsv(c.command || ''),
String(c.output_size ?? 0),
String(c.session_id),
highRisk ? '是' : '否',
].join(','));
}
const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
downloadBlob(blob, `terminal-commands-${new Date().toISOString().slice(0, 10)}.csv`);
}
// exportAuditReportJson 导出会话审计报告 JSON // Logan.Li
export function exportAuditReportJson(meta: ReplaySessionMeta, commands: CommandBookmark[]) {
const payload = {
exported_at: new Date().toISOString(),
session: meta,
commands: commands.map((c) => ({
offset: c.label,
offset_sec: c.offsetSec,
command: c.command,
high_risk: c.highRisk,
})),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
downloadBlob(blob, `terminal-audit-${meta.id}.json`);
}
@@ -0,0 +1,128 @@
// replay-engine.ts - 终端回放引擎(暂停/seek/倍速) // Logan.Li
import type { Terminal } from 'xterm';
import { findRowIndexAt, type AsciicastRow } from './replay-utils';
export type SpeedOption = 0.5 | 1 | 2 | 4 | 8;
export interface ReplayEngineCallbacks {
onTimeUpdate: (time: number) => void;
onEnded: () => void;
}
// ReplayEngine 控制 asciicast 定时回放 // Logan.Li
export class ReplayEngine {
private rows: AsciicastRow[] = [];
private totalTime = 0;
private currentTime = 0;
private rowIndex = 0;
private playing = false;
private speed: SpeedOption = 1;
private timer: ReturnType<typeof setTimeout> | null = null;
private term: Terminal | null = null;
private cancelled = false;
private callbacks: ReplayEngineCallbacks;
constructor(callbacks: ReplayEngineCallbacks) {
this.callbacks = callbacks;
}
// load 加载事件流并绑定终端 // Logan.Li
load(term: Terminal, rows: AsciicastRow[]) {
this.stop();
this.term = term;
this.rows = rows;
this.totalTime = rows.length > 0 ? rows[rows.length - 1].time : 0;
this.seekTo(0, false);
}
getTotalTime() { return this.totalTime; }
getCurrentTime() { return this.currentTime; }
isPlaying() { return this.playing; }
getSpeed() { return this.speed; }
// play 开始或继续播放 // Logan.Li
play() {
if (!this.term || this.rows.length === 0) return;
if (this.rowIndex >= this.rows.length) {
this.seekTo(0, false);
}
this.playing = true;
this.scheduleNext();
}
// pause 暂停播放 // Logan.Li
pause() {
this.playing = false;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
// stop 停止并清理定时器 // Logan.Li
stop() {
this.cancelled = true;
this.pause();
this.cancelled = false;
}
// dispose 释放引擎 // Logan.Li
dispose() {
this.stop();
this.term = null;
this.rows = [];
}
// setSpeed 设置倍速(播放中立即生效) // Logan.Li
setSpeed(speed: SpeedOption) {
this.speed = speed;
if (this.playing) {
if (this.timer) clearTimeout(this.timer);
this.scheduleNext();
}
}
// seekTo 跳转到指定时间点并重绘终端画面 // Logan.Li
seekTo(time: number, autoPause = true) {
if (!this.term) return;
if (autoPause) this.pause();
const clamped = Math.max(0, Math.min(time, this.totalTime));
this.currentTime = clamped;
this.rowIndex = findRowIndexAt(this.rows, clamped);
this.term.reset();
for (let i = 0; i < this.rowIndex; i++) {
this.term.write(this.rows[i].data);
}
this.callbacks.onTimeUpdate(clamped);
}
// skip 相对跳转(秒) // Logan.Li
skip(deltaSec: number) {
this.seekTo(this.currentTime + deltaSec);
}
private scheduleNext() {
if (this.cancelled || !this.playing || !this.term) return;
if (this.rowIndex >= this.rows.length) {
this.playing = false;
this.callbacks.onEnded();
return;
}
const row = this.rows[this.rowIndex];
const delayMs = Math.max(0, (row.time - this.currentTime) * 1000 / this.speed);
const clamped = Math.min(delayMs, 8000 / this.speed);
this.timer = setTimeout(() => {
if (this.cancelled || !this.playing || !this.term) return;
this.term.write(row.data);
this.currentTime = row.time;
this.rowIndex++;
this.callbacks.onTimeUpdate(this.currentTime);
this.scheduleNext();
}, clamped);
}
}
@@ -0,0 +1,116 @@
// replay-utils.ts - 终端录制回放工具(asciicast v2 解析 / 时间格式化 / 命令书签) // Logan.Li
export interface AsciicastRow {
time: number;
eventType: string;
data: string;
}
export interface CommandBookmark {
id: number;
command: string;
offsetSec: number;
highRisk: boolean;
label: string;
}
export interface ReplaySessionMeta {
id: number;
username: string;
server_name: string;
server_ip: string;
server_port: number;
client_ip: string;
start_time: string;
duration: number;
record_size: number;
}
const HIGH_RISK_PATTERNS = [
/\brm\s+-rf\b/i,
/\bmkfs\b/i,
/\bdd\s+if=/i,
/\breboot\b/i,
/\bshutdown\b/i,
/\bpoweroff\b/i,
/\buserdel\b/i,
/\biptables\s+-F\b/i,
/\bsystemctl\s+(stop|restart|disable)\b/i,
/\bkill\s+-9\b/i,
];
// parseAsciicast 解析 asciicast v2 录制数据 // Logan.Li
export function parseAsciicast(raw: string): AsciicastRow[] {
const lines = raw.split('\n').filter((l) => l.trim());
const rows: AsciicastRow[] = [];
for (const line of lines) {
try {
const parsed = JSON.parse(line) as unknown;
if (Array.isArray(parsed) && parsed.length >= 3) {
rows.push({
time: Number(parsed[0]) || 0,
eventType: String(parsed[1]),
data: String(parsed[2]),
});
}
} catch {
// 跳过无法解析的行
}
}
return rows;
}
// formatReplayTime 格式化回放时间 mm:ss 或 hh:mm:ss // Logan.Li
export function formatReplayTime(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return '00:00';
const total = Math.floor(sec);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (n: number) => String(n).padStart(2, '0');
if (h > 0) return `${pad(h)}:${pad(m)}:${pad(s)}`;
return `${pad(m)}:${pad(s)}`;
}
// isHighRiskCommand 高危命令检测(审计高亮) // Logan.Li
export function isHighRiskCommand(command: string): boolean {
const c = command.trim();
if (!c) return false;
return HIGH_RISK_PATTERNS.some((re) => re.test(c));
}
// buildCommandBookmarks 将会话命令转为可跳转书签 // Logan.Li
export function buildCommandBookmarks(
commands: { id: number; command: string; timestamp?: string; created_at?: string }[],
sessionStart: string,
totalTime: number,
): CommandBookmark[] {
if (!sessionStart || totalTime <= 0) return [];
const startMs = new Date(sessionStart).getTime();
if (!Number.isFinite(startMs)) return [];
const bookmarks: CommandBookmark[] = [];
for (const c of commands) {
const ts = c.timestamp || c.created_at;
if (!ts || !c.command) continue;
const offsetSec = (new Date(ts).getTime() - startMs) / 1000;
if (offsetSec < 0 || offsetSec > totalTime + 1) continue;
bookmarks.push({
id: c.id,
command: c.command,
offsetSec,
highRisk: isHighRiskCommand(c.command),
label: formatReplayTime(offsetSec),
});
}
return bookmarks.sort((a, b) => a.offsetSec - b.offsetSec);
}
// findRowIndexAt 查找时间点对应的下一事件索引 // Logan.Li
export function findRowIndexAt(rows: AsciicastRow[], time: number): number {
let idx = 0;
for (; idx < rows.length; idx++) {
if (rows[idx].time > time) break;
}
return idx;
}