1. ESLint unused-vars 从 32 清零(回归修复) - 删除 14 个文件中未使用的 import(lucide 图标/shadcn 组件/JsonData 类型) - 移除未使用的变量/参数(categoryLabels/updateEntry/onSaved 等) - 涉及: AppRelease/PipelineEditor/KubernetesNodes/AlertCenter/SilenceRulesPanel 等 2. 修复 go:embed all:static 编译阻塞 - 新增 backend/static/.gitkeep 占位文件 - 全新 clone 后 go build ./... 可直接通过(不再依赖先 npm run build) 3. 安装缺失的 npm 依赖 - react-markdown/remark-gfm(AgentMarkdown 组件) - @codemirror/lang-python/@codemirror/legacy-modes(ScriptCodeEditor 组件) 验证: go build✅ | tsc -b✅ | npm build✅(21s) | ESLint any=41 unused=0 | 17/17 集成测试✅
513 lines
20 KiB
TypeScript
513 lines
20 KiB
TypeScript
// 操作审计 Tab — 增强版审计日志列表,含筛选/导出/统计/清除
|
|
// v2: 虚拟滚动渲染,支持万级日志流畅查看
|
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Search,
|
|
RefreshCw,
|
|
Download,
|
|
Eye,
|
|
X,
|
|
ClipboardList,
|
|
} from 'lucide-react';
|
|
import { PageShell } from '@/components/layout/PageShell';
|
|
import { ContentPanel } from '@/components/layout/ContentPanel';
|
|
import { PageEmpty } from '@/components/layout/PageEmpty';
|
|
import { StatStrip } from '@/components/ui/stat-strip';
|
|
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 { t } = useTranslation();
|
|
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, isFetching, 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 dg-text-green bg-emerald-500/10">
|
|
{t('common.success')}
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10">
|
|
{t('common.failed')}
|
|
</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();
|
|
|
|
const successCount = filteredLogs.filter(l => l.result === 'success').length;
|
|
const failCount = filteredLogs.filter(l => l.result === 'failure').length;
|
|
|
|
const toolbar = (
|
|
<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 dg-text-muted" />
|
|
<Input
|
|
placeholder={t('audit.operations.searchPlaceholder')}
|
|
value={keyword}
|
|
onChange={(e) => setKeyword(e.target.value)}
|
|
className="pl-9 h-9 dg-input"
|
|
/>
|
|
</div>
|
|
<select
|
|
value={moduleFilter}
|
|
onChange={(e) => setModuleFilter(e.target.value)}
|
|
className="h-9 px-3 text-sm rounded-md dg-input"
|
|
>
|
|
<option value="">{t('audit.operations.allModules')}</option>
|
|
{modules.map((m) => (
|
|
<option key={m.value} value={m.value}>{m.label}</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
value={actionFilter}
|
|
onChange={(e) => setActionFilter(e.target.value)}
|
|
className="h-9 px-3 text-sm rounded-md dg-input"
|
|
>
|
|
<option value="">{t('audit.operations.allActions')}</option>
|
|
{actions.map((a) => (
|
|
<option key={a.value} value={a.value}>{a.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<PageShell
|
|
variant="list"
|
|
title={t('audit.operations.title', { defaultValue: '操作审计' })}
|
|
subtitle={t('audit.operations.subtitle', { defaultValue: '系统操作日志筛选、导出与详情查看' })}
|
|
icon={<ClipboardList className="h-5 w-5" />}
|
|
loading={loading}
|
|
metrics={(
|
|
<StatStrip
|
|
items={[
|
|
{ label: t('audit.operations.allModules'), value: filteredLogs.length, color: 'blue' },
|
|
{ label: t('common.success'), value: successCount, color: 'emerald' },
|
|
{ label: t('common.failed'), value: failCount, color: 'red' },
|
|
]}
|
|
/>
|
|
)}
|
|
toolbar={toolbar}
|
|
actions={(
|
|
<>
|
|
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchLogs()} disabled={isFetching}>
|
|
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />{t('common.refresh')}
|
|
</Button>
|
|
<Button variant="outline" className="dg-btn-secondary" onClick={handleExport}>
|
|
<Download className="h-4 w-4 mr-2" />{t('common.export')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
>
|
|
<ContentPanel noPadding>
|
|
<div>
|
|
{!loading && filteredLogs.length === 0 ? (
|
|
<PageEmpty icon={<Search className="h-8 w-8" />} title={t('audit.operations.noData')} />
|
|
) : (
|
|
<>
|
|
{/* 表头 */}
|
|
<div
|
|
className="grid gap-2 px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-xs font-medium dg-text-secondary sticky top-0 z-10"
|
|
style={{ gridTemplateColumns: GRID_COLS }}
|
|
>
|
|
<span>{t('audit.operations.col.time')}</span>
|
|
<span>{t('audit.operations.col.user')}</span>
|
|
<span>{t('audit.operations.col.module')}</span>
|
|
<span>{t('audit.operations.col.actionType')}</span>
|
|
<span>{t('audit.operations.col.resource')}</span>
|
|
<span>{t('audit.operations.col.ip')}</span>
|
|
<span>{t('audit.operations.col.result')}</span>
|
|
<span className="text-right">{t('audit.operations.col.detail')}</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-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]"
|
|
>
|
|
<span className="text-sm dg-text-secondary truncate">
|
|
{log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'}
|
|
</span>
|
|
<span className="text-sm text-[var(--dg-text-primary)] truncate">{log.username || '--'}</span>
|
|
<span className="text-sm text-[var(--dg-text-secondary)] truncate">{log.module || '--'}</span>
|
|
<div>
|
|
<Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10">
|
|
{log.action || '--'}
|
|
</Badge>
|
|
</div>
|
|
<code className="text-xs dg-text-secondary bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded truncate">
|
|
{log.resource || '--'}
|
|
</code>
|
|
<span className="text-sm dg-text-secondary 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 dg-text-secondary hover:dg-text-blue hover:bg-[var(--dg-bg-hover)]"
|
|
onClick={() => viewDetail(log)}
|
|
title={t('audit.operations.viewDetail')}
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 行数统计 */}
|
|
<div className="text-center py-2 text-xs dg-text-muted border-t border-[var(--dg-border)]">
|
|
{t('audit.operations.totalLogs', { count: filteredLogs.length })} {filteredLogs.length > 200 && `· ${t('audit.operations.virtualScroll')}`}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</ContentPanel>
|
|
|
|
{/* Detail Modal */}
|
|
<LogDetailDialog log={detailLog} onClose={() => setDetailLog(null)} />
|
|
|
|
{/* Statistics Modal */}
|
|
<StatisticsDialog
|
|
open={statsOpen}
|
|
onClose={() => setStatsOpen(false)}
|
|
loading={statsLoading}
|
|
stats={statistics}
|
|
/>
|
|
</PageShell>
|
|
);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Log Detail Dialog
|
|
// =========================================================================
|
|
|
|
function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose: () => void }) {
|
|
const { t } = useTranslation();
|
|
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 dg-card 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-[var(--dg-text-primary)]">{t('audit.operations.detailTitle')}</h3>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 dg-text-secondary hover:text-[var(--dg-text-primary)]"
|
|
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={t('audit.operations.col.time')} value={log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'} />
|
|
<DetailRow label={t('audit.operations.col.user')} value={log.username || '--'} />
|
|
<DetailRow label={t('audit.operations.detailCol.userId')} value={log.userId || '--'} />
|
|
<DetailRow label={t('audit.operations.col.module')} value={log.module || '--'} />
|
|
<DetailRow label={t('audit.operations.col.actionType')} value={log.action || '--'} />
|
|
<DetailRow label={t('audit.operations.col.resource')} value={log.resource || '--'} />
|
|
<DetailRow label={t('audit.operations.detailCol.ipAddress')} value={log.ip || '--'} />
|
|
<DetailRow label={t('audit.operations.detailCol.userAgent')} value={log.userAgent || '--'} />
|
|
<DetailRow
|
|
label={t('audit.operations.col.result')}
|
|
value={
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
log.result === 'success'
|
|
? 'border-emerald-500/30 dg-text-green bg-emerald-500/10'
|
|
: 'border-red-500/30 dg-text-red bg-red-500/10'
|
|
}
|
|
>
|
|
{log.result === 'success' ? t('common.success') : t('common.failed')}
|
|
</Badge>
|
|
}
|
|
/>
|
|
{log.message && <DetailRow label={t('audit.operations.detailCol.message')} value={log.message} mono />}
|
|
</div>
|
|
|
|
<div className="mt-5 flex justify-end">
|
|
<Button
|
|
variant="outline"
|
|
className="dg-btn-secondary"
|
|
onClick={onClose}
|
|
>
|
|
{t('common.close')}
|
|
</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="dg-text-muted w-20 flex-shrink-0">{label}</span>
|
|
<span className={mono ? 'text-[var(--dg-text-secondary)] font-mono text-xs break-all' : 'text-[var(--dg-text-primary)] break-all'}>
|
|
{value}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Statistics Dialog
|
|
// =========================================================================
|
|
|
|
function StatisticsDialog({
|
|
open,
|
|
onClose,
|
|
loading,
|
|
stats,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
loading: boolean;
|
|
stats: AuditStatistics | null;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
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 dg-card 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-[var(--dg-text-primary)]">{t('audit.operations.statsTitle')}</h3>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 dg-text-secondary hover:text-[var(--dg-text-primary)]"
|
|
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={t('audit.operations.stats.totalLogs')} value={String(stats.total_logs ?? '--')} color="blue" />
|
|
<StatCard label={t('audit.operations.stats.todayLogs')} value={String(stats.today_count ?? '--')} color="emerald" />
|
|
<StatCard label={t('audit.operations.stats.successCount')} value={String(stats.success_count ?? '--')} color="emerald" />
|
|
<StatCard label={t('audit.operations.stats.failureCount')} value={String(stats.failure_count ?? '--')} color="red" />
|
|
</div>
|
|
|
|
{stats.top_modules && stats.top_modules.length > 0 && (
|
|
<div>
|
|
<h4 className="text-xs font-semibold dg-text-secondary mb-2">{t('audit.operations.topModules')}</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-[var(--dg-text-secondary)]">{m.module}</span>
|
|
<span className="dg-text-muted">{m.count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{stats.top_users && stats.top_users.length > 0 && (
|
|
<div>
|
|
<h4 className="text-xs font-semibold dg-text-secondary mb-2">{t('audit.operations.topUsers')}</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-[var(--dg-text-secondary)]">{u.username}</span>
|
|
<span className="dg-text-muted">{u.count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="dg-text-muted text-center py-10 text-sm">{t('audit.operations.noStats')}</p>
|
|
)}
|
|
|
|
<div className="mt-5 flex justify-end">
|
|
<Button
|
|
variant="outline"
|
|
className="dg-btn-secondary"
|
|
onClick={onClose}
|
|
>
|
|
{t('common.close')}
|
|
</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 dg-text-blue',
|
|
emerald: 'border-emerald-500/20 bg-emerald-500/5 dg-text-green',
|
|
red: 'border-red-500/20 bg-red-500/5 dg-text-red',
|
|
};
|
|
return (
|
|
<div className={`rounded-lg border p-3 ${colorMap[color]}`}>
|
|
<div className="text-xs dg-text-muted">{label}</div>
|
|
<div className="text-xl font-bold mt-0.5">{value}</div>
|
|
</div>
|
|
);
|
|
}
|