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零错误
This commit is contained in:
1 parent
9f56e866f2
commit
4ac2528f8e
19 files changed
+1202
-325
No files matched your search
@@ -21,7 +21,7 @@ import MongoDBDetailPage from '@/pages/MongoDBDetailPage';
|
||||
import TicketCreatePage from '@/pages/TicketCreatePage';
|
||||
import TicketDetailPage from '@/pages/TicketDetailPage';
|
||||
import InstanceGroupPage from '@/pages/InstanceGroupPage';
|
||||
import SlowQueryPage from '@/pages/SlowQueryPage';
|
||||
// import SlowQueryPage from '@/pages/SlowQueryPage';
|
||||
|
||||
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
|
||||
function RouteGuard() {
|
||||
@@ -67,7 +67,6 @@ function AppRoutes() {
|
||||
<Route path="tickets/create" element={<TicketCreatePage />} />
|
||||
<Route path="tickets/:id" element={<TicketDetailPage />} />
|
||||
<Route path="queries" element={<QueriesPage />} />
|
||||
<Route path="slow-query" element={<SlowQueryPage />} />
|
||||
<Route path="roles" element={<RolesPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="audit-rules" element={<AuditRulesPage />} />
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import request from './request';
|
||||
|
||||
export interface DBTypeOverview {
|
||||
type: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
category: string;
|
||||
version: string;
|
||||
supported_operations: number;
|
||||
security_rules_count: number;
|
||||
}
|
||||
|
||||
export interface DBTypeQueryType {
|
||||
type: string;
|
||||
description: string;
|
||||
example: string;
|
||||
}
|
||||
|
||||
export interface DBTypeDetail {
|
||||
type: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
version: string;
|
||||
category: string;
|
||||
supported_query_types: DBTypeQueryType[];
|
||||
security_rules: string[];
|
||||
tips: string[];
|
||||
common_commands: Record<string, string>;
|
||||
limitations: string[];
|
||||
}
|
||||
|
||||
export interface DBTypeCommand {
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export const dbTypesApi = {
|
||||
list: () =>
|
||||
request.get<any, { items: DBTypeOverview[]; total: number }>('/db-types'),
|
||||
|
||||
getDetail: (dbType: string) =>
|
||||
request.get<any, DBTypeDetail>('/db-types/' + dbType),
|
||||
|
||||
getCommands: (dbType: string) =>
|
||||
request.get<any, { items: DBTypeCommand[]; total: number }>('/db-types/' + dbType + '/commands'),
|
||||
|
||||
getTips: (dbType: string) =>
|
||||
request.get<any, { items: string[]; total: number }>('/db-types/' + dbType + '/tips'),
|
||||
};
|
||||
@@ -3,4 +3,13 @@ import request from './request';
|
||||
export const queryApi = {
|
||||
execute: (data: { instance_id: number; database?: string; sql: string }) =>
|
||||
request.post<any, any>('/query', data),
|
||||
|
||||
formatSQL: (sql: string) =>
|
||||
request.post<any, any>('/sql-assistant/format', { sql }),
|
||||
|
||||
analyzeSQL: (sql: string, db_type: string) =>
|
||||
request.post<any, any>('/sql-assistant/analyze', { sql, db_type }),
|
||||
|
||||
getDBTypeDetail: (dbType: string) =>
|
||||
request.get<any, any>('/db-types/' + dbType),
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { X, Loader2 } from 'lucide-react';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
interface InstanceData {
|
||||
id: number;
|
||||
name: string;
|
||||
db_type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
database: string;
|
||||
env: string;
|
||||
access_mode: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
instance: InstanceData;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reusable Edit Instance Dialog */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export default function EditInstanceDialog({ instance, open, onClose, onSaved }: Props) {
|
||||
const [name, setName] = useState(instance.name);
|
||||
const [host, setHost] = useState(instance.host);
|
||||
const [port, setPort] = useState(String(instance.port));
|
||||
const [username, setUsername] = useState(instance.username);
|
||||
const [password, setPassword] = useState('');
|
||||
const [database, setDatabase] = useState(instance.database || '');
|
||||
const [env, setEnv] = useState(instance.env || 'development');
|
||||
const [accessMode, setAccessMode] = useState(instance.access_mode || 'readwrite');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
/* Sync when instance changes */
|
||||
useEffect(() => {
|
||||
setName(instance.name);
|
||||
setHost(instance.host);
|
||||
setPort(String(instance.port));
|
||||
setUsername(instance.username);
|
||||
setPassword('');
|
||||
setDatabase(instance.database || '');
|
||||
setEnv(instance.env || 'development');
|
||||
setAccessMode(instance.access_mode || 'readwrite');
|
||||
setError('');
|
||||
}, [instance]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!name.trim()) { setError('请填写名称'); return; }
|
||||
if (!host.trim()) { setError('请填写地址'); return; }
|
||||
setSubmitting(true); setError('');
|
||||
try {
|
||||
await instanceApi.update(instance.id, {
|
||||
name: name.trim(),
|
||||
host: host.trim(),
|
||||
port: Number(port),
|
||||
username: username.trim() || undefined,
|
||||
password: password || undefined,
|
||||
database: database.trim() || undefined,
|
||||
env,
|
||||
access_mode: accessMode,
|
||||
});
|
||||
onSaved();
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [name, host, port, username, password, database, env, accessMode, instance.id, onSaved]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const inputCls = 'w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors';
|
||||
const labelCls = 'block text-xs font-medium text-[var(--dg-text-secondary)] mb-1';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div
|
||||
className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-lg max-h-[85vh] overflow-y-auto"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<h2 className="text-base font-semibold">编辑数据源</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 pb-5 space-y-4">
|
||||
{error && (
|
||||
<div className="px-3 py-2 text-sm text-red-400 bg-red-500/10 rounded-lg">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className={labelCls}>名称</label>
|
||||
<input className={inputCls} value={name} onChange={e => setName(e.target.value)} placeholder="实例名称" />
|
||||
</div>
|
||||
|
||||
{/* Host + Port */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className={labelCls}>地址</label>
|
||||
<input className={inputCls} value={host} onChange={e => setHost(e.target.value)} placeholder="127.0.0.1" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>端口</label>
|
||||
<input className={inputCls} value={port} onChange={e => setPort(e.target.value)} placeholder="3306" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Username + Password */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>用户名</label>
|
||||
<input className={inputCls} value={username} onChange={e => setUsername(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>密码(留空不修改)</label>
|
||||
<input className={inputCls} type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="••••••" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database */}
|
||||
<div>
|
||||
<label className={labelCls}>默认数据库</label>
|
||||
<input className={inputCls} value={database} onChange={e => setDatabase(e.target.value)} placeholder="可选" />
|
||||
</div>
|
||||
|
||||
{/* Env + Access Mode */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>环境</label>
|
||||
<select className={inputCls} value={env} onChange={e => setEnv(e.target.value)}>
|
||||
<option value="development">开发</option>
|
||||
<option value="testing">测试</option>
|
||||
<option value="staging">预发</option>
|
||||
<option value="production">生产</option>
|
||||
<option value="uat">UAT</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>访问模式</label>
|
||||
<select className={inputCls} value={accessMode} onChange={e => setAccessMode(e.target.value)}>
|
||||
<option value="readwrite">读写</option>
|
||||
<option value="read">只读</option>
|
||||
<option value="write">只写</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-5 py-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,8 +25,7 @@ export const menuItems = [
|
||||
{ id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] },
|
||||
{ id: 'instance-groups', label: '权限管理', icon: 'Lock', section: 'permission', roles: ['admin'] },
|
||||
{ id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer'] },
|
||||
{ id: 'queries', label: '查询审计', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] },
|
||||
{ id: 'slow-query', label: '慢查询分析', icon: 'Timer', section: 'ticket', roles: ['admin', 'dba'] },
|
||||
{ id: 'queries', label: '查询数据', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] },
|
||||
{ id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] },
|
||||
{ id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] },
|
||||
{ id: 'audit-logs', label: '审计日志', icon: 'FileText', section: 'system', roles: ['admin', 'dba'] },
|
||||
@@ -192,7 +191,7 @@ export const permissionTreeData: PermissionNode[] = [
|
||||
{ id: 'ticket_execute', label: '执行工单' },
|
||||
{ id: 'ticket_rollback', label: '回滚工单' },
|
||||
]},
|
||||
{ id: 'queries', label: '查询审计(查看日志、新建查询、导出)', children: [
|
||||
{ id: 'queries', label: '查询数据(查看日志、新建查询、导出)', children: [
|
||||
{ id: 'query_create', label: '执行查询' },
|
||||
{ id: 'query_export', label: '导出结果' },
|
||||
{ id: 'query_view_all', label: '查看全部用户查询' },
|
||||
|
||||
@@ -493,9 +493,9 @@ export default function AuditLogsPage() {
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">
|
||||
{log.username.charAt(0).toUpperCase()}
|
||||
{(log.username || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-[var(--dg-text-primary)]">{log.username}</span>
|
||||
<span className="text-[var(--dg-text-primary)]">{log.username || '-'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@@ -26,7 +26,7 @@ interface AuditRule {
|
||||
}
|
||||
|
||||
type DbType = 'mysql' | 'postgresql' | 'redis' | 'mongodb';
|
||||
type RuleGroup = 'DDL' | 'DML' | 'DCL' | 'DQL';
|
||||
type RuleGroup = 'DDL' | 'DML' | 'DCL' | 'DQL' | 'security' | 'performance';
|
||||
type SeverityFilter = 'all' | 'error' | 'warning' | 'info';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -51,6 +51,8 @@ const GROUP_CONFIG: Record<RuleGroup, { label: string; icon: React.ElementType;
|
||||
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 }> = {
|
||||
@@ -59,34 +61,17 @@ const SEVERITY_CONFIG: Record<string, { label: string; color: string; dot: strin
|
||||
info: { label: '提示', color: 'text-blue-400', dot: 'bg-blue-400' },
|
||||
};
|
||||
|
||||
const DDL_KEYWORDS = [
|
||||
'DDL', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME',
|
||||
'TABLE', 'INDEX', 'KEY', 'COLUMN', 'PARTITION', 'VIEW',
|
||||
'DATABASE', 'SCHEMA', 'SEQUENCE', '集合', 'COLLECTION',
|
||||
' Validator', 'CAPPED', 'SCHEMA',
|
||||
];
|
||||
|
||||
const DML_KEYWORDS = ['DML', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'MERGE', 'BULK', '写入', 'UNSET'];
|
||||
|
||||
const DCL_KEYWORDS = ['DCL', 'GRANT', 'REVOKE', 'DENY'];
|
||||
|
||||
const DQL_KEYWORDS = ['DQL', 'SELECT', 'EXPLAIN', 'DESCRIBE', 'SHOW', 'FIND', '查询', '聚合', 'AGGREGAT', 'PROJECTION', 'FILTER', 'REGEX', '$WHERE'];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getRuleGroup(name: string): RuleGroup {
|
||||
const upper = name.toUpperCase();
|
||||
if (DDL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DDL';
|
||||
if (DML_KEYWORDS.some((kw) => upper.includes(kw))) return 'DML';
|
||||
if (DCL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DCL';
|
||||
if (DQL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DQL';
|
||||
// MongoDB security rules -> DCL, performance rules -> DQL
|
||||
if (upper.includes('SECURITY') || upper.includes('安全') || upper.includes('审计') || upper.includes('注入') || upper.includes('EVAL') || upper.includes('MAPSQL_REDUCE')) return 'DCL';
|
||||
if (upper.includes('PERFORMANCE') || upper.includes('性能') || upper.includes('索引') || upper.includes('扫描') || upper.includes('分片') || upper.includes('SHARD')) return 'DQL';
|
||||
// Default to DDL for structural rules, DML for data rules
|
||||
if (upper.includes('WHERE') || upper.includes('写入') || upper.includes('UPDATE') || upper.includes('DELETE')) return 'DML';
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -99,7 +84,7 @@ function sortRules(rules: AuditRule[], sortBy: 'severity' | 'name' | 'type'): Au
|
||||
case 'name':
|
||||
return sorted.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
||||
case 'type':
|
||||
return sorted.sort((a, b) => getRuleGroup(a.name).localeCompare(getRuleGroup(b.name)));
|
||||
return sorted.sort((a, b) => getRuleGroup(a).localeCompare(getRuleGroup(b)));
|
||||
default:
|
||||
return sorted;
|
||||
}
|
||||
@@ -239,9 +224,11 @@ export default function AuditRulesPage() {
|
||||
DML: [],
|
||||
DCL: [],
|
||||
DQL: [],
|
||||
security: [],
|
||||
performance: [],
|
||||
};
|
||||
for (const rule of filteredRules) {
|
||||
const group = getRuleGroup(rule.name);
|
||||
const group = getRuleGroup(rule);
|
||||
groups[group].push(rule);
|
||||
}
|
||||
return groups;
|
||||
@@ -580,8 +567,10 @@ export default function AuditRulesPage() {
|
||||
<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>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useActionPermission } from '@/hooks/useAuth';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import EditInstanceDialog from '@/components/EditInstanceDialog';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
@@ -41,11 +42,14 @@ type ViewMode = 'grid' | 'list';
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* DB Brand Icons */
|
||||
|
||||
const dbTypeConfig: Record<string, { icon: React.ElementType; color: string; bg: string; label: string }> = {
|
||||
mysql: { icon: Database, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]', label: 'MySQL' },
|
||||
postgres: { icon: Server, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' },
|
||||
redis: { icon: Zap, color: 'text-[#ef5350]', bg: 'bg-[rgba(220,50,32,0.15)]', label: 'Redis' },
|
||||
mongo: { icon: Leaf, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]', label: 'MongoDB' },
|
||||
mysql: { icon: MySQLIcon, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]', label: 'MySQL' },
|
||||
postgres: { icon: PostgreSQLIcon, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' },
|
||||
redis: { icon: RedisIcon, color: 'text-[#ef5350]', bg: 'bg-[rgba(220,50,32,0.15)]', label: 'Redis' },
|
||||
mongo: { icon: MongoDBIcon, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]', label: 'MongoDB' },
|
||||
};
|
||||
|
||||
const envLabel: Record<string, string> = {
|
||||
@@ -132,6 +136,8 @@ export default function InstancePage() {
|
||||
const [instances, setInstances] = useState<InstanceItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [editingInstance, setEditingInstance] = useState<InstanceItem | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||
|
||||
@@ -242,6 +248,28 @@ export default function InstancePage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (showEdit && editingInstance) {
|
||||
return (
|
||||
<>
|
||||
<InstanceDetail
|
||||
instance={editingInstance}
|
||||
onBack={() => { setView('grid'); setShowEdit(false); setEditingInstance(null); }}
|
||||
config={dbTypeConfig[editingInstance.db_type] || dbTypeConfig.mysql}
|
||||
canEdit={canEdit}
|
||||
canDelete={canDelete}
|
||||
onDelete={() => { handleDelete(editingInstance.id); setView('grid'); setShowEdit(false); setEditingInstance(null); }}
|
||||
onEdit={() => {}}
|
||||
/>
|
||||
<EditInstanceDialog
|
||||
instance={editingInstance}
|
||||
open={showEdit}
|
||||
onClose={() => { setShowEdit(false); setEditingInstance(null); }}
|
||||
onSaved={() => { setShowEdit(false); setEditingInstance(null); fetchInstances(); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedConfig = selectedInstance
|
||||
? (dbTypeConfig[selectedInstance.db_type] || dbTypeConfig.mysql)
|
||||
: dbTypeConfig.mysql;
|
||||
@@ -255,6 +283,7 @@ export default function InstancePage() {
|
||||
canEdit={canEdit}
|
||||
canDelete={canDelete}
|
||||
onDelete={() => { handleDelete(selectedInstance.id); setView('grid'); }}
|
||||
onEdit={() => { setEditingInstance(selectedInstance); setShowEdit(true); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -428,7 +457,7 @@ export default function InstancePage() {
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState type="search" />
|
||||
) : layoutMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{filtered.map(instance => {
|
||||
const ic = dbTypeConfig[instance.db_type] || dbTypeConfig.mysql;
|
||||
const IconComp = ic.icon;
|
||||
@@ -438,7 +467,7 @@ export default function InstancePage() {
|
||||
return (
|
||||
<div
|
||||
key={instance.id}
|
||||
className={`dg-card p-5 cursor-pointer dg-card-hover group relative transition-all ${
|
||||
className={`dg-card p-3.5 cursor-pointer dg-card-hover group relative transition-all ${
|
||||
isSelected ? 'ring-2 ring-blue-500/50' : ''
|
||||
}`}
|
||||
onClick={() => { setSelectedInstance(instance); setView('detail'); }}
|
||||
@@ -456,8 +485,8 @@ export default function InstancePage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between mb-4 pl-6">
|
||||
<div className={`w-11 h-11 rounded-xl ${ic.bg} flex items-center justify-center`}>
|
||||
<IconComp className={`w-6 h-6 ${ic.color}`} />
|
||||
<div className={`w-9 h-9 rounded-lg ${ic.bg} flex items-center justify-center`}>
|
||||
<IconComp className="w-5 h-5 object-contain" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-md border font-medium ${envColor[instance.env] || 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border-[var(--dg-border)]'}`}>
|
||||
@@ -472,14 +501,14 @@ export default function InstancePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-base font-semibold mb-1 truncate">{instance.name}</h3>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-1">{instance.host}:{instance.port}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3">
|
||||
<h3 className="text-sm font-semibold mb-0.5 truncate">{instance.name}</h3>
|
||||
<p className="text-[11px] text-[var(--dg-text-muted)] mb-0.5">{instance.host}:{instance.port}</p>
|
||||
<p className="text-[11px] text-[var(--dg-text-muted)] mb-2">
|
||||
{ic.label} {instance.version || ''}
|
||||
</p>
|
||||
|
||||
{/* Health Score */}
|
||||
<div className="flex items-center gap-2 mb-3 p-2 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="flex items-center gap-2 mb-2 p-1.5 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
||||
<Gauge className={`w-4 h-4 ${healthColor(health)}`} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -495,35 +524,33 @@ export default function InstancePage() {
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="p-1.5 rounded-md bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="grid grid-cols-3 gap-1.5 text-center">
|
||||
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Users className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-xs font-semibold">{instance.connections}</span>
|
||||
<span className="text-[11px] font-semibold">{instance.connections}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">连接</span>
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)]">连接</span>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-md bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Activity className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-xs font-semibold">{instance.qps ? instance.qps.toLocaleString() : '-'}</span>
|
||||
<span className="text-[11px] font-semibold">{instance.qps ? instance.qps.toLocaleString() : '-'}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">QPS</span>
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)]">QPS</span>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-md bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Signal className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-xs font-semibold">{instance.latency}ms</span>
|
||||
<span className="text-[11px] font-semibold">{instance.latency}ms</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">延迟</span>
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)]">延迟</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{instance.description && (
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] line-clamp-2 mt-3">{instance.description}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
<div className="mt-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-[11px] text-blue-400 flex items-center gap-1">
|
||||
查看详情
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
@@ -584,7 +611,7 @@ export default function InstancePage() {
|
||||
<td className="px-4 py-3 font-medium">{instance.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`flex items-center gap-1.5 ${ic.color}`}>
|
||||
<IconComp className="w-4 h-4" />
|
||||
<IconComp className="w-4 h-4 object-contain" />
|
||||
<span className="text-xs">{ic.label}</span>
|
||||
</span>
|
||||
</td>
|
||||
@@ -633,10 +660,10 @@ export default function InstancePage() {
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* InstanceDetail */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete }: {
|
||||
function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete, onEdit }: {
|
||||
instance: InstanceItem; onBack: () => void;
|
||||
config: { icon: React.ElementType; color: string; bg: string };
|
||||
canEdit: boolean; canDelete: boolean; onDelete: () => void;
|
||||
canEdit: boolean; canDelete: boolean; onDelete: () => void; onEdit: () => void;
|
||||
}) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null);
|
||||
@@ -694,7 +721,7 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
|
||||
{testing ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
|
||||
<button onClick={onEdit} className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
编辑
|
||||
</button>
|
||||
@@ -845,8 +872,7 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* DB Brand Icons */
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
function MySQLIcon({ className }: { className?: string }) {
|
||||
return <img src="/db-logos/mysql.svg" alt="MySQL" className={className} />;
|
||||
|
||||
@@ -170,7 +170,7 @@ export default function QueriesPage() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="查询审计"
|
||||
title="查询数据"
|
||||
description="在线查询工具,支持 MySQL / PostgreSQL / Redis / MongoDB 多类型数据源"
|
||||
action={canCreate ? { label: '新建查询', icon: Plus, onClick: () => setShowNewQuery(true), permission: 'query.create' } : undefined}
|
||||
/>
|
||||
|
||||
@@ -32,7 +32,7 @@ const PERMISSION_PRESETS: PermissionPreset[] = [
|
||||
{
|
||||
key: 'dba',
|
||||
name: 'DBA 模板',
|
||||
description: '数据库管理员:完整的数据源管理、工单审批、查询审计权限',
|
||||
description: '数据库管理员:完整的数据源管理、工单审批、查询数据权限',
|
||||
icon: Database,
|
||||
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
|
||||
},
|
||||
|
||||
@@ -216,19 +216,6 @@ export default function TicketDetailPage() {
|
||||
/* Parse execute result for visualization */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const executeStats = useMemo(() => {
|
||||
if (!ticket?.execute_result) return null;
|
||||
// Try to extract affected rows and duration from execute result
|
||||
const result = ticket.execute_result;
|
||||
const affectedMatch = result.match(/(\d+)\s*(?:row|rows)?\s*(?:affected|changed)/i);
|
||||
const durationMatch = result.match(/(\d+(?:\.\d+)?)\s*(?:ms|seconds?|s)/i);
|
||||
return {
|
||||
affectedRows: ticket.affected_rows || (affectedMatch ? parseInt(affectedMatch[1], 10) : undefined),
|
||||
durationMs: ticket.execute_duration_ms || (durationMatch ? parseFloat(durationMatch[1]) : undefined),
|
||||
raw: result,
|
||||
};
|
||||
}, [ticket]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Loading & Error states */
|
||||
/* ---------------------------------------------------------------- */
|
||||
@@ -442,39 +429,6 @@ export default function TicketDetailPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const renderExecuteStats = () => {
|
||||
if (!executeStats) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
{executeStats.affectedRows !== undefined && (
|
||||
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)] mb-1">
|
||||
<BarChart3 className="w-3.5 h-3.5" />
|
||||
影响行数
|
||||
</div>
|
||||
<div className="text-xl font-semibold text-[var(--dg-text-primary)]">
|
||||
{executeStats.affectedRows.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{executeStats.durationMs !== undefined && (
|
||||
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)] mb-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
执行耗时
|
||||
</div>
|
||||
<div className="text-xl font-semibold text-[var(--dg-text-primary)]">
|
||||
{executeStats.durationMs >= 1000
|
||||
? `${(executeStats.durationMs / 1000).toFixed(2)}s`
|
||||
: `${Math.round(executeStats.durationMs)}ms`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRelatedTickets = () => {
|
||||
if (relatedTickets.length === 0) return null;
|
||||
|
||||
@@ -624,22 +578,90 @@ export default function TicketDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Audit result */}
|
||||
{ticket.audit_result && (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<button
|
||||
onClick={() => setShowFullAudit(!showFullAudit)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
|
||||
</h3>
|
||||
{showFullAudit ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||
</button>
|
||||
<div className={`mt-3 text-sm text-[var(--dg-text-secondary)] ${showFullAudit ? '' : 'line-clamp-3'}`}>
|
||||
{ticket.audit_result}
|
||||
{ticket.audit_result && (() => {
|
||||
let audit: any = null;
|
||||
try { audit = typeof ticket.audit_result === 'string' ? JSON.parse(ticket.audit_result) : ticket.audit_result; } catch { audit = null; }
|
||||
|
||||
if (!audit || !audit.findings) {
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-[var(--dg-text-secondary)]">{ticket.audit_result}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const errorCount = audit.findings.filter((f: any) => f.level === 'error').length;
|
||||
const warningCount = audit.findings.filter((f: any) => f.level === 'warning').length;
|
||||
const infoCount = audit.findings.filter((f: any) => f.level === 'info').length;
|
||||
const scoreColor = audit.score >= 90 ? 'text-green-400' : audit.score >= 60 ? 'text-amber-400' : 'text-red-400';
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<button
|
||||
onClick={() => setShowFullAudit(!showFullAudit)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-blue-400" /> SQL 审核结果
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xl font-bold ${scoreColor}`}>{audit.score ?? '-'}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">分</span>
|
||||
{showFullAudit ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Summary badges */}
|
||||
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
||||
{audit.passed ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium bg-green-500/15 text-green-400">
|
||||
<CheckCircle className="w-3 h-3" /> 审核通过
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium bg-red-500/15 text-red-400">
|
||||
<XCircle className="w-3 h-3" /> 存在阻断项
|
||||
</span>
|
||||
)}
|
||||
{errorCount > 0 && <span className="px-2 py-0.5 rounded text-xs bg-red-500/15 text-red-400">{errorCount} 错误</span>}
|
||||
{warningCount > 0 && <span className="px-2 py-0.5 rounded text-xs bg-amber-500/15 text-amber-400">{warningCount} 警告</span>}
|
||||
{infoCount > 0 && <span className="px-2 py-0.5 rounded text-xs bg-blue-500/15 text-blue-400">{infoCount} 提示</span>}
|
||||
</div>
|
||||
|
||||
{/* Findings table */}
|
||||
{showFullAudit && audit.findings.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{audit.findings.map((f: any, idx: number) => {
|
||||
const levelConfig: Record<string, { label: string; color: string; icon: any }> = {
|
||||
error: { label: '错误', color: 'text-red-400 bg-red-500/15', icon: XCircle },
|
||||
warning: { label: '警告', color: 'text-amber-400 bg-amber-500/15', icon: AlertTriangle },
|
||||
info: { label: '提示', color: 'text-blue-400 bg-blue-500/15', icon: CheckCircle },
|
||||
};
|
||||
const lc = levelConfig[f.level] || levelConfig.info;
|
||||
const LevelIcon = lc.icon;
|
||||
return (
|
||||
<div key={idx} className={`p-3 rounded-lg border border-[var(--dg-border)] ${f.level === 'error' ? 'border-red-500/20' : ''}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<LevelIcon className={`w-3.5 h-3.5 ${lc.color.split(' ')[0]}`} />
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${lc.color}`}>{lc.label}</span>
|
||||
{f.rule && <span className="text-xs text-[var(--dg-text-muted)] font-mono">{f.rule}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--dg-text-primary)] pl-6">{f.message}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary text */}
|
||||
{!showFullAudit && audit.summary && (
|
||||
<p className="mt-2 text-xs text-[var(--dg-text-muted)]">{audit.summary}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Rollback SQL */}
|
||||
{ticket.rollback_sql && (
|
||||
@@ -658,23 +680,112 @@ export default function TicketDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Execute result */}
|
||||
{ticket.execute_result && (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
|
||||
<Play className="w-4 h-4 text-purple-400" /> 执行结果
|
||||
</h3>
|
||||
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto">
|
||||
{ticket.execute_result}
|
||||
</pre>
|
||||
{renderExecuteStats()}
|
||||
{ticket.execute_time && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-2 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
||||
{ticket.execute_result && (() => {
|
||||
let execResult: any = null;
|
||||
try { execResult = typeof ticket.execute_result === 'string' ? JSON.parse(ticket.execute_result) : ticket.execute_result; } catch { execResult = null; }
|
||||
|
||||
if (!execResult || typeof execResult !== 'object') {
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
|
||||
<Play className="w-4 h-4 text-purple-400" /> 执行结果
|
||||
</h3>
|
||||
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto">
|
||||
{ticket.execute_result}
|
||||
</pre>
|
||||
{ticket.execute_time && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-2 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
const isSuccess = execResult.status === 'success';
|
||||
const StatusIcon = isSuccess ? CheckCircle : XCircle;
|
||||
const statusColor = isSuccess ? 'text-green-400' : 'text-red-400';
|
||||
const statusBg = isSuccess ? 'bg-green-500/15' : 'bg-red-500/15';
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
|
||||
<Play className="w-4 h-4 text-purple-400" /> 执行结果
|
||||
</h3>
|
||||
|
||||
{/* Status badge + key metrics */}
|
||||
<div className="flex items-center gap-3 flex-wrap mb-3">
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium ${statusBg} ${statusColor}`}>
|
||||
<StatusIcon className="w-3.5 h-3.5" />
|
||||
{isSuccess ? '执行成功' : '执行失败'}
|
||||
</span>
|
||||
{execResult.affected_rows !== undefined && (
|
||||
<span className="px-2.5 py-1 rounded text-xs bg-[var(--dg-bg-tertiary)]">
|
||||
影响行数: <span className="font-medium">{execResult.affected_rows}</span>
|
||||
</span>
|
||||
)}
|
||||
{execResult.duration_ms !== undefined && (
|
||||
<span className="px-2.5 py-1 rounded text-xs bg-[var(--dg-bg-tertiary)]">
|
||||
耗时: <span className="font-medium">{execResult.duration_ms}ms</span>
|
||||
</span>
|
||||
)}
|
||||
{execResult.validation_passed !== undefined && (
|
||||
<span className={`px-2.5 py-1 rounded text-xs ${execResult.validation_passed ? 'text-green-400 bg-green-500/10' : 'text-amber-400 bg-amber-500/10'}`}>
|
||||
{execResult.validation_passed ? '✓ 验证通过' : '✗ 验证未通过'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{execResult.errors && execResult.errors.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
{execResult.errors.map((err: string, idx: number) => (
|
||||
<div key={idx} className="p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<XCircle className="w-3.5 h-3.5 text-red-400" />
|
||||
<span className="text-xs font-medium text-red-400">错误 {execResult.errors.length > 1 ? `#${idx + 1}` : ''}</span>
|
||||
</div>
|
||||
<p className="text-sm text-red-300 pl-5">{err}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{execResult.warnings && execResult.warnings.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
{execResult.warnings.map((w: string, idx: number) => (
|
||||
<div key={idx} className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span className="text-xs font-medium text-amber-400">警告 {execResult.warnings.length > 1 ? `#${idx + 1}` : ''}</span>
|
||||
</div>
|
||||
<p className="text-sm text-amber-300 pl-5">{w}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw output (if present) */}
|
||||
{execResult.raw_output && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-1">原始输出</p>
|
||||
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto border border-[var(--dg-border-light)]">
|
||||
{execResult.raw_output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticket.execute_time && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-3 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Timeline */}
|
||||
{renderTimeline()}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Plus, Search, Eye, CheckCircle, XCircle, Play, RotateCcw,
|
||||
FileCode, History, Undo2, XCircle as XIcon,
|
||||
Clock, AlertTriangle, AlertCircle, Info, ChevronLeft,
|
||||
Plus, Search, Eye, CheckCircle, XCircle, Play,
|
||||
FileCode, History, XCircle as XIcon,
|
||||
Clock, AlertTriangle, ChevronLeft,
|
||||
Filter, X, Loader2, Users, Calendar,
|
||||
CheckSquare, Square, ArrowUpDown, FileDown, Ban,
|
||||
} from 'lucide-react';
|
||||
@@ -24,9 +24,7 @@ interface TicketItem {
|
||||
instance_id: number;
|
||||
target_database: string;
|
||||
sql_content: string;
|
||||
rollback_sql: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
applicant_id: number;
|
||||
applicant_name: string | null;
|
||||
approver_id: number | null;
|
||||
@@ -62,11 +60,7 @@ type DateRange = 'all' | 'today' | 'week' | 'month';
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const priorityConfig: Record<string, { label: string; color: string; bg: string; border: string; icon: React.ElementType }> = {
|
||||
high: { label: '高', color: 'text-red-400', bg: 'bg-red-500/15', border: 'border-red-500/30', icon: AlertCircle },
|
||||
medium: { label: '中', color: 'text-yellow-400', bg: 'bg-yellow-500/15', border: 'border-yellow-500/30', icon: AlertTriangle },
|
||||
low: { label: '低', color: 'text-blue-400', bg: 'bg-blue-500/15', border: 'border-blue-500/30', icon: Info },
|
||||
};
|
||||
|
||||
|
||||
const statusFlow = ['pending', 'reviewing', 'approved', 'executing', 'success'];
|
||||
|
||||
@@ -74,14 +68,13 @@ const statusFlow = ['pending', 'reviewing', 'approved', 'executing', 'success'];
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function exportTicketsToCSV(tickets: TicketItem[]): string {
|
||||
const columns = ['工单号', '标题', '数据库类型', '目标数据库', '申请人', '优先级', '状态', '创建时间', '变更原因'];
|
||||
const columns = ['工单号', '标题', '数据库类型', '目标数据库', '申请人', '状态', '创建时间', '变更原因'];
|
||||
const rows = tickets.map(t => ({
|
||||
工单号: `TK-${t.id}`,
|
||||
标题: t.title,
|
||||
数据库类型: t.db_type.toUpperCase(),
|
||||
目标数据库: t.target_database,
|
||||
申请人: t.applicant_name || '-',
|
||||
优先级: t.priority === 'high' ? '高' : t.priority === 'medium' ? '中' : '低',
|
||||
状态: statusMap[t.status] || t.status,
|
||||
创建时间: new Date(t.create_time).toLocaleString(),
|
||||
变更原因: t.reason || '-',
|
||||
@@ -144,8 +137,6 @@ function TableRowSkeleton() {
|
||||
<td className="px-5 py-3.5"><div className="h-4 w-32 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
||||
<td className="px-5 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
||||
<td className="px-5 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
||||
<td className="px-5 py-3.5"><div className="h-4 w-10 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
||||
<td className="px-5 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
||||
<td className="px-5 py-3.5"><div className="h-4 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
||||
<td className="px-5 py-3.5"><div className="h-4 w-12 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
||||
</tr>
|
||||
@@ -163,7 +154,6 @@ export default function TicketsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [dbTypeFilter, setDbTypeFilter] = useState('');
|
||||
const [priorityFilter, setPriorityFilter] = useState('');
|
||||
const [dateRange, setDateRange] = useState<DateRange>('all');
|
||||
const [applicantFilter, setApplicantFilter] = useState('');
|
||||
const [selectedTicket, setSelectedTicket] = useState<TicketItem | null>(null);
|
||||
@@ -177,7 +167,7 @@ export default function TicketsPage() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [batchApproving, setBatchApproving] = useState(false);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [sortField, setSortField] = useState<'create_time' | 'priority'>('create_time');
|
||||
const [sortField, setSortField] = useState<'create_time'>('create_time');
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
|
||||
const fetchTickets = useCallback(async () => {
|
||||
@@ -223,11 +213,6 @@ export default function TicketsPage() {
|
||||
}
|
||||
/* Client-side sort */
|
||||
result.sort((a, b) => {
|
||||
if (sortField === 'priority') {
|
||||
const order = { high: 0, medium: 1, low: 2 };
|
||||
const cmp = (order[a.priority as keyof typeof order] ?? 3) - (order[b.priority as keyof typeof order] ?? 3);
|
||||
return sortAsc ? cmp : -cmp;
|
||||
}
|
||||
return sortAsc
|
||||
? new Date(a.create_time).getTime() - new Date(b.create_time).getTime()
|
||||
: new Date(b.create_time).getTime() - new Date(a.create_time).getTime();
|
||||
@@ -310,7 +295,7 @@ export default function TicketsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasActiveFilters = search || statusFilter || dbTypeFilter || priorityFilter || dateRange !== 'all' || applicantFilter;
|
||||
const hasActiveFilters = search || statusFilter || dbTypeFilter || dateRange !== 'all' || applicantFilter;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -401,16 +386,7 @@ export default function TicketsPage() {
|
||||
<option value="postgres">PostgreSQL</option>
|
||||
<option value="redis">Redis</option>
|
||||
</select>
|
||||
<select
|
||||
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors"
|
||||
value={priorityFilter}
|
||||
onChange={e => { setPriorityFilter(e.target.value); setPage(1); }}
|
||||
>
|
||||
<option value="">全部优先级</option>
|
||||
<option value="high">高</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="low">低</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors"
|
||||
value={dateRange}
|
||||
@@ -446,12 +422,7 @@ export default function TicketsPage() {
|
||||
<button onClick={() => setDbTypeFilter('')}><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
)}
|
||||
{priorityFilter && (
|
||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md bg-blue-500/10 text-blue-400">
|
||||
优先级: {priorityConfig[priorityFilter]?.label || priorityFilter}
|
||||
<button onClick={() => setPriorityFilter('')}><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{dateRange !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md bg-blue-500/10 text-blue-400">
|
||||
<Calendar className="w-3 h-3" />
|
||||
@@ -460,7 +431,7 @@ export default function TicketsPage() {
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setSearch(''); setStatusFilter(''); setDbTypeFilter(''); setPriorityFilter(''); setDateRange('all'); setApplicantFilter(''); setPage(1); }}
|
||||
onClick={() => { setSearch(''); setStatusFilter(''); setDbTypeFilter(''); setDateRange('all'); setApplicantFilter(''); setPage(1); }}
|
||||
className="text-xs text-red-400 hover:text-red-300 ml-auto"
|
||||
>
|
||||
清除全部
|
||||
@@ -510,7 +481,6 @@ export default function TicketsPage() {
|
||||
<th className="text-left px-5 py-3 font-semibold">标题</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">数据库</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">申请人</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">优先级</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">创建时间</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
||||
@@ -546,15 +516,7 @@ export default function TicketsPage() {
|
||||
<th className="text-left px-4 py-3 font-semibold">标题</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">数据库</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">申请人</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">
|
||||
<button
|
||||
onClick={() => { setSortField('priority'); setSortAsc(!sortAsc); }}
|
||||
className="flex items-center gap-1 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
优先级
|
||||
<ArrowUpDown className="w-3 h-3" />
|
||||
</button>
|
||||
</th>
|
||||
|
||||
<th className="text-left px-4 py-3 font-semibold">状态</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">
|
||||
<button
|
||||
@@ -570,8 +532,6 @@ export default function TicketsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{clientFilteredTickets.map(ticket => {
|
||||
const pConfig = priorityConfig[ticket.priority] || priorityConfig.medium;
|
||||
const PIcon = pConfig.icon;
|
||||
const isSelected = selectedIds.has(ticket.id);
|
||||
return (
|
||||
<tr
|
||||
@@ -599,12 +559,7 @@ export default function TicketsPage() {
|
||||
{ticket.applicant_name || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium border ${pConfig.bg} ${pConfig.color} ${pConfig.border}`}>
|
||||
<PIcon className="w-3 h-3" />
|
||||
{pConfig.label}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' || ticket.status === 'executing' ? 'animate-pulse' : ''}`} />
|
||||
@@ -688,14 +643,10 @@ function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreate
|
||||
const [instanceId, setInstanceId] = useState('');
|
||||
const [targetDatabase, setTargetDatabase] = useState('');
|
||||
const [sqlContent, setSqlContent] = useState('');
|
||||
const [rollbackSql, setRollbackSql] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [priority, setPriority] = useState<'high' | 'medium' | 'low'>('medium');
|
||||
const [sqlTab, setSqlTab] = useState<'change' | 'rollback'>('change');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [instances, setInstances] = useState<Array<{ id: number; name: string; host: string; port: number }>>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [charCount, setCharCount] = useState({ change: 0, rollback: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
instanceApi.list().then(res => {
|
||||
@@ -718,9 +669,7 @@ function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreate
|
||||
instance_id: Number(instanceId),
|
||||
target_database: targetDatabase,
|
||||
sql_content: sqlContent,
|
||||
rollback_sql: rollbackSql || undefined,
|
||||
reason,
|
||||
priority,
|
||||
});
|
||||
onCreated();
|
||||
} catch (e: unknown) {
|
||||
@@ -729,25 +678,11 @@ function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreate
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [title, dbType, instanceId, targetDatabase, sqlContent, rollbackSql, reason, priority, onCreated]);
|
||||
}, [title, dbType, instanceId, targetDatabase, sqlContent, reason, onCreated]);
|
||||
|
||||
|
||||
|
||||
const handleSqlChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value;
|
||||
if (sqlTab === 'change') {
|
||||
setSqlContent(val);
|
||||
setCharCount(prev => ({ ...prev, change: val.length }));
|
||||
} else {
|
||||
setRollbackSql(val);
|
||||
setCharCount(prev => ({ ...prev, rollback: val.length }));
|
||||
}
|
||||
}, [sqlTab]);
|
||||
|
||||
const currentSql = sqlTab === 'change' ? sqlContent : rollbackSql;
|
||||
const priorityOptions: Array<{ value: 'high' | 'medium' | 'low'; label: string }> = [
|
||||
{ value: 'high', label: '高' },
|
||||
{ value: 'medium', label: '中' },
|
||||
{ value: 'low', label: '低' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-5xl">
|
||||
@@ -807,62 +742,17 @@ function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreate
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">优先级</label>
|
||||
<div className="flex gap-3">
|
||||
{priorityOptions.map(p => {
|
||||
const pConf = priorityConfig[p.value];
|
||||
return (
|
||||
<button
|
||||
key={p.value}
|
||||
onClick={() => setPriority(p.value)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm border transition-all font-medium ${
|
||||
priority === p.value
|
||||
? `${pConf.bg} ${pConf.color} ${pConf.border}`
|
||||
: 'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:border-[var(--dg-text-muted)]'
|
||||
}`}
|
||||
>
|
||||
<pConf.icon className="w-4 h-4" />
|
||||
{p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* SQL Editor */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">SQL 语句 <span className="text-red-400">*</span></label>
|
||||
<div className="dg-card overflow-hidden">
|
||||
<div className="flex items-center gap-1 p-2 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<button
|
||||
onClick={() => setSqlTab('change')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-all ${
|
||||
sqlTab === 'change' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
<FileCode className="w-3.5 h-3.5" />
|
||||
变更 SQL
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSqlTab('rollback')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-all ${
|
||||
sqlTab === 'rollback' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
回滚 SQL
|
||||
</button>
|
||||
<span className="ml-auto text-xs text-[var(--dg-text-muted)]">
|
||||
{charCount[sqlTab]} 字符
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full min-h-[200px] p-4 bg-[var(--dg-bg-primary)] text-sm font-mono text-[var(--dg-text-primary)] resize-y border-none outline-none placeholder:text-[var(--dg-text-muted)]"
|
||||
placeholder={sqlTab === 'change' ? "UPDATE users SET status = 'active' WHERE id = 1;" : "-- 请输入回滚 SQL"}
|
||||
value={currentSql}
|
||||
onChange={handleSqlChange}
|
||||
placeholder="UPDATE users SET status = 'active' WHERE id = 1;"
|
||||
value={sqlContent}
|
||||
onChange={e => setSqlContent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -914,7 +804,6 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const pConfig = priorityConfig[ticket.priority] || priorityConfig.medium;
|
||||
|
||||
const handleReview = useCallback(async (action: 'approve' | 'reject') => {
|
||||
setLoading(true);
|
||||
@@ -956,10 +845,7 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-3 flex-wrap">
|
||||
{ticket.title}
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${pConfig.bg} ${pConfig.color} ${pConfig.border}`}>
|
||||
<pConfig.icon className="w-3 h-3" />
|
||||
{pConfig.label}优先级
|
||||
</span>
|
||||
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
|
||||
{statusMap[ticket.status] || ticket.status}
|
||||
</span>
|
||||
@@ -1061,7 +947,7 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
|
||||
{ label: '目标数据库', value: ticket.target_database },
|
||||
{ label: '申请人', value: ticket.applicant_name || '-' },
|
||||
{ label: '审批人', value: ticket.approver_name || '-' },
|
||||
{ label: '优先级', value: <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border ${pConfig.bg} ${pConfig.color} ${pConfig.border}`}><pConfig.icon className="w-3 h-3" />{pConfig.label}</span> },
|
||||
|
||||
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
|
||||
{ label: '变更原因', value: ticket.reason || '-' },
|
||||
].map((item, idx) => (
|
||||
@@ -1087,20 +973,7 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Rollback SQL */}
|
||||
{ticket.rollback_sql && (
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<Undo2 className="w-4 h-4 text-purple-400" />
|
||||
回滚 SQL
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap max-h-[300px] overflow-y-auto">{ticket.rollback_sql}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
{/* Audit Record */}
|
||||
{ticket.audit_result && (
|
||||
|
||||
Reference in new issue
Block a user