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,338 @@
// 服务器文件浏览器 — SFTP 浏览/上传/下载(参考 deployment FileBrowserDialog 设计)
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Folder,
FolderOpen,
File,
Upload,
Download,
Trash2,
ArrowLeft,
Home,
RefreshCw,
Loader2,
FolderPlus,
ChevronRight,
X,
Maximize2,
Minimize2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { serverApi } from '@/services/api';
interface FileItem {
name: string;
path: string;
size: number;
is_dir: boolean;
mod_time: string;
mode: string;
}
interface Breadcrumb {
name: string;
path: string;
}
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(2)} MB`;
};
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
serverId: number | null;
serverName: string;
}
export function ServerFileBrowser({ open, onOpenChange, serverId, serverName }: Props) {
const [items, setItems] = useState<FileItem[]>([]);
const [breadcrumbs, setBreadcrumbs] = useState<Breadcrumb[]>([]);
const [currentPath, setCurrentPath] = useState('/');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const fetchDir = useCallback(async (path: string) => {
if (!serverId) return;
setLoading(true);
setError('');
try {
const resp = await serverApi.listFiles(serverId, path) as any;
const data = resp?.data || resp;
setItems(data.items || []);
setBreadcrumbs(data.breadcrumbs || []);
setCurrentPath(data.path || path);
} catch (err: any) {
setError(err?.message || '加载目录失败');
setItems([]);
} finally {
setLoading(false);
}
}, [serverId]);
useEffect(() => {
if (open && serverId) {
setFullscreen(false);
fetchDir('/');
}
}, [open, serverId, fetchDir]);
const handleDownload = async (filePath: string) => {
if (!serverId) return;
try {
await serverApi.downloadFile(serverId, filePath);
} catch (err) {
setError('下载失败: ' + ((err as any)?.message || ''));
}
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !serverId) return;
setUploading(true);
try {
await serverApi.uploadFile(serverId, currentPath, file);
await fetchDir(currentPath);
} catch (err: any) {
setError(err?.message || '上传失败');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
};
const handleDelete = async (filePath: string, isDir: boolean) => {
if (!serverId || !confirm(`确认删除 ${isDir ? '目录' : '文件'} "${filePath}"?`)) return;
try {
await serverApi.deleteFile(serverId, filePath);
await fetchDir(currentPath);
} catch (err: any) {
setError(err?.message || '删除失败');
}
};
const handleMkdir = async () => {
const name = prompt('输入目录名:');
if (!name || !serverId) return;
const newPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
try {
await serverApi.mkdir(serverId, newPath);
await fetchDir(currentPath);
} catch (err: any) {
setError(err?.message || '创建失败');
}
};
const sortedItems = [...items].sort((a, b) => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
return a.name.localeCompare(b.name);
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
onInteractOutside={(e) => {
// 阻止点击外部关闭,上传中避免误关
if (uploading) e.preventDefault();
}}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
'!duration-0 !transition-none',
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none'
: ''
)}
style={fullscreen
? { maxWidth: '100vw', width: '100vw', height: '100vh' }
: { maxWidth: '95vw', width: '1100px', maxHeight: '92vh' }
}
>
<DialogTitle className="sr-only">文件浏览 — {serverName}</DialogTitle>
<DialogDescription className="sr-only">SFTP 文件管理 · 上传 · 下载 · 删除</DialogDescription>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--dg-border)] shrink-0">
<div className="flex items-center gap-2 min-w-0">
<FolderOpen className="h-4 w-4 text-[var(--dg-blue)] shrink-0" />
<span className="text-[var(--dg-text-primary)] font-medium text-sm truncate">
{serverName}
</span>
<span className="text-[var(--dg-text-muted)] text-xs">· SFTP 文件管理</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title={fullscreen ? '退出全屏' : '全屏'}
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Toolbar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-primary)] shrink-0">
<Button variant="outline" size="sm" className="border-[var(--dg-border)] h-8"
onClick={() => fetchDir(currentPath)}>
<RefreshCw className="h-3.5 w-3.5 mr-1" />刷新
</Button>
<label>
<Button variant="outline" size="sm" className="border-[var(--dg-border)] h-8 cursor-pointer"
onClick={() => inputRef.current?.click()} disabled={uploading}>
{uploading ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Upload className="h-3.5 w-3.5 mr-1" />}
上传
</Button>
<input ref={inputRef} type="file" className="hidden" onChange={handleUpload} />
</label>
<Button variant="outline" size="sm" className="border-[var(--dg-border)] h-8"
onClick={handleMkdir}>
<FolderPlus className="h-3.5 w-3.5 mr-1" />新建目录
</Button>
{error && (
<Badge variant="outline" className="border-red-500/30 text-red-400 ml-auto">
<X className="h-3 w-3 mr-1 cursor-pointer" onClick={() => setError('')} />
{error}
</Badge>
)}
</div>
{/* Breadcrumbs */}
<div className="flex items-center gap-0.5 px-4 py-2 text-xs border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] overflow-x-auto shrink-0">
{breadcrumbs.map((crumb, i) => (
<span key={crumb.path} className="flex items-center gap-0.5">
{i === 0 ? (
<button
onClick={() => fetchDir('/')}
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] p-0.5"
>
<Home className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={() => fetchDir(crumb.path)}
className="text-[var(--dg-blue)] hover:underline truncate max-w-[150px]"
>
{crumb.name}
</button>
)}
{i < breadcrumbs.length - 1 && (
<ChevronRight className="h-3 w-3 text-[var(--dg-text-muted)] shrink-0" />
)}
</span>
))}
</div>
{/* File List */}
<div className="overflow-y-auto flex-1" style={{
height: fullscreen ? 'calc(100vh - 110px)' : 'min(55vh, 600px)'
}}>
{loading ? (
<div className="flex items-center justify-center h-full py-16">
<Loader2 className="h-6 w-6 animate-spin text-[var(--dg-blue)]" />
</div>
) : sortedItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-muted)]">
<Folder className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm">空目录</p>
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[var(--dg-bg-primary)] border-b border-[var(--dg-border)] z-10">
<tr className="text-left text-[var(--dg-text-muted)] text-xs">
<th className="py-2 pl-4 w-8 font-medium"></th>
<th className="py-2 font-medium">名称</th>
<th className="py-2 px-2 font-medium w-28 text-right">大小</th>
<th className="py-2 px-2 font-medium w-44 hidden sm:table-cell">修改时间</th>
<th className="py-2 px-2 font-medium w-24 hidden md:table-cell">权限</th>
<th className="py-2 pr-4 pl-2 font-medium w-24 text-center">操作</th>
</tr>
</thead>
<tbody>
{currentPath !== '/' && (
<tr className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] cursor-pointer"
onClick={() => {
const parent = currentPath.split('/').slice(0, -1).join('/') || '/';
fetchDir(parent);
}}>
<td className="py-2 pl-4"><ArrowLeft className="h-4 w-4 text-[var(--dg-text-muted)]" /></td>
<td className="py-2 text-[var(--dg-text-secondary)]" colSpan={5}>..</td>
</tr>
)}
{sortedItems.map((item) => (
<tr key={item.path}
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]"
>
<td className="py-2 pl-4">
{item.is_dir ? (
<Folder className="h-4 w-4 text-amber-400" />
) : (
<File className="h-4 w-4 text-sky-400" />
)}
</td>
<td className="py-2">
{item.is_dir ? (
<button
onClick={() => fetchDir(item.path)}
className="text-[var(--dg-text-primary)] hover:text-[var(--dg-blue)] hover:underline font-medium cursor-pointer"
>
{item.name}
</button>
) : (
<span className="text-[var(--dg-text-primary)]">{item.name}</span>
)}
</td>
<td className="py-2 px-2 text-right text-[var(--dg-text-muted)]">
{item.is_dir ? '-' : formatSize(item.size)}
</td>
<td className="py-2 px-2 text-[var(--dg-text-muted)] hidden sm:table-cell">{item.mod_time}</td>
<td className="py-2 px-2 text-[var(--dg-text-muted)] font-mono text-xs hidden md:table-cell">{item.mode}</td>
<td className="py-2 pr-4 pl-2">
<div className="flex items-center justify-center gap-1">
{!item.is_dir && (
<button
onClick={(e) => { e.stopPropagation(); handleDownload(item.path); }}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-white"
title="下载"
>
<Download className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); handleDelete(item.path, item.is_dir); }}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-red-400 hover:text-red-300"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</DialogContent>
</Dialog>
);
}