Files
opsxsql/frontend/src/pages/AuditRulesPage.tsx
T
logan 4ac2528f8e fix: 审核引擎优化 + 执行结果结构化展示
后端审核规则修复:
- Redis SEC-001: SET/DEL等常规命令不再误报AUTH要求,改为仅对管理级命令WARNING
- Redis SEC-003: 降级为INFO,排除已由CMD-001拦截的FLUSHDB/FLUSHALL
- Redis key提取: 排除CONFIG/ACL等管理命令,不再把子命令参数误认为key名
- MySQL SEC-006: 新增GRANT ALL/REVOKE ALL全局权限拦截
- MySQL SEC-007: 新增LOAD DATA/LOAD XML拦截
- MySQL SEC-008: 新增SLEEP()/BENCHMARK()时间盲注拦截
- MySQL SEC-009: 新增INTO OUTFILE/DUMPFILE文件写入拦截

前端执行结果结构化:
- TicketDetailPage执行结果从原始JSON改为结构化展示
- 解析JSON展示: 状态徽章+影响行数+耗时+验证状态
- Errors/Warnings逐条渲染: 红色错误框+黄色警告框+具体消息
- 删除废弃的executeStats/renderExecuteStats

其他: 审核规则6分组/慢查询移除/查询数据重命名/AuditLogs修复
测试: 1040 passed, TSC零错误
2026-05-24 01:31:33 +08:00

597 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useMemo } from 'react';
import request from '../api/request';
import {
Search, Filter, ChevronDown, ChevronUp, AlertTriangle,
Info, XCircle, Database, Table, FileSearch,
Shield, Loader2, Leaf, Server,
} from 'lucide-react';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface AuditRule {
id: number;
name: string;
description: string | null;
rule_type: string;
severity: string;
db_type: string;
pattern: string | null;
is_enabled: boolean;
message: string;
value: string | null;
created_at: string;
updated_at: string;
}
type DbType = 'mysql' | 'postgresql' | 'redis' | 'mongodb';
type RuleGroup = 'DDL' | 'DML' | 'DCL' | 'DQL' | 'security' | 'performance';
type SeverityFilter = 'all' | 'error' | 'warning' | 'info';
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
interface DbTab {
key: DbType;
label: string;
icon: React.ElementType;
}
const DB_TABS: DbTab[] = [
{ key: 'mysql', label: 'MySQL', icon: Database },
{ key: 'postgresql', label: 'PostgreSQL', icon: Server },
{ key: 'redis', label: 'Redis', icon: Table },
{ key: 'mongodb', label: 'MongoDB', icon: Leaf },
];
const GROUP_CONFIG: Record<RuleGroup, { label: string; icon: React.ElementType; color: string }> = {
DDL: { label: 'DDL 规则', icon: Table, color: 'text-amber-400 bg-amber-500/15' },
DML: { label: 'DML 规则', icon: Shield, color: 'text-blue-400 bg-blue-500/15' },
DCL: { label: 'DCL 规则', icon: Shield, color: 'text-purple-400 bg-purple-500/15' },
DQL: { label: 'DQL 规则', icon: FileSearch, color: 'text-green-400 bg-green-500/15' },
security: { label: '安全规则', icon: AlertTriangle, color: 'text-red-400 bg-red-500/15' },
performance: { label: '性能规则', icon: Info, color: 'text-cyan-400 bg-cyan-500/15' },
};
const SEVERITY_CONFIG: Record<string, { label: string; color: string; dot: string }> = {
error: { label: '错误', color: 'text-red-400', dot: 'bg-red-400' },
warning: { label: '警告', color: 'text-amber-400', dot: 'bg-amber-400' },
info: { label: '提示', color: 'text-blue-400', dot: 'bg-blue-400' },
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const VALID_GROUPS: RuleGroup[] = ['DDL', 'DML', 'DCL', 'DQL', 'security', 'performance'];
function getRuleGroup(rule: AuditRule): RuleGroup {
const rt = rule.rule_type;
if (VALID_GROUPS.includes(rt as RuleGroup)) return rt as RuleGroup;
// Fallback: map unknown types
if (rt === 'safety' || rt === 'naming' || rt === 'syntax') return 'DDL';
return 'DDL';
}
function sortRules(rules: AuditRule[], sortBy: 'severity' | 'name' | 'type'): AuditRule[] {
const severityOrder = { error: 0, warning: 1, info: 2 };
const sorted = [...rules];
switch (sortBy) {
case 'severity':
return sorted.sort((a, b) => (severityOrder[a.severity as keyof typeof severityOrder] ?? 3) - (severityOrder[b.severity as keyof typeof severityOrder] ?? 3));
case 'name':
return sorted.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
case 'type':
return sorted.sort((a, b) => getRuleGroup(a).localeCompare(getRuleGroup(b)));
default:
return sorted;
}
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function AuditRulesPage() {
const [activeTab, setActiveTab] = useState<DbType>('mysql');
const [rules, setRules] = useState<AuditRule[]>([]);
const [loading, setLoading] = useState(false);
const [seeding, setSeeding] = useState(false);
const [editingValue, setEditingValue] = useState<Record<number, string>>({});
const [saving, setSaving] = useState<Record<number, boolean>>({});
// -- Search & Filter --
const [searchQuery, setSearchQuery] = useState('');
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('all');
const [sortBy, setSortBy] = useState<'severity' | 'name' | 'type'>('severity');
// -- Expand & Confirm --
const [expandedRuleId, setExpandedRuleId] = useState<number | null>(null);
const [confirmToggle, setConfirmToggle] = useState<number | null>(null);
/* ---------------------------------------------------------------- */
/* Fetch rules */
/* ---------------------------------------------------------------- */
const fetchRules = useCallback(async (dbType: DbType) => {
try {
setLoading(true);
const res = await request.get('/audit-rules', {
params: { db_type: dbType },
});
const data = (res as { data?: unknown[] }).data ?? (res as unknown as unknown[]);
setRules(Array.isArray(data) ? data as AuditRule[] : []);
} catch {
setRules([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchRules(activeTab);
setSearchQuery('');
setSeverityFilter('all');
setExpandedRuleId(null);
}, [activeTab, fetchRules]);
/* ---------------------------------------------------------------- */
/* Handlers */
/* ---------------------------------------------------------------- */
const handleSeed = useCallback(async () => {
try {
setSeeding(true);
await request.post(`/audit-rules/seed/${activeTab}`);
fetchRules(activeTab);
} catch (err: unknown) {
const e = err as { response?: { data?: { detail?: string } } };
alert(e.response?.data?.detail || '初始化失败');
} finally {
setSeeding(false);
}
}, [activeTab, fetchRules]);
const handleToggle = useCallback(async (rule: AuditRule) => {
if (confirmToggle !== rule.id) {
setConfirmToggle(rule.id);
return;
}
try {
await request.post(`/audit-rules/${rule.id}/toggle`);
setRules((prev) =>
prev.map((r) => (r.id === rule.id ? { ...r, is_enabled: !r.is_enabled } : r)),
);
setConfirmToggle(null);
} catch {
/* ignore */
}
}, [confirmToggle]);
const handleSaveValue = useCallback(async (rule: AuditRule) => {
const newVal = editingValue[rule.id];
if (newVal === undefined || newVal === rule.value) return;
try {
setSaving((prev) => ({ ...prev, [rule.id]: true }));
await request.put(`/audit-rules/${rule.id}`, { value: newVal });
setRules((prev) =>
prev.map((r) => (r.id === rule.id ? { ...r, value: newVal } : r)),
);
setEditingValue((prev) => {
const next = { ...prev };
delete next[rule.id];
return next;
});
} catch {
/* ignore */
} finally {
setSaving((prev) => ({ ...prev, [rule.id]: false }));
}
}, [editingValue]);
/* ---------------------------------------------------------------- */
/* Derived: filtered & grouped rules */
/* ---------------------------------------------------------------- */
const filteredRules = useMemo(() => {
let result = [...rules];
// Search
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.message.toLowerCase().includes(q) ||
(r.description && r.description.toLowerCase().includes(q)),
);
}
// Severity filter
if (severityFilter !== 'all') {
result = result.filter((r) => r.severity === severityFilter);
}
// Sort
return sortRules(result, sortBy);
}, [rules, searchQuery, severityFilter, sortBy]);
const groupedRules = useMemo(() => {
const groups: Record<RuleGroup, AuditRule[]> = {
DDL: [],
DML: [],
DCL: [],
DQL: [],
security: [],
performance: [],
};
for (const rule of filteredRules) {
const group = getRuleGroup(rule);
groups[group].push(rule);
}
return groups;
}, [filteredRules]);
/* ---------------------------------------------------------------- */
/* Render helpers */
/* ---------------------------------------------------------------- */
const renderRuleCard = (rule: AuditRule) => {
const hasValue = rule.value !== null && rule.value !== undefined && rule.value !== '';
const currentEditVal = editingValue[rule.id];
const displayValue = currentEditVal !== undefined ? currentEditVal : rule.value ?? '';
const isExpanded = expandedRuleId === rule.id;
const severityInfo = SEVERITY_CONFIG[rule.severity] || SEVERITY_CONFIG.info;
const isConfirmingToggle = confirmToggle === rule.id;
return (
<div
key={rule.id}
className={`dg-card overflow-hidden transition-all ${!rule.is_enabled ? 'opacity-50' : ''}`}
>
{/* Main row */}
<div
className="px-4 py-3 flex items-center gap-4 cursor-pointer"
onClick={() => setExpandedRuleId(isExpanded ? null : rule.id)}
>
{/* Toggle */}
<button
onClick={(e) => {
e.stopPropagation();
handleToggle(rule);
}}
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors duration-200 ${
rule.is_enabled ? 'bg-[var(--dg-green)]' : 'bg-[var(--dg-bg-hover)]'
}`}
title={rule.is_enabled ? '禁用规则' : '启用规则'}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
rule.is_enabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
{/* Severity dot */}
<span className={`w-2 h-2 rounded-full ${severityInfo.dot} shrink-0`} title={severityInfo.label} />
{/* Name + Message */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-[var(--dg-text-primary)] leading-tight">
{rule.name}
</p>
<span className={`text-[11px] px-1.5 py-0.5 rounded ${severityInfo.color} ${severityInfo.color.split(' ')[1] || 'bg-gray-500/15'}`}>
{severityInfo.label}
</span>
</div>
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5 truncate">{rule.message}</p>
</div>
{/* Value input */}
{hasValue && (
<div className="flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<span className="text-xs text-[var(--dg-text-muted)]">=</span>
<input
type="text"
value={displayValue}
onChange={(e) =>
setEditingValue((prev) => ({ ...prev, [rule.id]: e.target.value }))
}
onBlur={() => handleSaveValue(rule)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveValue(rule);
}}
disabled={saving[rule.id]}
className="w-20 px-2 py-1 text-sm font-mono text-center rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] focus:border-[var(--dg-blue)] focus:outline-none focus:ring-1 focus:ring-[var(--dg-input-focus-ring)] disabled:opacity-50 transition-all"
/>
{saving[rule.id] && <Loader2 className="w-3.5 h-3.5 animate-spin text-[var(--dg-text-muted)]" />}
</div>
)}
{/* Expand icon */}
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)] shrink-0" />
) : (
<ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)] shrink-0" />
)}
</div>
{/* Toggle confirmation */}
{isConfirmingToggle && (
<div className="px-4 py-2 bg-yellow-500/10 border-t border-yellow-500/20 flex items-center gap-3">
<AlertTriangle className="w-4 h-4 text-yellow-400 shrink-0" />
<span className="text-xs text-yellow-300 flex-1">
确认{rule.is_enabled ? '禁用' : '启用'}规则 &quot;{rule.name}&quot;?
</span>
<button
onClick={() => handleToggle(rule)}
className="px-2.5 py-1 text-xs bg-yellow-500/20 text-yellow-400 rounded hover:bg-yellow-500/30 transition-colors"
>
确认
</button>
<button
onClick={() => setConfirmToggle(null)}
className="px-2.5 py-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
>
取消
</button>
</div>
)}
{/* Expanded details */}
{isExpanded && (
<div className="px-4 pb-4 border-t border-[var(--dg-border)] pt-3">
<div className="space-y-3 text-sm">
{/* Description */}
{rule.description && (
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-blue-400 mt-0.5 shrink-0" />
<div>
<span className="text-xs text-[var(--dg-text-muted)] font-medium">规则说明</span>
<p className="text-[var(--dg-text-secondary)] mt-0.5">{rule.description}</p>
</div>
</div>
)}
{/* Message */}
<div className="flex items-start gap-2">
<MessageSquareIcon className="w-4 h-4 text-amber-400 mt-0.5 shrink-0" />
<div>
<span className="text-xs text-[var(--dg-text-muted)] font-medium">触发消息</span>
<p className="text-[var(--dg-text-secondary)] mt-0.5">{rule.message}</p>
</div>
</div>
{/* Pattern */}
{rule.pattern && (
<div className="flex items-start gap-2">
<FileSearch className="w-4 h-4 text-purple-400 mt-0.5 shrink-0" />
<div>
<span className="text-xs text-[var(--dg-text-muted)] font-medium">匹配模式</span>
<code className="block mt-0.5 text-xs font-mono bg-[var(--dg-bg-primary)] px-2 py-1 rounded text-purple-300">
{rule.pattern}
</code>
</div>
</div>
)}
{/* Metadata */}
<div className="flex items-center gap-4 pt-2 border-t border-[var(--dg-border-light)]">
<span className="text-xs text-[var(--dg-text-muted)]">
类型: <span className="text-[var(--dg-text-secondary)]">{rule.rule_type.toUpperCase()}</span>
</span>
<span className="text-xs text-[var(--dg-text-muted)]">
更新于: <span className="text-[var(--dg-text-secondary)]">{new Date(rule.updated_at).toLocaleDateString()}</span>
</span>
</div>
</div>
</div>
)}
</div>
);
};
const renderRuleGroup = (group: RuleGroup, groupRules: AuditRule[]) => {
if (groupRules.length === 0) return null;
const config = GROUP_CONFIG[group];
const GroupIcon = config.icon;
return (
<div className="space-y-2">
<h3 className={`text-sm font-semibold flex items-center gap-2 px-1 py-1 ${config.color} w-fit rounded-lg`}>
<GroupIcon className="w-4 h-4" />
<span>{config.label}</span>
<span className="text-xs opacity-70">({groupRules.length})</span>
</h3>
<div className="space-y-2">
{groupRules.map((rule) => renderRuleCard(rule))}
</div>
</div>
);
};
/* ---------------------------------------------------------------- */
/* Main render */
/* ---------------------------------------------------------------- */
return (
<div className="space-y-5">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-[var(--dg-text-primary)]">审核规则</h1>
<p className="text-sm text-[var(--dg-text-muted)] mt-1">按数据库类型管理 SQL 审核规则模版,支持搜索、筛选和排序</p>
</div>
<button
onClick={handleSeed}
disabled={seeding}
className="px-4 py-2 text-sm font-medium rounded-lg bg-[var(--dg-blue)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
>
{seeding ? (
<span className="flex items-center gap-1.5">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
初始化中...
</span>
) : (
'初始化规则'
)}
</button>
</div>
{/* DB Type Tabs */}
<div className="dg-card p-1.5 flex gap-1 flex-wrap">
{DB_TABS.map((tab) => {
const TabIcon = tab.icon;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors min-w-[100px] ${
activeTab === tab.key
? 'bg-[var(--dg-blue)] text-white'
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<TabIcon className="w-4 h-4" />
<span>{tab.label}</span>
</button>
);
})}
</div>
{/* Search & Filter Bar */}
<div className="dg-card p-3 flex flex-col sm:flex-row gap-3">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索规则名称、消息或描述..."
className="w-full pl-9 pr-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] focus:outline-none focus:border-blue-500 transition-colors"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
{/* Severity filter */}
<div className="flex items-center gap-1.5">
<Filter className="w-4 h-4 text-[var(--dg-text-muted)]" />
{([
{ value: 'all', label: '全部' },
{ value: 'error', label: '错误' },
{ value: 'warning', label: '警告' },
{ value: 'info', label: '提示' },
] as { value: SeverityFilter; label: string }[]).map((opt) => (
<button
key={opt.value}
onClick={() => setSeverityFilter(opt.value)}
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
severityFilter === opt.value
? 'bg-blue-500/15 text-blue-400'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{opt.label}
</button>
))}
</div>
{/* Sort */}
<div className="flex items-center gap-1.5">
<span className="text-xs text-[var(--dg-text-muted)]">排序:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'severity' | 'name' | 'type')}
className="px-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-md text-xs text-[var(--dg-text-primary)] focus:outline-none focus:border-blue-500"
>
<option value="severity">按严重程度</option>
<option value="name">按名称</option>
<option value="type">按类型</option>
</select>
</div>
</div>
{/* Results summary */}
{(searchQuery || severityFilter !== 'all') && (
<div className="text-xs text-[var(--dg-text-muted)] px-1 flex items-center gap-1.5">
<Info className="w-3.5 h-3.5" />
共 {filteredRules.length} 条规则
{searchQuery && <span>匹配 &quot;{searchQuery}&quot;</span>}
{severityFilter !== 'all' && <span>严重级别: {SEVERITY_CONFIG[severityFilter]?.label || severityFilter}</span>}
<button onClick={() => { setSearchQuery(''); setSeverityFilter('all'); }} className="text-blue-400 hover:underline ml-1">
清除筛选
</button>
</div>
)}
{/* Rule List */}
{loading ? (
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm flex flex-col items-center gap-3">
<Loader2 className="w-6 h-6 animate-spin" />
<span>加载中...</span>
</div>
) : rules.length === 0 ? (
<div className="dg-card p-8 text-center space-y-3">
<AlertTriangle className="w-10 h-10 text-[var(--dg-text-muted)] mx-auto opacity-50" />
<p className="text-[var(--dg-text-muted)] text-sm">
当前没有 {DB_TABS.find((t) => t.key === activeTab)?.label} 的审核规则
</p>
<button
onClick={handleSeed}
disabled={seeding}
className="px-4 py-2 text-sm rounded-lg border border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50 transition-colors"
>
点击初始化默认规则
</button>
</div>
) : filteredRules.length === 0 ? (
<div className="dg-card p-8 text-center space-y-3">
<Search className="w-10 h-10 text-[var(--dg-text-muted)] mx-auto opacity-50" />
<p className="text-[var(--dg-text-muted)] text-sm">没有匹配的规则</p>
<button onClick={() => { setSearchQuery(''); setSeverityFilter('all'); }} className="text-sm text-blue-400 hover:underline">
清除筛选条件
</button>
</div>
) : (
<div className="space-y-6">
{renderRuleGroup('DDL', groupedRules.DDL)}
{renderRuleGroup('DML', groupedRules.DML)}
{renderRuleGroup('security', groupedRules.security)}
{renderRuleGroup('DCL', groupedRules.DCL)}
{renderRuleGroup('DQL', groupedRules.DQL)}
{renderRuleGroup('performance', groupedRules.performance)}
</div>
)}
</div>
);
}
/* Small icon component */
function MessageSquareIcon(props: React.SVGProps<SVGSVGElement> & { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);
}