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>
);
}
@@ -40,12 +40,13 @@ import {
ChevronRight,
} from 'lucide-react';
import { serverApi } from '@/services/api';
import { ServerStatsCards } from './ServerStatsCards';
import { ServerStatsStrip } from './ServerStatsStrip';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { useUserStore } from '@/store';
import { CloudServerImport } from './CloudServerImport';
import { TerminalDialog } from './TerminalDialog';
import { ServerFileBrowser } from './ServerFileBrowser';
import { ExcelServerImport } from './ExcelServerImport';
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
import { getTags } from './serverHelpers';
@@ -72,6 +73,10 @@ export function ServerManager() {
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string; hasAuth?: boolean } | null>(null);
// 文件浏览 Dialog 状态
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
const [fileBrowserServer, setFileBrowserServer] = useState<{ id: number; name: string } | null>(null);
// 分页状态
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
@@ -82,6 +87,11 @@ export function ServerManager() {
setTerminalOpen(true);
};
const openFileBrowser = ( server: any) => {
setFileBrowserServer({ id: server.id, name: server.hostname });
setFileBrowserOpen(true);
};
const loadData = async (kw?: string, st?: string) => {
try {
setLoading(true);
@@ -421,7 +431,7 @@ export function ServerManager() {
</div>
{/* Stats Cards */}
<ServerStatsCards servers={servers} />
<ServerStatsStrip servers={servers} />
{/* Filters & Table */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
@@ -512,6 +522,7 @@ export function ServerManager() {
selected={selectedServers.includes(server.id)}
onToggleSelect={toggleSelect}
onOpenTerminal={openTerminal}
onOpenFiles={openFileBrowser}
onOpenEdit={openEditForm}
onDelete={handleDelete}
/>
@@ -603,6 +614,14 @@ export function ServerManager() {
serverIp={terminalServer?.ip ?? ''}
hasAuth={terminalServer?.hasAuth ?? true}
/>
{/* 文件浏览 Dialog */}
<ServerFileBrowser
open={fileBrowserOpen}
onOpenChange={setFileBrowserOpen}
serverId={fileBrowserServer?.id ?? null}
serverName={fileBrowserServer?.name ?? ''}
/>
</div>
);
}
@@ -1,70 +0,0 @@
// 服务器统计卡片组件 — 从 ServerManager 提取 // Logan.Li
import { Card, CardContent } from '@/components/ui/card';
import { Server, Activity, RotateCw, Power } from 'lucide-react';
interface ServerStatsCardsProps {
servers: any[];
}
export function ServerStatsCards({ servers }: ServerStatsCardsProps) {
const runningCount = servers.filter((s) => s.status === 'running').length;
const maintenanceCount = servers.filter((s) => s.status === 'maintenance').length;
const stoppedCount = servers.filter((s) => s.status === 'stopped').length;
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--dg-text-muted)]">服务器总数</p>
<p className="text-2xl font-bold text-[var(--dg-text-primary)]">{servers.length}</p>
</div>
<div className="p-3 rounded-lg bg-[var(--dg-blue)]/10 text-[var(--dg-blue)]">
<Server className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--dg-text-muted)]">运行中</p>
<p className="text-2xl font-bold text-emerald-400">{runningCount}</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<Activity className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--dg-text-muted)]">维护中</p>
<p className="text-2xl font-bold text-amber-400">{maintenanceCount}</p>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
<RotateCw className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--dg-text-muted)]">已停止</p>
<p className="text-2xl font-bold text-[var(--dg-text-muted)]">{stoppedCount}</p>
</div>
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]">
<Power className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,23 @@
// 服务器统计条 — 紧凑型替代 4 张大卡片
import { StatStrip } from '@/components/ui/stat-strip';
interface ServerStatsStripProps {
servers: any[];
}
export function ServerStatsStrip({ servers }: ServerStatsStripProps) {
const runningCount = servers.filter((s) => s.status === 'running').length;
const maintenanceCount = servers.filter((s) => s.status === 'maintenance').length;
const stoppedCount = servers.filter((s) => s.status === 'stopped').length;
return (
<StatStrip
items={[
{ label: '服务器', value: servers.length, color: 'blue' },
{ label: '运行中', value: runningCount, color: 'emerald' },
{ label: '维护中', value: maintenanceCount, color: 'amber' },
{ label: '已停止', value: stoppedCount, color: 'muted' },
]}
/>
);
}
@@ -12,7 +12,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TableCell, TableRow } from '@/components/ui/table';
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive } from 'lucide-react';
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive, FolderOpen } from 'lucide-react';
import { getStatusBadge, getTags, getUsageColor } from './serverHelpers';
interface ServerTableRowProps {
@@ -21,6 +21,7 @@ interface ServerTableRowProps {
onToggleSelect: (id: string) => void;
onOpenTerminal: ( server: any) => void;
onOpenEdit: ( server: any) => void;
onOpenFiles?: ( server: any) => void;
onDelete: (id: string) => void;
}
@@ -30,6 +31,7 @@ export function ServerTableRow({
onToggleSelect,
onOpenTerminal,
onOpenEdit,
onOpenFiles,
onDelete,
}: ServerTableRowProps) {
const cpuUsage = Number(server.cpu_usage || 0);
@@ -145,7 +147,7 @@ export function ServerTableRow({
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)}>
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)} title="终端登录">
<Terminal className="h-4 w-4" />
</Button>
<DropdownMenu>
@@ -171,6 +173,13 @@ export function ServerTableRow({
<Terminal className="h-4 w-4 mr-2" />
终端
</DropdownMenuItem>
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenFiles?.(server)}
>
<FolderOpen className="h-4 w-4 mr-2" />
文件
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem
className="text-red-400 focus:bg-[var(--dg-bg-hover)] focus:text-red-400 cursor-pointer"
+16 -9
View File
@@ -239,7 +239,7 @@ export function WebShell() {
}
};
// 注册容器并首次挂载终端
// 注册容器并首次挂载终端 + ResizeObserver 自适应
const registerContainer = (sessionId: string, el: HTMLDivElement | null) => {
if (el) {
containerRef.current.set(sessionId, el);
@@ -248,19 +248,25 @@ export function WebShell() {
try {
entry.term.open(el);
entry.opened = true;
requestAnimationFrame(() => {
try {
entry.fit.fit();
entry.term.focus();
} catch {
/* noop */
}
// 延迟适配:等 DOM 布局完全确定
setTimeout(() => {
try { entry.fit.fit(); entry.term.focus(); } catch { /* noop */ }
}, 100);
// ResizeObserver 持续监听容器大小变化
const observer = new ResizeObserver(() => {
try { entry.fit.fit(); } catch { /* noop */ }
});
observer.observe(el);
(entry as any)._observer = observer;
} catch (e) {
console.error('打开终端失败:', e);
}
}
} else {
const entry = termsRef.current.get(sessionId);
if (entry && (entry as any)._observer) {
(entry as any)._observer.disconnect();
}
containerRef.current.delete(sessionId);
}
};
@@ -399,9 +405,10 @@ export function WebShell() {
key={session.id}
ref={(el) => registerContainer(session.id, el)}
className={cn(
'absolute inset-0 p-1',
'absolute inset-0',
activeSession === session.id ? 'block' : 'hidden'
)}
style={{ padding: 0 }}
/>
))}
</div>