refactor(frontend): api/components/pages 目录架构统一对齐菜单域 + 日志中心修复
1. 目录架构统一(API = components = 菜单域,一域一目录 + barrel) - log/ 提为独立顶层域(日志中心在菜单中与监控中心平级,不再塞在 monitor/) - task/ 拆分原 automation.ts → cron-job / script / remote-exec - aiops/ 合并 ai.ts + agentCli.ts → assistant + agent-cli - agent/ approval/ audit/ 提升为域目录 - auth 拆分:authApi(认证自服务) + system/user.ts(userApi+ldapApi 管理端) - components/automation → components/task,菜单"自动化运维"→"作业任务" - pages/ 按域分子目录(approval/audit/monitor),删孤儿 AuditPage.tsx - 所有 API 符号名不变,组件经 @/api 聚合引用,运行时行为零变化 2. Loki 查询无返回修复:aiops 容器 DNS 解析外网域名失败 (宿主机 systemd-resolved 转发链路异常),docker-compose 显式配 dns 上游 3. ES 查询框布局:工具栏单行挤 7 控件(ES 多 150px 索引框),拆两行—— 查询输入独占一行(+查询按钮),次级控件换行 flex-wrap Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
da01cbb119
commit
c0ee8e4ddd
74 files changed
+229
-216
No files matched your search
@@ -0,0 +1,508 @@
|
||||
// 操作审计 Tab — 增强版审计日志列表,含筛选/导出/统计/清除
|
||||
// v2: 虚拟滚动渲染,支持万级日志流畅查看
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Eye,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { auditApi } from '@/api';
|
||||
import { useAuditLogs } from '@/hooks/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import type { OperationLog, OptionItem } from '@/types';
|
||||
|
||||
// Normalize backend log to frontend type
|
||||
function normalizeLog(raw: Record<string, unknown>): OperationLog {
|
||||
return {
|
||||
id: String(raw.id ?? ''),
|
||||
userId: String(raw.user_id ?? raw.userId ?? ''),
|
||||
username: String(raw.username ?? ''),
|
||||
module: String(raw.module ?? raw.resource ?? ''),
|
||||
action: String(raw.action ?? ''),
|
||||
resource: String(raw.resource ?? ''),
|
||||
ip: String(raw.ip ?? ''),
|
||||
userAgent: String(raw.user_agent ?? raw.userAgent ?? ''),
|
||||
result: raw.result === 'failure' ? 'failure' : 'success',
|
||||
message: String(raw.message ?? raw.detail ?? ''),
|
||||
createdAt: String(raw.createdAt ?? raw.created_at ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
interface AuditStatistics {
|
||||
total_logs?: number;
|
||||
success_count?: number;
|
||||
failure_count?: number;
|
||||
today_count?: number;
|
||||
top_modules?: { module: string; count: number }[];
|
||||
top_actions?: { action: string; count: number }[];
|
||||
top_users?: { username: string; count: number }[];
|
||||
}
|
||||
|
||||
export function AuditOperationsTab() {
|
||||
const _confirm = useConfirm();
|
||||
// Filters
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [moduleFilter, setModuleFilter] = useState('');
|
||||
const [actionFilter, setActionFilter] = useState('');
|
||||
|
||||
const auditParams = useMemo(() => ({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
...(moduleFilter ? { module: moduleFilter } : {}),
|
||||
...(actionFilter ? { action: actionFilter } : {}),
|
||||
}), [moduleFilter, actionFilter]);
|
||||
|
||||
const { data: auditData, isLoading: loading, refetch: refetchLogs } = useAuditLogs(auditParams);
|
||||
const logs = useMemo(
|
||||
() => (auditData?.list || []).map((raw) => normalizeLog(raw)),
|
||||
[auditData],
|
||||
);
|
||||
const [modules, setModules] = useState<OptionItem[]>([]);
|
||||
const [actions, setActions] = useState<OptionItem[]>([]);
|
||||
|
||||
// Detail modal
|
||||
const [detailLog, setDetailLog] = useState<OperationLog | null>(null);
|
||||
|
||||
// Statistics modal
|
||||
const [statsOpen, setStatsOpen] = useState(false);
|
||||
const [statistics, setStatistics] = useState<AuditStatistics | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
|
||||
const fetchOptions = useCallback(async () => {
|
||||
try {
|
||||
const [modRes, actRes] = await Promise.all([auditApi.getModules(), auditApi.getActions()]);
|
||||
const modData = modRes as { list?: OptionItem[] } | OptionItem[];
|
||||
const actData = actRes as { list?: OptionItem[] } | OptionItem[];
|
||||
setModules(Array.isArray(modData) ? modData : (modData?.list ?? []));
|
||||
setActions(Array.isArray(actData) ? actData : (actData?.list ?? []));
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOptions();
|
||||
}, [fetchOptions]);
|
||||
|
||||
const filteredLogs = logs.filter((log) => {
|
||||
const kw = keyword.toLowerCase();
|
||||
return (
|
||||
!kw ||
|
||||
log.username.toLowerCase().includes(kw) ||
|
||||
log.module.toLowerCase().includes(kw) ||
|
||||
log.action.toLowerCase().includes(kw) ||
|
||||
log.resource.toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
|
||||
const getLogResultBadge = (result: OperationLog['result']) => {
|
||||
return result === 'success' ? (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
成功
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
||||
失败
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (moduleFilter) params.module = moduleFilter;
|
||||
if (actionFilter) params.action = actionFilter;
|
||||
await auditApi.exportLogs(params);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
const _handleShowStats = async () => {
|
||||
setStatsOpen(true);
|
||||
setStatsLoading(true);
|
||||
try {
|
||||
const data = (await auditApi.getStatistics()) as AuditStatistics;
|
||||
setStatistics(data);
|
||||
} catch {
|
||||
setStatistics(null);
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const viewDetail = (log: OperationLog) => {
|
||||
setDetailLog(log);
|
||||
};
|
||||
|
||||
// 虚拟滚动
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const GRID_COLS = 'minmax(140px,1fr) minmax(80px,1fr) minmax(100px,1fr) minmax(100px,1fr) minmax(140px,1fr) minmax(100px,1fr) minmax(80px,0.8fr) 48px';
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredLogs.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 52,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const virtualRows = virtualizer.getVirtualItems();
|
||||
const totalHeight = virtualizer.getTotalSize();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索操作日志..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Module filter */}
|
||||
<select
|
||||
value={moduleFilter}
|
||||
onChange={(e) => setModuleFilter(e.target.value)}
|
||||
className="h-9 px-3 text-sm rounded-md bg-slate-800/50 border border-slate-700 text-slate-300 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">全部模块</option>
|
||||
{modules.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Action filter */}
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
className="h-9 px-3 text-sm rounded-md bg-slate-800/50 border border-slate-700 text-slate-300 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">全部操作</option>
|
||||
{actions.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => refetchLogs()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Logs Table — 虚拟滚动 */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<Search className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无操作日志</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 表头 */}
|
||||
<div
|
||||
className="grid gap-2 px-4 py-2 border-b border-slate-800 bg-slate-900 text-xs font-medium text-slate-400 sticky top-0 z-10"
|
||||
style={{ gridTemplateColumns: GRID_COLS }}
|
||||
>
|
||||
<span>时间</span>
|
||||
<span>用户</span>
|
||||
<span>模块</span>
|
||||
<span>操作类型</span>
|
||||
<span>资源</span>
|
||||
<span>IP</span>
|
||||
<span>结果</span>
|
||||
<span className="text-right">详情</span>
|
||||
</div>
|
||||
|
||||
{/* 虚拟滚动列表 */}
|
||||
<div ref={scrollRef} style={{ height: 'min(600px, calc(100vh - 340px))', overflow: 'auto' }}>
|
||||
<div style={{ height: totalHeight, position: 'relative' }}>
|
||||
{virtualRows.map((vr) => {
|
||||
const log = filteredLogs[vr.index];
|
||||
if (!log) return null;
|
||||
return (
|
||||
<div
|
||||
key={log.id}
|
||||
data-index={vr.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vr.start}px)`,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: GRID_COLS,
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
className="px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50"
|
||||
>
|
||||
<span className="text-sm text-slate-400 truncate">
|
||||
{log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'}
|
||||
</span>
|
||||
<span className="text-sm text-slate-200 truncate">{log.username || '--'}</span>
|
||||
<span className="text-sm text-slate-300 truncate">{log.module || '--'}</span>
|
||||
<div>
|
||||
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
|
||||
{log.action || '--'}
|
||||
</Badge>
|
||||
</div>
|
||||
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded truncate">
|
||||
{log.resource || '--'}
|
||||
</code>
|
||||
<span className="text-sm text-slate-400 truncate">{log.ip || '--'}</span>
|
||||
<div>{getLogResultBadge(log.result)}</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
|
||||
onClick={() => viewDetail(log)}
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 行数统计 */}
|
||||
<div className="text-center py-2 text-xs text-slate-500 border-t border-slate-800">
|
||||
共 {filteredLogs.length} 条日志 {filteredLogs.length > 200 && '· 虚拟滚动渲染'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<LogDetailDialog log={detailLog} onClose={() => setDetailLog(null)} />
|
||||
|
||||
{/* Statistics Modal */}
|
||||
<StatisticsDialog
|
||||
open={statsOpen}
|
||||
onClose={() => setStatsOpen(false)}
|
||||
loading={statsLoading}
|
||||
stats={statistics}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Log Detail Dialog
|
||||
// =========================================================================
|
||||
|
||||
function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose: () => void }) {
|
||||
if (!log) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div className="relative bg-slate-900 border border-slate-800 rounded-lg p-6 max-w-lg w-full mx-4 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-100">操作日志详情</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-slate-100"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<DetailRow label="ID" value={log.id} />
|
||||
<DetailRow label="时间" value={log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'} />
|
||||
<DetailRow label="用户" value={log.username || '--'} />
|
||||
<DetailRow label="用户ID" value={log.userId || '--'} />
|
||||
<DetailRow label="模块" value={log.module || '--'} />
|
||||
<DetailRow label="操作类型" value={log.action || '--'} />
|
||||
<DetailRow label="资源" value={log.resource || '--'} />
|
||||
<DetailRow label="IP地址" value={log.ip || '--'} />
|
||||
<DetailRow label="User-Agent" value={log.userAgent || '--'} />
|
||||
<DetailRow
|
||||
label="结果"
|
||||
value={
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
log.result === 'success'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
}
|
||||
>
|
||||
{log.result === 'success' ? '成功' : '失败'}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
{log.message && <DetailRow label="详情" value={log.message} mono />}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-slate-500 w-20 flex-shrink-0">{label}</span>
|
||||
<span className={mono ? 'text-slate-300 font-mono text-xs break-all' : 'text-slate-200 break-all'}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Statistics Dialog
|
||||
// =========================================================================
|
||||
|
||||
function StatisticsDialog({
|
||||
open,
|
||||
onClose,
|
||||
loading,
|
||||
stats,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
loading: boolean;
|
||||
stats: AuditStatistics | null;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div className="relative bg-slate-900 border border-slate-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-100">审计统计</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-slate-100"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : stats ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatCard label="总日志数" value={String(stats.total_logs ?? '--')} color="blue" />
|
||||
<StatCard label="今日日志" value={String(stats.today_count ?? '--')} color="emerald" />
|
||||
<StatCard label="成功数" value={String(stats.success_count ?? '--')} color="emerald" />
|
||||
<StatCard label="失败数" value={String(stats.failure_count ?? '--')} color="red" />
|
||||
</div>
|
||||
|
||||
{stats.top_modules && stats.top_modules.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-400 mb-2">热门模块</h4>
|
||||
<div className="space-y-1">
|
||||
{stats.top_modules.slice(0, 5).map((m, i) => (
|
||||
<div key={i} className="flex justify-between text-xs">
|
||||
<span className="text-slate-300">{m.module}</span>
|
||||
<span className="text-slate-500">{m.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats.top_users && stats.top_users.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-400 mb-2">活跃用户</h4>
|
||||
<div className="space-y-1">
|
||||
{stats.top_users.slice(0, 5).map((u, i) => (
|
||||
<div key={i} className="flex justify-between text-xs">
|
||||
<span className="text-slate-300">{u.username}</span>
|
||||
<span className="text-slate-500">{u.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500 text-center py-10 text-sm">暂无统计数据</p>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color }: { label: string; value: string; color: 'blue' | 'emerald' | 'red' }) {
|
||||
const colorMap = {
|
||||
blue: 'border-blue-500/20 bg-blue-500/5 text-blue-400',
|
||||
emerald: 'border-emerald-500/20 bg-emerald-500/5 text-emerald-400',
|
||||
red: 'border-red-500/20 bg-red-500/5 text-red-400',
|
||||
};
|
||||
return (
|
||||
<div className={`rounded-lg border p-3 ${colorMap[color]}`}>
|
||||
<div className="text-xs text-slate-500">{label}</div>
|
||||
<div className="text-xl font-bold mt-0.5">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user