feat(audit): F1+F2 告警静默规则标记完成 + 审计日志CSV导出实现
F1 告警静默规则 [done]: - 后端 alert_silence.go: CRUD + Toggle (onetime/daily/weekly 三模式) - 告警引擎 isSilenced 集成 (alert_engine.go) - 前端 SilenceRuleTab + SilenceDialog - 路由 /alerts/silences 已注册 F2 审计日志导出 [done]: - 重写 ExportAuditLogs: 真正的 CSV 导出 (encoding/csv + UTF-8 BOM) - 支持 module/action/username/startTime/endTime 筛选 - 导出限制 10000 条防溢出,自动记录审计日志 - JSON 格式向后兼容 (format=json) - 前端 exportLogs 传递查询参数 同时包含累积的前期工作: - 目录重构 internal/ -> controller/model/router (v0.55-v0.57) - 后端分层架构 dto/repository/service/utils - 审计模块全功能 (终端审计/操作审计/登录日志) - asciicast v2 录制格式 - SFTP 文件浏览器 - StatStrip 紧凑统计条 - 在线会话自动刷新 - LiveMonitor WebSocket 诊断修复 验证: go build ✅ | npm run build ✅ | Docker 部署 ✅ | 10 回归端点 ✅ | CSV 导出 ✅ | 静默 CRUD ✅
This commit is contained in:
1 parent
e57718998a
commit
77c234b060
314 files changed
+10050
-4065
No files matched your search
@@ -18,13 +18,22 @@ interface ReplayDialogProps {
|
||||
sessionId: number | null;
|
||||
}
|
||||
|
||||
interface AsciicastRow {
|
||||
time: number;
|
||||
eventType: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 终端录制回放 Dialog — 用 xterm.js 重放录制的终端输出 // Logan.Li
|
||||
* 终端录制回放 Dialog — asciicast v2 定时回放 (KoKo-compatible)
|
||||
*/
|
||||
export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !sessionId) return;
|
||||
@@ -34,7 +43,8 @@ export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProp
|
||||
fontSize: 13,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
disableStdin: true,
|
||||
allowProposedApi: true,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
theme: {
|
||||
background: '#0a0e14',
|
||||
foreground: '#d6d6d6',
|
||||
@@ -48,48 +58,108 @@ export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProp
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setProgress(0);
|
||||
|
||||
terminalSessionApi
|
||||
.getRecord(sessionId)
|
||||
.then(( data: any) => {
|
||||
.then((raw: any) => {
|
||||
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');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
fit.fit();
|
||||
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 {
|
||||
/* noop */
|
||||
// Skip lines that can't be parsed
|
||||
}
|
||||
const output = typeof data === 'string' ? data : '';
|
||||
term.write(output);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && containerRef.current) {
|
||||
term.open(containerRef.current);
|
||||
term.write('\x1b[31m无录制数据\x1b[0m\r\n');
|
||||
|
||||
if (rows.length === 0) {
|
||||
// Fallback: just write raw data (old format)
|
||||
term.write(data);
|
||||
setProgress(100);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Timed playback
|
||||
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();
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
.catch((err: any) => {
|
||||
if (!cancelled) {
|
||||
console.error('[Replay] Load failed:', err);
|
||||
setError('Failed to load recording data');
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
try {
|
||||
term.dispose();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
try { term.dispose(); } catch { /* noop */ }
|
||||
termRef.current = null;
|
||||
};
|
||||
}, [open, sessionId]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800 p-0 gap-0" style={{ maxWidth: '900px', width: '90vw' }}>
|
||||
<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">终端录制回放 #{sessionId ?? ''}</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 sr-only">重放该会话录制的终端输出</DialogDescription>
|
||||
<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>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 sr-only">Replay recorded terminal output</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="relative bg-[#0a0e14]" style={{ height: '460px' }}>
|
||||
{loading && (
|
||||
@@ -97,6 +167,19 @@ export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProp
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
|
||||
Reference in new issue
Block a user