Files
aiops/frontend/src/pages/audit/AuditOperationsTab.tsx
T
attacker cde7bbe540 fix: 清理 unused-vars 回归(32→0) + 修复 go:embed 编译阻塞
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 集成测试✅
2026-07-02 00:08:23 +08:00

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>
);
}