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:
attacker committed 2026-06-25 20:16:30 +08:00
1 parent e57718998a
commit 77c234b060
314 files changed
+10050 -4065

No files matched your search

@@ -0,0 +1,220 @@
// 终端审计实时监控 Dialog — xterm.js 实时查看在线会话 // Logan.Li
import { useEffect, useRef, useState } from 'react';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Loader2, Monitor, Server, User as UserIcon, Clock } from 'lucide-react';
import { useUserStore } from '@/store';
interface SessionInfo {
session_id: number;
username: string;
server_name: string;
server_ip: string;
start_time: string;
status: string;
}
interface LiveMonitorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
sessionId: number | null;
sessionInfo?: SessionInfo | null;
}
export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo }: LiveMonitorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [connected, setConnected] = useState(false);
const [connectError, setConnectError] = useState('');
const [meta, setMeta] = useState<SessionInfo | null>(sessionInfo || null);
useEffect(() => {
if (!open || !sessionId) return;
const container = containerRef.current;
if (!container) return;
// Init xterm
const term = new XTerm({
cursorBlink: false,
fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
disableStdin: true,
theme: {
background: '#0a0e14',
foreground: '#d6d6d6',
cursor: '#d6d6d6',
selectionBackground: '#264f78',
black: '#1a1a2e',
red: '#ff6b6b',
green: '#69db7c',
yellow: '#ffd43b',
blue: '#4dabf7',
magenta: '#da77f2',
cyan: '#63e6be',
white: '#d6d6d6',
brightBlack: '#4a4a4a',
brightRed: '#ff8787',
brightGreen: '#8ce99a',
brightYellow: '#ffe066',
brightBlue: '#74c0fc',
brightMagenta: '#e599f7',
brightCyan: '#7ae5d5',
brightWhite: '#f8f9fa',
},
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(container);
fitAddon.fit();
termRef.current = term;
// Connect WebSocket — try zustand store first, fallback to localStorage
let token = useUserStore.getState().token;
if (!token) {
try {
const stored = JSON.parse(localStorage.getItem('user-storage') || '');
token = stored?.state?.token || '';
} catch { /* ignore */ }
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/audit/${sessionId}?token=${encodeURIComponent(token || '')}`;
console.log('[LiveMonitor] Connecting to:', wsUrl.replace(/token=[^&]+/, 'token=***'));
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
let didOpen = false;
ws.onopen = () => {
console.log('[LiveMonitor] WS connected for session:', sessionId);
didOpen = true;
setConnected(true);
setConnectError('');
term.writeln('\x1b[36m[审计监控] 已连接到会话...\x1b[0m');
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'session_info':
setMeta(msg.payload);
term.writeln(`\x1b[36m用户: ${msg.payload.username} 服务器: ${msg.payload.server_name}\x1b[0m`);
break;
case 'history':
if (msg.data) {
term.write(msg.data);
}
break;
case 'output':
if (msg.data) {
term.write(msg.data);
}
break;
}
} catch {
// ignore parse errors
}
};
ws.onerror = (e) => {
console.log('[LiveMonitor] WS error:', e);
setConnectError('WebSocket 连接失败');
};
ws.onclose = (e) => {
console.log('[LiveMonitor] WS closed code:', e.code, 'reason:', e.reason);
if (!didOpen) {
setConnectError(`连接失败 (code=${e.code}): ${e.reason || '认证失败或网络问题'}`);
}
setConnected(false);
term.writeln('\r\n\x1b[33m[审计监控] 连接已断开\x1b[0m');
};
// 5s 超时:如果还没连上,显示错误
const timeout = setTimeout(() => {
if (!didOpen) {
setConnectError('连接超时 — 请确认已登录且有活跃会话');
ws.close();
}
}, 5000);
return () => {
clearTimeout(timeout);
ws.close();
term.dispose();
termRef.current = null;
wsRef.current = null;
setConnected(false);
};
}, [open, sessionId]);
const formatTime = (t: string) => {
if (!t) return '--';
return new Date(t).toLocaleString();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-[95vw] max-h-[90vh] w-[1200px]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Monitor className="h-5 w-5 text-emerald-400" />
终端实时监控
{connected && (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10 ml-2 text-xs">
● 在线
</Badge>
)}
{!connected && !connectError && (
<Badge variant="outline" className="border-yellow-500/30 text-yellow-400 bg-yellow-500/10 ml-2 text-xs">
<Loader2 className="h-3 w-3 animate-spin mr-1" />
连接中
</Badge>
)}
{connectError && (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10 ml-2 text-xs">
连接失败
</Badge>
)}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
{meta && (
<div className="flex items-center gap-4 text-xs mt-1 flex-wrap">
<span className="flex items-center gap-1">
<UserIcon className="h-3 w-3" />
{meta.username}
</span>
<span className="flex items-center gap-1">
<Server className="h-3 w-3" />
{meta.server_name} ({meta.server_ip})
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(meta.start_time)}
</span>
</div>
)}
</DialogDescription>
</DialogHeader>
<div
ref={containerRef}
className="w-full rounded-md overflow-hidden border border-[var(--dg-border)]"
style={{ height: '55vh' }}
/>
</DialogContent>
</Dialog>
);
}
+105 -22
View File
@@ -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>
@@ -3,14 +3,12 @@ import { useLocation } from 'react-router-dom';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { CheckCircle, XCircle, Users, Shield, FileCode, Settings, TerminalSquare } from 'lucide-react';
import { CheckCircle, XCircle, Users, Shield, Settings } from 'lucide-react';
import { userApi } from '@/services/api';
import type { Role } from '@/types';
import { SystemUserTab } from './SystemUserTab';
import { SystemRolesTab } from './SystemRolesTab';
import { SystemLogsTab } from './SystemLogsTab';
import { SystemSettingsTab } from './SystemSettingsTab';
import { TerminalSessionsTab } from './TerminalSessionsTab';
import { useLdapManagement } from './useLdapManagement';
import type { UserFormData } from './UserFormDialog';
@@ -43,8 +41,6 @@ export function SystemManager() {
const pathToTab: Record<string, string> = {
'/users': 'users',
'/roles': 'roles',
'/logs-audit': 'logs',
'/terminal-audit': 'terminal',
'/settings': 'settings',
};
const [activeTab, setActiveTab] = useState(pathToTab[location.pathname] || 'users');
@@ -253,14 +249,6 @@ export function SystemManager() {
<Shield className="h-4 w-4 mr-2" />
角色权限
</TabsTrigger>
<TabsTrigger value="logs" className="data-[state=active]:bg-slate-700">
<FileCode className="h-4 w-4 mr-2" />
操作审计
</TabsTrigger>
<TabsTrigger value="terminal" className="data-[state=active]:bg-slate-700">
<TerminalSquare className="h-4 w-4 mr-2" />
终端审计
</TabsTrigger>
<TabsTrigger value="settings" className="data-[state=active]:bg-slate-700">
<Settings className="h-4 w-4 mr-2" />
系统设置
@@ -315,17 +303,6 @@ export function SystemManager() {
/>
</TabsContent>
<TabsContent value="logs" className="mt-4 space-y-4">
<SystemLogsTab
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
</TabsContent>
<TabsContent value="terminal" className="mt-4 space-y-4">
<TerminalSessionsTab />
</TabsContent>
<TabsContent value="settings" className="mt-4">
<SystemSettingsTab {...ldap.settingsTabProps} />
</TabsContent>