feat: Agent CLI 与终端审计回放全面优化
- Agent CLI: 多轮对话、SSH 长连接、流式推理、Evolver 按用户隔离、历史侧栏 - 终端审计: 回放播放器、命令书签、筛选分页导出、命令输出统计、录制落盘修复 - 修复 session_commands 表迁移、Kill 竞态导致录制丢失、无录制会话回放提示 - 审计历史页只读(移除删除),移除回放弹框录制搜索
This commit is contained in:
1 parent
9a3dd304f7
commit
1acb0c49af
81 files changed
+3916
-1655
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user