sync: 同步 sql-platform-pro 最新代码 — 权限体系安全修复 v0.8.1

- P0: 实例访问控制全线接入权限组 (829测试)
- P1: permissions字段激活 + 操作级权限检查 (843测试)
- 前端: 删除数据权限页面, 权限组合并为唯一入口
- 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析
- 新增: MongoDB管理页面 + Dashboard增强
- 修复: Pydantic V2迁移 + 前端TS错误修复
This commit is contained in:
logan committed 2026-05-23 14:41:06 +08:00
1 parent 6fcd9465cf
commit 4c9427e546
105 files changed
+23680 -471

No files matched your search

+3 -3
View File
@@ -55,9 +55,9 @@ export default function AuditLogsPage() {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (filterAction) params.set('action', filterAction);
const res = await request.get(`/audit/logs?${params}`);
const data = res.data as AuditLogResponse;
const data = (res?.data ?? res ?? {}) as AuditLogResponse;
setLogs(data.items || []);
setTotal(data.total);
setTotal(data.total || 0);
} catch (e) {
console.error('获取审计日志失败', e);
} finally {
@@ -156,4 +156,4 @@ export default function AuditLogsPage() {
)}
</div>
);
}
}
+122 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import {
ClipboardList, Clock, CheckCircle, AlertTriangle,
Database, ChevronRight,
X, AlertCircle, Info, Bell
X, AlertCircle, Info, Bell, Activity, TrendingUp, Zap, Server,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { dbInstances, statusMap, statusColorMap, dbTypeColorMap, tickets, announcements } from '@/lib/mock';
@@ -296,6 +296,127 @@ export default function Dashboard() {
</Card>
</div>
{/* 数据源健康概览 + 查询统计 */}
<div className="grid grid-cols-2 gap-5">
{/* 数据源健康概览 */}
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Server className="w-4 h-4 text-blue-400" />
数据源健康概览
</CardTitle>
<span className="text-xs text-[var(--dg-text-muted)]">{onlineInstances}/{dbInstances.length} 在线</span>
</div>
</CardHeader>
<CardContent className="p-0">
{/* 按类型统计 */}
<div className="grid grid-cols-4 gap-2 p-4 border-b border-[var(--dg-border-light)]">
{(['mysql', 'postgresql', 'redis', 'mongodb'] as const).map(type => {
const typeInstances = dbInstances.filter(i => i.dbType === type);
const typeOnline = typeInstances.filter(i => i.status === 'online').length;
const iconMap: Record<string, string> = { mysql: '/db-logos/mysql.svg', postgresql: '/db-logos/postgres.svg', redis: '/db-logos/redis.svg', mongodb: '/db-logos/mongo.svg' };
const labelMap: Record<string, string> = { mysql: 'MySQL', postgresql: 'PostgreSQL', redis: 'Redis', mongodb: 'MongoDB' };
return (
<div key={type} className="text-center p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)]">
<img src={iconMap[type]} alt="" className="w-6 h-6 object-contain mx-auto mb-1.5" />
<div className="text-sm font-semibold">{typeInstances.length}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">{labelMap[type]}</div>
<div className="text-[10px] mt-1">
<span className={typeOnline === typeInstances.length && typeInstances.length > 0 ? 'text-green-400' : typeOnline > 0 ? 'text-yellow-400' : 'text-[var(--dg-text-muted)]'}>
{typeOnline}/{typeInstances.length}
</span>
</div>
</div>
);
})}
</div>
{/* 最近告警实例 */}
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').length > 0 && (
<div className="p-4">
<div className="text-xs text-[var(--dg-text-muted)] mb-2">需要关注</div>
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').slice(0, 3).map(inst => (
<div key={inst.id} className="flex items-center gap-3 py-2">
<span className={`w-2 h-2 rounded-full ${inst.status === 'offline' ? 'bg-red-400' : 'bg-yellow-400'}`} />
<span className="text-sm flex-1">{inst.name}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
<span className={`text-[10px] px-2 py-0.5 rounded-full ${inst.status === 'offline' ? 'bg-red-500/15 text-red-400' : 'bg-yellow-500/15 text-yellow-400'}`}>
{inst.status === 'offline' ? '离线' : '告警'}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 查询统计 */}
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Activity className="w-4 h-4 text-green-400" />
查询统计
</CardTitle>
<div className="flex gap-1.5">
{['今日', '本周', '本月'].map((p, i) => (
<button key={p} className={`px-3 py-1 text-xs rounded-md transition-colors ${i === 0 ? 'bg-green-500/15 text-green-400' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'}`}>
{p}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent className="p-4">
{/* 统计指标 */}
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center gap-2 mb-2">
<Zap className="w-3.5 h-3.5 text-blue-400" />
<span className="text-xs text-[var(--dg-text-muted)]">总查询数</span>
</div>
<div className="text-xl font-bold">1,284</div>
<div className="text-[10px] text-green-400 mt-1">↑ 12% vs 昨日</div>
</div>
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-3.5 h-3.5 text-orange-400" />
<span className="text-xs text-[var(--dg-text-muted)]">平均耗时</span>
</div>
<div className="text-xl font-bold">23ms</div>
<div className="text-[10px] text-green-400 mt-1">↓ 8% vs 昨日</div>
</div>
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-3.5 h-3.5 text-red-400" />
<span className="text-xs text-[var(--dg-text-muted)]">慢查询</span>
</div>
<div className="text-xl font-bold">7</div>
<div className="text-[10px] text-red-400 mt-1">↑ 3 vs 昨日</div>
</div>
</div>
{/* 查询类型分布 */}
<div className="space-y-2.5">
<div className="text-xs text-[var(--dg-text-muted)]">查询类型分布</div>
{[
{ label: 'MySQL', count: 682, total: 1284, color: 'bg-[#00758f]' },
{ label: 'PostgreSQL', count: 312, total: 1284, color: 'bg-[#336791]' },
{ label: 'Redis', count: 198, total: 1284, color: 'bg-[#dc382d]' },
{ label: 'MongoDB', count: 92, total: 1284, color: 'bg-[#47a248]' },
].map(item => (
<div key={item.label} className="flex items-center gap-3">
<span className="text-xs w-20 text-[var(--dg-text-secondary)]">{item.label}</span>
<div className="flex-1 h-2 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
<div className={`h-full rounded-full ${item.color}`} style={{ width: `${(item.count / item.total) * 100}%` }} />
</div>
<span className="text-xs w-12 text-right text-[var(--dg-text-muted)]">{item.count}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* 待处理工单 */}
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
-109
View File
@@ -1,109 +0,0 @@
import { useState } from 'react';
import { Plus, Search, Edit2, Trash2, Database, Table, Filter, Shield } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { dataPermissions } from '@/lib/mock';
export default function DataPermissionPage() {
const [search, setSearch] = useState('');
const filtered = dataPermissions.filter(p =>
!search || p.user.includes(search) || p.instance.includes(search) || p.database.includes(search)
);
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">数据权限</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置用户对数据库、表、字段的访问权限,支持行级权限控制</p>
</div>
<button className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
<Plus className="w-4 h-4" /> 添加权限
</button>
</div>
<div className="grid grid-cols-4 gap-4">
{[
{ label: '权限规则数', value: dataPermissions.length, icon: Shield, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: '受控用户', value: new Set(dataPermissions.map(p => p.user)).size, icon: Database, color: 'text-purple-400', bg: 'bg-purple-400/10' },
{ label: '受控实例', value: new Set(dataPermissions.map(p => p.instance)).size, icon: Table, color: 'text-green-400', bg: 'bg-green-400/10' },
{ label: '行级过滤规则', value: dataPermissions.filter(p => p.rowFilter).length, icon: Filter, color: 'text-orange-400', bg: 'bg-orange-400/10' },
].map((s, i) => (
<Card key={i} className="dg-card">
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
</CardContent>
</Card>
))}
</div>
<div className="relative max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none" placeholder="搜索用户/实例/数据库" value={search} onChange={e => setSearch(e.target.value)} />
</div>
<Card className="dg-card">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<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>
<th className="text-left px-5 py-3 font-semibold">操作</th>
</tr>
</thead>
<tbody>
{filtered.map(p => (
<tr key={p.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-5 py-3.5 font-medium">{p.user}</td>
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{p.instance}</td>
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{p.database}</td>
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{p.table}</td>
<td className="px-5 py-3.5">
{p.columns === '*' ? (
<span className="px-2 py-1 bg-green-500/15 text-green-400 text-xs rounded-md">全部字段</span>
) : (
<div className="flex flex-wrap gap-1">
{p.columns.split(',').map(c => (
<span key={c} className="px-2 py-1 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] text-xs rounded-md">{c}</span>
))}
</div>
)}
</td>
<td className="px-5 py-3.5">
{p.rowFilter ? (
<span className="px-2 py-1 bg-yellow-500/15 text-yellow-400 text-xs rounded-md">{p.rowFilter}</span>
) : (
<span className="text-[var(--dg-text-muted)]">-</span>
)}
</td>
<td className="px-5 py-3.5">
<div className="flex flex-wrap gap-1">
{p.operations.map(op => (
<span key={op} className="px-2 py-1 bg-blue-500/15 text-blue-400 text-xs rounded-md">{op}</span>
))}
</div>
</td>
<td className="px-5 py-3.5">
<div className="flex gap-2">
<button className="text-blue-400 hover:text-blue-300"><Edit2 className="w-4 h-4" /></button>
<button className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4" /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}
+9 -31
View File
@@ -317,44 +317,21 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
);
}
/* ---------- Database Brand SVG Icons ---------- */
/* ---------- Database Brand Logo Icons (from logo.wine) ---------- */
function MySQLIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 256 256" className={className}>
<path d="M235.6 184.1c-13.2-1.3-24.5-10.2-29.8-22.1a7.7 7.7 0 01-.4-1.5c-1.2-3.2-1.5-7-.7-10.8 2-9.5 9.9-15.2 18.6-14.3 8.7.9 15.6 8.1 16.2 17.7.5 7.6-3.2 14.3-9 18.2a1.3 1.3 0 00-.5 1.5c2.7 7.4 8 12.6 14.9 14.4a1.3 1.3 0 01-.8 2.4c-3-.5-5.9-2.4-8.5-5.5z" fill="#00758F"/>
<path d="M28.3 200.3c0 .3.2.5.5.5h5.6c.3 0 .5-.2.5-.5v-19.4h7.8c.3 0 .5-.2.5-.5v-4.4c0-.3-.2-.5-.5-.5H20.5c-.3 0-.5.2-.5.5v4.4c0 .3.2.5.5.5h7.8v19.4zm22.5 0c0 .3.2.5.5.5h5.6c.3 0 .5-.2.5-.5v-24.8c0-.3-.2-.5-.5-.5h-5.6c-.3 0-.5.2-.5.5v24.8zm15.6 0c0 .3.2.5.5.5h13.2c.3 0 .5-.2.5-.5v-4.4c0-.3-.2-.5-.5-.5h-7.1v-19.9c0-.3-.2-.5-.5-.5h-5.6c-.3 0-.5.2-.5.5v24.8zm22.5 0c0 .3.2.5.5.5h5.6c.3 0 .5-.2.5-.5v-24.8c0-.3-.2-.5-.5-.5h-5.6c-.3 0-.5.2-.5.5v24.8zm16.8-12.4c0-7.5 5.7-12.9 13.2-12.9 4.6 0 8.1 1.9 10.5 5l-3.9 3.2c-1.7-2.2-3.9-3.3-6.4-3.3-4.6 0-7.9 3.4-7.9 8s3.3 8 7.9 8c2.5 0 4.7-1.1 6.4-3.3l3.9 3.2c-2.4 3.1-5.9 5-10.5 5-7.5 0-13.2-5.4-13.2-12.9z" fill="#F29111"/>
<ellipse cx="128" cy="56" rx="90" ry="28" fill="none" stroke="#00758F" strokeWidth="6"/>
</svg>
);
return <img src="/db-logos/mysql.svg" alt="MySQL" className={className} />;
}
function PostgreSQLIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 256 256" className={className}>
<path d="M191.5 18.2c-1.2-10.2-7.8-17.6-18.3-18C163.6-.3 153 4 145.2 12c-7.2 7.3-11 17-10.6 27 .3 5.7 1.5 11 3.6 15.7-3.2 7.5-7.6 18.2-10.5 25.8-4.5 11.8-13.4 29.7-19.6 29.7-1.5 0-3.2-.8-5-2.4-6.5-5.8-14.4-8.8-22.9-8.8-11 0-20.7 5.2-25.5 13.6-3.6 6.3-3.7 13.5-.4 19.7 4 7.4 12.5 12.8 22 14l.7.1c6.6.6 13.6-2.3 20.7-8.6 3.3-2.9 7.6-4.5 12.4-4.7 7.4-.3 14.8 3.3 18.7 9.1 3.4 5.1 3.5 11.5.2 16.6-2.2 3.4-5.3 5.8-9 7.1-3 1-5.3 3.3-6.3 6.3-1 3.1-.5 6.4 1.4 9 3.8 5.2 9.3 7.9 15.8 7.9 4.5 0 9.2-1.3 13.5-3.8 4.6-2.7 7.9-6.7 9.6-11.7 1.4-4.1 1.5-8.5.2-12.8l-.1-.3c-2-6.5.3-13.8 5.7-17.9 5.6-4.2 13-4.5 18.9-.7 8.8 5.6 14.1 13.2 15.4 22 .6 3.8.4 7.7-.6 11.4" fill="none" stroke="#336791" strokeWidth="10" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
return <img src="/db-logos/postgres.svg" alt="PostgreSQL" className={className} />;
}
function RedisIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 256 256" className={className}>
<path d="M128 190L20.5 145.3v-20.6l107.5 44.7 107.5-44.7v20.6L128 190z" fill="#912626"/>
<path d="M128 149.4L20.5 104.7V84.1L128 128.8l107.5-44.7v20.6L128 149.4z" fill="#C6302B"/>
<path d="M128 108.8L20.5 64.1V43.5L128 88.2l107.5-44.7v20.6L128 108.8z" fill="#912626"/>
<path d="M128 66.5L73.2 43.7 128 20.9l54.8 22.8L128 66.5z" fill="#C6302B"/>
<path d="M128 235.2l-107.5-44.7v-20.6L128 214.6l107.5-44.7v20.6L128 235.2z" fill="#912626"/>
</svg>
);
return <img src="/db-logos/redis.svg" alt="Redis" className={className} />;
}
function MongoDBIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 256 256" className={className}>
<path d="M128 20c-4.4 16-10 28-17.6 39.2C101.6 72.4 88.8 84 78 98c-14.8 19.2-23.2 40.4-23.2 65.2 0 38.8 31.2 72.8 73.2 72.8s73.2-34 73.2-72.8c0-24.8-8.4-46-23.2-65.2C167.2 84 154.4 72.4 145.6 59.2 138 48 132.4 36 128 20z" fill="#439934"/>
<path d="M128 20c-2 12-6.8 22-12.8 31.2C107.6 63.6 98 73.6 89.6 85.2 78 100.4 72 118 72 138c0 30 22 56 56 56s56-26 56-56c0-20-6-37.6-17.6-52.8C158 73.6 148.4 63.6 140.8 51.2 134.8 42 130 32 128 20z" fill="#4AA841"/>
</svg>
);
return <img src="/db-logos/mongo.svg" alt="MongoDB" className={className} />;
}
/* ---------- CreateInstanceForm ---------- */
@@ -377,6 +354,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
const [password, setPassword] = useState('');
const [showPwd, setShowPwd] = useState(false);
const [database, setDatabase] = useState('');
const [redisDb, setRedisDb] = useState('0');
const [env, setEnv] = useState('development');
const [customEnv, setCustomEnv] = useState('');
const [accessMode, setAccessMode] = useState<'readwrite' | 'read' | 'write'>('readwrite');
@@ -474,7 +452,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
<button key={t.value} type="button" onClick={() => handleTypeChange(t.value)}
className={'flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all '
+ (dbType === t.value ? 'border-blue-500 bg-blue-500/5' : 'border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]')}>
<t.Icon className="w-8 h-8" />
<t.Icon className="w-10 h-10 object-contain" />
<span className="text-xs font-medium">{t.label}</span>
</button>
))}
@@ -561,7 +539,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
) : (
<div>
<label className={labelCls}>数据库编号</label>
<input className={inputCls} placeholder="默认 0" />
<input value={redisDb} onChange={e => setRedisDb(e.target.value)} className={inputCls} placeholder="默认 0" />
</div>
)}
</div>
@@ -664,4 +642,4 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
</div>
</div>
);
}
}
+386
View File
@@ -0,0 +1,386 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Database, Table2, Terminal, BarChart3, RefreshCw, Search, ChevronRight, FileJson } from 'lucide-react';
import { mongoApi } from '@/api/mongo';
type TabKey = 'collections' | 'documents' | 'cli' | 'stats';
export default function MongoDBDetailPage() {
var params = useParams<{ id: string }>();
var navigate = useNavigate();
var instanceId = Number(params.id);
var [activeTab, setActiveTab] = useState<TabKey>('collections');
var [databases, setDatabases] = useState<any[]>([]);
var [selectedDb, setSelectedDb] = useState('');
var [collections, setCollections] = useState<any[]>([]);
var [selectedCollection, setSelectedCollection] = useState('');
var [collectionStats, setCollectionStats] = useState<any>(null);
var [documents, setDocuments] = useState<any[]>([]);
var [docFilter, setDocFilter] = useState('{}');
var [docCount, setDocCount] = useState(0);
var [docPage, setDocPage] = useState(0);
var [pageSize] = useState(20);
var [cmdInput, setCmdInput] = useState('');
var [cmdOutput, setCmdOutput] = useState<string[]>([]);
var [loading, setLoading] = useState(false);
var [error, setError] = useState('');
var tabs: { key: TabKey; label: string; icon: React.ElementType }[] = [
{ key: 'collections', label: '集合管理', icon: Database },
{ key: 'documents', label: '文档浏览', icon: Table2 },
{ key: 'cli', label: '查询控制台', icon: Terminal },
{ key: 'stats', label: '统计信息', icon: BarChart3 },
];
useEffect(function () {
setLoading(true);
mongoApi.getDatabases(instanceId).then(function (res: any) {
var dbs = res.databases || res || [];
setDatabases(Array.isArray(dbs) ? dbs : []);
if (dbs.length > 0 && !selectedDb) {
setSelectedDb(typeof dbs[0] === 'string' ? dbs[0] : (dbs[0].name || ''));
}
}).catch(function () { setDatabases([]); }).finally(function () { setLoading(false); });
}, [instanceId]);
useEffect(function () {
if (!selectedDb) { setCollections([]); return; }
setLoading(true);
mongoApi.getCollections(instanceId, selectedDb).then(function (res: any) {
var cols = res.collections || res || [];
setCollections(Array.isArray(cols) ? cols : []);
}).catch(function () { setCollections([]); }).finally(function () { setLoading(false); });
}, [instanceId, selectedDb]);
useEffect(function () {
if (!selectedCollection || !selectedDb) { setCollectionStats(null); return; }
mongoApi.getCollectionStats(instanceId, selectedCollection, selectedDb).then(function (res: any) {
setCollectionStats(res || null);
}).catch(function () { setCollectionStats(null); });
}, [instanceId, selectedDb, selectedCollection]);
var fetchDocuments = function () {
if (!selectedCollection || !selectedDb) return;
setLoading(true);
setError('');
try {
var filterObj = JSON.parse(docFilter);
mongoApi.query(instanceId, {
database: selectedDb,
collection: selectedCollection,
operation: 'find',
filter: filterObj,
limit: pageSize,
skip: docPage * pageSize,
}).then(function (res: any) {
var result = res.result || res;
setDocuments(Array.isArray(result) ? result : []);
setDocCount(Array.isArray(result) ? result.length : 0);
}).catch(function (err: any) {
setError(err.response ? err.response.data ? err.response.data.detail : '查询失败' : '查询失败');
setDocuments([]);
}).finally(function () { setLoading(false); });
} catch (e) {
setError('JSON 格式错误');
setLoading(false);
}
};
var handleCmdExecute = function () {
if (!cmdInput.trim()) return;
setCmdOutput(function (prev) { return [...prev, '> ' + cmdInput]; });
setLoading(true);
try {
var queryObj = JSON.parse(cmdInput);
mongoApi.query(instanceId, queryObj).then(function (res: any) {
var result = res.result || res;
setCmdOutput(function (prev) {
return [...prev, JSON.stringify(result, null, 2)];
});
}).catch(function (err: any) {
setCmdOutput(function (prev) {
return [...prev, '错误: ' + (err.response ? err.response.data ? err.response.data.detail : err.message : err.message)];
});
}).finally(function () { setLoading(false); });
} catch (e) {
setCmdOutput(function (prev) { return [...prev, '错误: JSON 格式不正确']; });
setLoading(false);
}
setCmdInput('');
};
var handleSelectCollection = function (col: string) {
setSelectedCollection(col);
setActiveTab('documents');
setDocPage(0);
setDocuments([]);
};
var formatSize = function (bytes: number) {
if (!bytes || bytes === 0) return '0 B';
var units = ['B', 'KB', 'MB', 'GB'];
var i = 0;
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
return bytes.toFixed(1) + ' ' + units[i];
};
return (
<div className="h-full flex flex-col">
<div className="flex items-center gap-3 px-5 py-3 border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
<button onClick={function () { navigate('/instances'); }} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)]">
<ArrowLeft className="w-4 h-4" />
</button>
<FileJson className="w-5 h-5 text-[#66bb6a]" />
<h1 className="text-base font-semibold">MongoDB 实例 #{instanceId}</h1>
<span className="text-xs text-[var(--dg-text-muted)]">数据库管理</span>
<button onClick={function () { window.location.reload(); }} className="ml-auto p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)]" title="刷新">
<RefreshCw className="w-4 h-4" />
</button>
</div>
<div className="flex border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
{tabs.map(function (tab) {
var Icon = tab.icon;
var isActive = activeTab === tab.key;
return (
<button key={tab.key} onClick={function () { setActiveTab(tab.key); }}
className={'flex items-center gap-1.5 px-4 py-2.5 text-sm transition-colors border-b-2 '
+ (isActive ? 'border-[#66bb6a] text-[#66bb6a] font-medium' : 'border-transparent text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]')}>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</div>
<div className="flex items-center gap-3 px-5 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-primary)]">
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">数据库:</span>
<select value={selectedDb} onChange={function (e) { setSelectedDb(e.target.value); setSelectedCollection(''); setCollections([]); }}
className="text-sm bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 py-1 text-[var(--dg-text-primary)]">
<option value="">选择数据库</option>
{databases.map(function (db: any) {
var name = typeof db === 'string' ? db : (db.name || '');
return <option key={name} value={name}>{name}</option>;
})}
</select>
{selectedDb && (
<span className="text-xs text-[var(--dg-text-muted)]">
{collections.length} 个集合
</span>
)}
</div>
<div className="flex-1 overflow-auto p-5">
{activeTab === 'collections' && (
<div>
{databases.length === 0 && !loading && (
<div className="text-center py-10 text-[var(--dg-text-muted)]">
<Database className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无数据库,请确认连接状态</p>
</div>
)}
{collections.length > 0 && (
<div className="border border-[var(--dg-border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--dg-bg-secondary)]">
<th className="text-left px-4 py-2.5 font-medium text-[var(--dg-text-muted)]">集合名称</th>
<th className="text-left px-4 py-2.5 font-medium text-[var(--dg-text-muted)]">类型</th>
<th className="text-right px-4 py-2.5 font-medium text-[var(--dg-text-muted)]">操作</th>
</tr>
</thead>
<tbody>
{collections.map(function (col: any) {
var name = typeof col === 'string' ? col : (col.name || col.collectionName || '');
var colType = typeof col === 'string' ? 'collection' : (col.type || 'collection');
return (
<tr key={name} className="border-t border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<Table2 className="w-4 h-4 text-[#66bb6a]" />
<span className="font-mono text-sm">{name}</span>
</div>
</td>
<td className="px-4 py-2.5">
<span className={'px-2 py-0.5 rounded text-xs font-medium '
+ (colType === 'view' ? 'bg-purple-500/10 text-purple-400' : 'bg-blue-500/10 text-blue-400')}>
{colType}
</span>
</td>
<td className="px-4 py-2.5 text-right">
<button onClick={function () { handleSelectCollection(name); }}
className="text-blue-400 hover:text-blue-300 text-sm flex items-center gap-1 ml-auto">
查看文档 <ChevronRight className="w-3 h-3" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{collections.length === 0 && selectedDb && !loading && (
<div className="text-center py-10 text-[var(--dg-text-muted)]">
<p className="text-sm">该数据库中暂无集合</p>
</div>
)}
</div>
)}
{activeTab === 'documents' && (
<div>
{!selectedCollection ? (
<div className="text-center py-10 text-[var(--dg-text-muted)]">
<Table2 className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">请从集合管理中选择一个集合</p>
</div>
) : (
<div>
<div className="flex items-center gap-2 mb-4">
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">筛选:</span>
<input
value={docFilter}
onChange={function (e) { setDocFilter(e.target.value); }}
placeholder='{"field": "value"}'
className="flex-1 text-sm font-mono bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-3 py-1.5 text-[var(--dg-text-primary)]"
/>
<button onClick={fetchDocuments} disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 bg-[#66bb6a] text-white rounded text-sm hover:bg-[#4caf50] disabled:opacity-50">
<Search className="w-3.5 h-3.5" /> 查询
</button>
</div>
<div className="mb-3 text-xs text-[var(--dg-text-muted)]">
集合: <span className="text-[var(--dg-text-primary)] font-mono">{selectedCollection}</span>
{docCount > 0 && <span> | 返回 {docCount} 条文档</span>}
</div>
{error && (
<div className="mb-3 p-3 bg-red-500/10 text-red-400 rounded text-sm">{error}</div>
)}
{documents.length > 0 ? (
<div className="border border-[var(--dg-border)] rounded-lg overflow-auto max-h-[500px]">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-secondary)] sticky top-0">
<tr>
{Object.keys(documents[0] || {}).map(function (key) {
return <th key={key} className="text-left px-3 py-2 font-medium text-[var(--dg-text-muted)] whitespace-nowrap">{key}</th>;
})}
</tr>
</thead>
<tbody>
{documents.map(function (doc, idx) {
return (
<tr key={idx} className="border-t border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
{Object.keys(documents[0] || {}).map(function (key) {
var val = doc[key];
var display = typeof val === 'object' ? JSON.stringify(val) : String(val == null ? 'null' : val);
return <td key={key} className="px-3 py-2 font-mono text-xs max-w-[300px] truncate">{display}</td>;
})}
</tr>
);
})}
</tbody>
</table>
</div>
) : (
!loading && !error && (
<div className="text-center py-6 text-[var(--dg-text-muted)] text-sm">
点击"查询"按钮加载文档
</div>
)
)}
{documents.length >= pageSize && (
<div className="flex items-center justify-center gap-2 mt-4">
<button onClick={function () { setDocPage(Math.max(0, docPage - 1)); fetchDocuments(); }}
disabled={docPage === 0} className="px-3 py-1 text-sm rounded border border-[var(--dg-border)] disabled:opacity-30 hover:bg-[var(--dg-bg-hover)]">
上一页
</button>
<span className="text-xs text-[var(--dg-text-muted)]">第 {docPage + 1} 页</span>
<button onClick={function () { setDocPage(docPage + 1); fetchDocuments(); }}
disabled={documents.length < pageSize} className="px-3 py-1 text-sm rounded border border-[var(--dg-border)] disabled:opacity-30 hover:bg-[var(--dg-bg-hover)]">
下一页
</button>
</div>
)}
</div>
)}
</div>
)}
{activeTab === 'cli' && (
<div className="flex flex-col h-full">
<div className="mb-2 text-xs text-[var(--dg-text-muted)]">
输入 JSON 格式的查询命令(需含 collection、operation 字段)
</div>
<div className="flex-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto p-3 font-mono text-xs min-h-[300px] max-h-[400px]">
{cmdOutput.map(function (line, idx) {
return <pre key={idx} className="whitespace-pre-wrap break-all mb-2">{line}</pre>;
})}
{cmdOutput.length === 0 && <span className="text-[var(--dg-text-muted)]">等待输入查询命令...</span>}
</div>
<div className="border-t border-[var(--dg-border)] flex">
<input
value={cmdInput}
onChange={function (e) { setCmdInput(e.target.value); }}
onKeyDown={function (e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleCmdExecute(); } }}
placeholder='{"collection":"users", "operation":"find", "filter":{}}'
className="flex-1 bg-transparent px-3 py-2.5 font-mono text-sm text-[var(--dg-text-primary)] outline-none"
/>
<button onClick={handleCmdExecute} disabled={loading || !cmdInput.trim()}
className="px-4 text-[#66bb6a] hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 text-sm font-medium">
执行
</button>
</div>
</div>
</div>
)}
{activeTab === 'stats' && (
<div>
{!selectedCollection ? (
<div className="text-center py-10 text-[var(--dg-text-muted)]">
<BarChart3 className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">请从集合管理中选择一个集合查看统计</p>
</div>
) : collectionStats ? (
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 border border-[var(--dg-border)] rounded-lg p-4">
<h3 className="text-sm font-medium mb-3">集合: {selectedCollection}</h3>
<div className="grid grid-cols-2 gap-3">
{[
{ label: '命名空间', value: collectionStats.ns || '-' },
{ label: '文档数量', value: (collectionStats.count || 0).toLocaleString() },
{ label: '数据大小', value: formatSize(collectionStats.size || 0) },
{ label: '平均文档大小', value: formatSize(collectionStats.avgObjSize || 0) },
{ label: '索引大小', value: formatSize(collectionStats.totalIndexSize || 0) },
{ label: '索引数量', value: collectionStats.nindexes || 0 },
{ label: '存储大小', value: formatSize(collectionStats.storageSize || 0) },
{ label: '是否固定集合', value: collectionStats.capped ? '是' : '否' },
].map(function (item) {
return (
<div key={item.label} className="flex justify-between">
<span className="text-xs text-[var(--dg-text-muted)]">{item.label}</span>
<span className="text-xs font-mono text-[var(--dg-text-primary)]">{item.value}</span>
</div>
);
})}
</div>
</div>
</div>
) : !loading ? (
<div className="text-center py-6 text-[var(--dg-text-muted)] text-sm">
无法获取统计信息
</div>
) : null}
</div>
)}
</div>
</div>
);
}
+153 -33
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import {
Plus, Clock, X,
AlertTriangle, CheckCircle, Play, Loader2,
Plus, X, AlertTriangle, CheckCircle, Play, Loader2,
ChevronDown, Clock, FileText, Sparkles,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useActionPermission } from '@/hooks/useAuth';
@@ -9,6 +9,9 @@ import PageHeader from '@/components/PageHeader';
import EmptyState from '@/components/EmptyState';
import { queryApi } from '@/api/query';
import { instanceApi } from '@/api/instance';
import SqlEditor from '@/components/SqlEditor';
import { queryTemplates, getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
import type { DBType } from '@/lib/queryTemplates';
interface QueryResult {
columns: string[];
@@ -22,32 +25,46 @@ interface QueryHistoryItem {
sql: string;
instance_id: number;
database: string;
db_type: string;
status: string;
execution_time_ms: number;
result_preview: string;
created_at: string;
}
const dbTypeLabel: Record<string, string> = {
mysql: 'MySQL', postgresql: 'PostgreSQL', redis: 'Redis', mongodb: 'MongoDB',
};
const dbTypeIcon: Record<string, string> = {
mysql: '/db-logos/mysql.svg',
postgresql: '/db-logos/postgres.svg',
redis: '/db-logos/redis.svg',
mongodb: '/db-logos/mongo.svg',
};
export default function QueriesPage() {
const canCreate = useActionPermission('query.create');
const [showNewQuery, setShowNewQuery] = useState(false);
// 查询历史暂时用本地状态(后端没有查询历史列表 API)
const [history, setHistory] = useState<QueryHistoryItem[]>([]);
if (showNewQuery) {
return <NewQueryPanel onCancel={() => setShowNewQuery(false)} onExecuted={(item) => { setHistory(prev => [item, ...prev]); }} />;
return (
<NewQueryPanel
onCancel={() => setShowNewQuery(false)}
onExecuted={(item) => { setHistory(prev => [item, ...prev]); }}
/>
);
}
return (
<div className="space-y-5">
<PageHeader
title="查询审计"
description="在线 SQL 查询工具,支持 SELECT 查询和结果预览"
description="在线查询工具,支持 MySQL / PostgreSQL / Redis / MongoDB 多类型数据源"
action={canCreate ? { label: '新建查询', icon: Plus, onClick: () => setShowNewQuery(true), permission: 'query.create' } : undefined}
/>
{/* 查询历史 */}
<Card className="dg-card">
<CardContent className="p-0">
{history.length === 0 ? (
@@ -57,7 +74,8 @@ export default function QueriesPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-5 py-3 font-semibold">SQL 语句</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>
@@ -66,8 +84,13 @@ export default function QueriesPage() {
<tbody>
{history.map((q, i) => (
<tr key={i} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-5 py-3.5">
{q.db_type && dbTypeIcon[q.db_type] && (
<img src={dbTypeIcon[q.db_type]} alt={q.db_type} className="w-5 h-5 object-contain" />
)}
</td>
<td className="px-5 py-3.5 max-w-md">
<code className="text-xs font-mono text-blue-400 truncate block">{q.sql.substring(0, 100)}</code>
<code className="text-xs font-mono text-blue-400 truncate block">{q.sql.substring(0, 120)}</code>
</td>
<td className="px-5 py-3.5">
<span className={q.execution_time_ms > 1000 ? 'text-red-400 font-medium' : ''}>{q.execution_time_ms}ms</span>
@@ -90,7 +113,7 @@ export default function QueriesPage() {
);
}
/* ---------- NewQueryPanel ---------- */
/* ---------- NewQueryPanel with templates + CodeMirror ---------- */
function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecuted: (item: QueryHistoryItem) => void }) {
const [sql, setSql] = useState('');
const [instanceId, setInstanceId] = useState('');
@@ -99,6 +122,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
const [executing, setExecuting] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<QueryResult | null>(null);
const [showTemplates, setShowTemplates] = useState(false);
useEffect(() => {
instanceApi.list().then(res => {
@@ -107,8 +131,30 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
}).catch(() => {});
}, []);
const selectedInstance = useMemo(() => {
return instances.find(inst => String(inst.id) === instanceId);
}, [instances, instanceId]);
const dbType = (selectedInstance?.db_type || 'mysql') as DBType;
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
const templates = useMemo(() => getTemplatesForType(dbType), [dbType]);
const placeholderMap: Record<DBType, string> = {
mysql: "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
postgresql: "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
redis: "-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*",
mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
};
const handleApplyTemplate = (tpl: typeof queryTemplates[0]) => {
const templateSQL = getTemplateSQL(tpl, dbType);
setSql(templateSQL);
setShowTemplates(false);
};
const handleExecute = async () => {
if (!sql || !instanceId) { setError('请选择实例并输入 SQL'); return; }
if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; }
setExecuting(true);
setError('');
setResult(null);
@@ -126,6 +172,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
sql,
instance_id: Number(instanceId),
database,
db_type: dbType,
status: 'success',
execution_time_ms: execTime,
result_preview: JSON.stringify(res.rows?.[0] || {}).substring(0, 100),
@@ -139,6 +186,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
sql,
instance_id: Number(instanceId),
database,
db_type: dbType,
status: 'failed',
execution_time_ms: 0,
result_preview: msg,
@@ -158,59 +206,124 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
if (typeof value === 'string' && value.length > 7) return value.substring(0, 3) + '****' + value.substring(value.length - 4);
return '***';
};
const sensitiveKeywords = ['password', 'pwd', 'secret', 'token', 'phone', 'mobile', 'email', 'id_card', 'card_no'];
return (
<div className="space-y-5 max-w-5xl">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">新建查询</h1>
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><X className="w-6 h-6" /></button>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold">新建查询</h1>
{selectedInstance && (
<span className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-[var(--dg-bg-tertiary)]">
<img src={dbTypeIcon[dbType]} alt="" className="w-4 h-4 object-contain" />
{dbTypeLabel[dbType] || dbType}
</span>
)}
</div>
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">
<X className="w-6 h-6" />
</button>
</div>
{/* Instance + Database selector */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">目标实例 <span className="text-red-400">*</span></label>
<select value={instanceId} onChange={e => setInstanceId(e.target.value)} className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
<select
value={instanceId}
onChange={e => setInstanceId(e.target.value)}
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
>
<option value="">请选择实例</option>
{instances.map(inst => (
<option key={inst.id} value={inst.id}>{inst.name} ({inst.host}:{inst.port})</option>
<option key={inst.id} value={inst.id}>
{inst.name} ({inst.host}:{inst.port}) — {dbTypeLabel[inst.db_type] || inst.db_type}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">目标数据库</label>
<input value={database} onChange={e => setDatabase(e.target.value)} className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="可选,默认数据库" />
<input
value={database}
onChange={e => setDatabase(e.target.value)}
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
placeholder="可选,默认数据库"
/>
</div>
</div>
{/* Template selector */}
<div>
<label className="block text-sm font-medium mb-2">SQL 语句 <span className="text-red-400">*</span></label>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium flex items-center gap-2">
<FileText className="w-4 h-4" />
查询语句 <span className="text-red-400">*</span>
</label>
<button
onClick={() => setShowTemplates(!showTemplates)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors"
>
<Sparkles className="w-3.5 h-3.5 text-blue-400" />
快捷模板 ({templates.length})
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${showTemplates ? 'rotate-180' : ''}`} />
</button>
</div>
{/* Templates dropdown */}
{showTemplates && (
<div className="mb-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg max-h-64 overflow-y-auto">
<div className="grid grid-cols-2 gap-2">
{templates.map(tpl => (
<button
key={tpl.id}
onClick={() => handleApplyTemplate(tpl)}
className="text-left p-3 rounded-lg bg-[var(--dg-bg-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors group"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{tpl.category}</span>
<span className="text-sm font-medium">{tpl.name}</span>
</div>
<p className="text-xs text-[var(--dg-text-muted)]">{tpl.description}</p>
</button>
))}
</div>
</div>
)}
{/* CodeMirror Editor */}
<div className="dg-card overflow-hidden">
<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={"-- 仅支持 SELECT 查询\n-- DDL/DML 操作请通过工单系统提交\n\nSELECT id, name, email, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;"}
<SqlEditor
value={sql}
onChange={e => setSql(e.target.value)}
onKeyDown={e => { if (e.ctrlKey && e.key === 'Enter') handleExecute(); }}
onChange={setSql}
placeholder={placeholderMap[dbType]}
language={editorLang}
height="220px"
onExecute={handleExecute}
/>
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
<span>{sql.length} 字符</span>
<span>Ctrl + Enter 执行查询</span>
<div className="flex items-center justify-between px-4 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
<span className="flex items-center gap-1.5">
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3.5 h-3.5 object-contain" />}
{dbTypeLabel[dbType]} 模式
</span>
<span>{sql.length} 字符 · Ctrl+Enter 执行 · Ctrl+Shift+F 格式化</span>
</div>
</div>
</div>
{/* Security notice */}
<div className="flex items-center gap-3 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg">
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
<span className="text-xs text-[var(--dg-text-secondary)]">查询结果中的敏感字段(密码、手机号等)将自动脱敏显示。仅支持 SELECT 查询,DML/DDL 请走工单审批。</span>
<span className="text-xs text-[var(--dg-text-secondary)]">
查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。
</span>
</div>
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">{error}</div>
)}
{/* 查询结果 */}
{/* Query result */}
{result && (
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
@@ -264,11 +377,18 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
)}
<div className="flex justify-end gap-3">
<button onClick={onCancel} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)]">取消</button>
<button onClick={handleExecute} disabled={executing} className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 disabled:opacity-50">
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />} {executing ? '执行中...' : '执行查询'}
<button onClick={onCancel} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)]">
取消
</button>
<button
onClick={handleExecute}
disabled={executing}
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
>
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{executing ? '执行中...' : '执行查询'}
</button>
</div>
</div>
);
}
}
+588
View File
@@ -0,0 +1,588 @@
import { useState, useEffect, useCallback } from 'react';
import {
Search, Clock, Database, AlertTriangle, ChevronDown, ChevronRight,
BarChart3, Play, Loader2, RefreshCw,
} from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import PageHeader from '@/components/PageHeader';
import { instanceApi } from '@/api/instance';
import { slowLogApi } from '@/api/slowLog';
/* ───────── Types ───────── */
interface SlowLogItem {
id: string;
sql: string;
start_time: string;
query_time: number;
lock_time: number;
rows_sent: number;
rows_examined: number;
database: string;
}
interface TopQuery {
sql: string;
count: number;
avg_time_ms: number;
max_time_ms: number;
total_time_ms: number;
}
interface InstanceOption {
id: number;
name: string;
host: string;
db_type: string;
}
/* ───────── Helpers ───────── */
function formatQueryTime(seconds: number): string {
if (seconds >= 1) return `${seconds.toFixed(2)}s`;
return `${(seconds * 1000).toFixed(0)}ms`;
}
function queryTimeColor(seconds: number): string {
if (seconds > 5) return 'text-red-400';
if (seconds > 1) return 'text-orange-400';
if (seconds > 0.5) return 'text-yellow-400';
return 'text-[var(--dg-text-primary)]';
}
function queryTimeBg(seconds: number): string {
if (seconds > 5) return 'bg-red-500/10';
if (seconds > 1) return 'bg-orange-500/10';
if (seconds > 0.5) return 'bg-yellow-500/10';
return '';
}
function truncateSql(sql: string, max: number): string {
const oneLine = sql.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
if (oneLine.length <= max) return oneLine;
return oneLine.slice(0, max) + '…';
}
/* ───────── Component ───────── */
export default function SlowQueryPage() {
// Instance state
const [instances, setInstances] = useState<InstanceOption[]>([]);
const [selectedInstanceId, setSelectedInstanceId] = useState<number | null>(null);
const [instancesLoading, setInstancesLoading] = useState(false);
// Tab state
const [activeTab, setActiveTab] = useState<'logs' | 'top'>('logs');
// Slow logs state
const [logs, setLogs] = useState<SlowLogItem[]>([]);
const [logsTotal, setLogsTotal] = useState(0);
const [logsLoading, setLogsLoading] = useState(false);
const [expandedSqlIds, setExpandedSqlIds] = useState<Set<string>>(new Set());
const [expandedRowId, setExpandedRowId] = useState<string | null>(null);
// Top queries state
const [topQueries, setTopQueries] = useState<TopQuery[]>([]);
const [topLoading, setTopLoading] = useState(false);
// Action states
const [collecting, setCollecting] = useState(false);
const [generating, setGenerating] = useState(false);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
/* ── Fetch instances ── */
const fetchInstances = useCallback(async () => {
setInstancesLoading(true);
try {
const res = await instanceApi.list({});
const data: InstanceOption[] = Array.isArray(res) ? res : [];
setInstances(data);
if (data.length > 0 && !selectedInstanceId) {
setSelectedInstanceId(data[0].id);
}
} catch {
// keep existing
} finally {
setInstancesLoading(false);
}
}, [selectedInstanceId]);
useEffect(() => { fetchInstances(); }, [fetchInstances]);
/* ── Fetch slow logs ── */
const fetchLogs = useCallback(async () => {
if (!selectedInstanceId) return;
setLogsLoading(true);
try {
const res = await slowLogApi.list(selectedInstanceId);
setLogs(res.logs || []);
setLogsTotal(res.total || 0);
} catch {
setLogs([]);
} finally {
setLogsLoading(false);
}
}, [selectedInstanceId]);
/* ── Fetch top queries ── */
const fetchTopQueries = useCallback(async () => {
setTopLoading(true);
try {
const res = await slowLogApi.topQueries();
setTopQueries(res.queries || []);
} catch {
setTopQueries([]);
} finally {
setTopLoading(false);
}
}, []);
/* ── Auto-fetch on tab/instance change ── */
useEffect(() => {
if (activeTab === 'logs') fetchLogs();
if (activeTab === 'top') fetchTopQueries();
}, [activeTab, fetchLogs, fetchTopQueries]);
/* ── Handlers ── */
const handleCollect = async () => {
if (!selectedInstanceId) return;
setCollecting(true);
setMessage(null);
try {
const res = await slowLogApi.collect(selectedInstanceId, '', 100);
setMessage({ text: `成功收集 ${res.collected} 条慢日志`, type: 'success' });
fetchLogs();
} catch {
setMessage({ text: '收集慢日志失败', type: 'error' });
} finally {
setCollecting(false);
}
};
const handleGenerateReport = async () => {
if (!selectedInstanceId) return;
setGenerating(true);
setMessage(null);
try {
await slowLogApi.report(selectedInstanceId);
setMessage({ text: '报告已生成', type: 'success' });
} catch {
setMessage({ text: '生成报告失败', type: 'error' });
} finally {
setGenerating(false);
}
};
const toggleSqlExpand = (id: string) => {
setExpandedSqlIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const toggleRowExpand = (id: string) => {
setExpandedRowId(prev => (prev === id ? null : id));
};
/* ── Chart data ── */
const chartData = topQueries.slice(0, 10).map((q, idx) => ({
name: `#${idx + 1}`,
avgTime: q.avg_time_ms,
maxTime: q.max_time_ms,
count: q.count,
sql: truncateSql(q.sql, 50),
}));
/* ── Render ── */
const selectedInstance = instances.find(i => i.id === selectedInstanceId);
return (
<div className="space-y-5">
{/* Page Header */}
<PageHeader
title="慢查询分析"
description="数据库慢查询监控与性能分析"
/>
{/* Controls bar */}
<Card className="dg-card">
<CardContent className="p-4">
<div className="flex items-center gap-3 flex-wrap">
{/* Instance selector */}
<div className="flex items-center gap-2">
<Database className="w-4 h-4 text-[var(--dg-text-muted)]" />
<select
value={selectedInstanceId ?? ''}
onChange={e => setSelectedInstanceId(Number(e.target.value) || null)}
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 min-w-[200px]"
>
{instancesLoading ? (
<option>加载中...</option>
) : (
instances.map(inst => (
<option key={inst.id} value={inst.id}>{inst.name} ({inst.host})</option>
))
)}
</select>
</div>
{/* Action buttons */}
<button
onClick={handleCollect}
disabled={collecting || !selectedInstanceId}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{collecting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
收集慢日志
</button>
<button
onClick={handleGenerateReport}
disabled={generating || !selectedInstanceId}
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 font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] hover:border-[var(--dg-text-muted)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{generating ? <Loader2 className="w-4 h-4 animate-spin" /> : <BarChart3 className="w-4 h-4" />}
生成报告
</button>
<button
onClick={() => { if (activeTab === 'logs') fetchLogs(); else fetchTopQueries(); }}
className="flex items-center gap-2 px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-all"
>
<RefreshCw className="w-4 h-4" />
</button>
{selectedInstance && (
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">
已选实例: {selectedInstance.name} · {selectedInstance.host}
</span>
)}
</div>
{/* Toast message */}
{message && (
<div className={`mt-3 px-4 py-2.5 rounded-lg text-sm flex items-center gap-2 ${
message.type === 'success'
? 'bg-green-500/10 text-green-400 border border-green-500/20'
: 'bg-red-500/10 text-red-400 border border-red-500/20'
}`}>
{message.type === 'success' ? <Search className="w-4 h-4" /> : <AlertTriangle className="w-4 h-4" />}
{message.text}
</div>
)}
</CardContent>
</Card>
{/* Tabs */}
<div className="flex items-center gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-lg w-fit">
{[
{ key: 'logs' as const, label: '慢日志列表', icon: Clock },
{ key: 'top' as const, label: 'TOP查询', icon: BarChart3 },
].map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={'flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors '
+ (activeTab === tab.key ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]')}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</div>
{/* Tab 1: Slow log list */}
{activeTab === 'logs' && (
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Clock className="w-4 h-4 text-blue-400" />
慢日志列表
<span className="text-xs font-normal text-[var(--dg-text-muted)] ml-2">共 {logsTotal} 条</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{logsLoading ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2" />
加载中...
</div>
) : logs.length === 0 ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<Database className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无慢日志数据</p>
<p className="text-xs mt-1">请先选择实例并点击"收集慢日志"</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)]">
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium w-8" />
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">SQL</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">执行时间</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">锁等待</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">扫描行数</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">返回行数</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">数据库</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">时间</th>
</tr>
</thead>
<tbody>
{logs.map(log => {
const isExpanded = expandedRowId === log.id;
const isSqlExpanded = expandedSqlIds.has(log.id);
return (
<tr key={log.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
{/* Expand toggle */}
<td className="px-4 py-3">
<button
onClick={() => toggleRowExpand(log.id)}
className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
>
{isExpanded
? <ChevronDown className="w-4 h-4" />
: <ChevronRight className="w-4 h-4" />}
</button>
</td>
<td className="px-4 py-3 max-w-md">
<code className={`text-xs font-mono ${queryTimeColor(log.query_time)}`}>
{isSqlExpanded ? log.sql.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() : truncateSql(log.sql, 80)}
</code>
{log.sql.length > 80 && (
<button
onClick={(e) => { e.stopPropagation(); toggleSqlExpand(log.id); }}
className="ml-2 text-xs text-blue-400 hover:text-blue-300"
>
{isSqlExpanded ? '收起' : '展开'}
</button>
)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${queryTimeBg(log.query_time)} ${queryTimeColor(log.query_time)}`}>
<Clock className="w-3 h-3" />
{formatQueryTime(log.query_time)}
</span>
</td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
{formatQueryTime(log.lock_time)}
</td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
{log.rows_examined.toLocaleString()}
</td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
{log.rows_sent.toLocaleString()}
</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] text-xs rounded-md">
{log.database}
</span>
</td>
<td className="px-4 py-3 text-[var(--dg-text-muted)] text-xs whitespace-nowrap">
{new Date(log.start_time).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
})}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* EXPLAIN expanded section */}
{expandedRowId && (() => {
const log = logs.find(l => l.id === expandedRowId);
if (!log) return null;
return (
<div className="border-t border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50 p-5">
<div className="flex items-center gap-2 mb-4">
<Search className="w-4 h-4 text-blue-400" />
<span className="text-sm font-semibold">EXPLAIN 分析</span>
<span className="text-xs text-[var(--dg-text-muted)]">(需要数据源连接)</span>
</div>
{/* Full SQL */}
<div className="mb-4 p-3 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg">
<code className="text-xs font-mono text-[var(--dg-text-secondary)] break-all whitespace-pre-wrap">
{log.sql}
</code>
</div>
{/* Mock EXPLAIN tree */}
<div className="flex items-center gap-0 flex-wrap">
{['Table Scan', 'Filter', 'Sort', 'Limit'].map((step, idx) => (
<div key={step} className="flex items-center">
<div className={`px-4 py-2 rounded-lg border text-sm font-medium ${
idx === 0
? 'bg-red-500/10 border-red-500/30 text-red-400'
: idx === 1
? 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400'
: idx === 2
? 'bg-blue-500/10 border-blue-500/30 text-blue-400'
: 'bg-green-500/10 border-green-500/30 text-green-400'
}`}>
{step}
</div>
{idx < 3 && (
<span className="mx-2 text-[var(--dg-text-muted)] text-lg">→</span>
)}
</div>
))}
</div>
{/* Query metrics summary */}
<div className="mt-4 grid grid-cols-4 gap-3">
{[
{ label: '执行时间', value: formatQueryTime(log.query_time), color: queryTimeColor(log.query_time) },
{ label: '锁等待', value: formatQueryTime(log.lock_time), color: 'text-[var(--dg-text-secondary)]' },
{ label: '扫描行数', value: log.rows_examined.toLocaleString(), color: 'text-[var(--dg-text-secondary)]' },
{ label: '返回行数', value: log.rows_sent.toLocaleString(), color: 'text-[var(--dg-text-secondary)]' },
].map(m => (
<div key={m.label} className="p-3 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg">
<div className="text-xs text-[var(--dg-text-muted)] mb-1">{m.label}</div>
<div className={`text-base font-semibold ${m.color}`}>{m.value}</div>
</div>
))}
</div>
</div>
);
})()}
</CardContent>
</Card>
)}
{/* Tab 2: Top queries */}
{activeTab === 'top' && (
<div className="space-y-4">
{/* Bar chart */}
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-purple-400" />
TOP 10 查询 — 平均执行时间
</CardTitle>
</CardHeader>
<CardContent className="p-5">
{topLoading ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2" />
加载中...
</div>
) : chartData.length === 0 ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<BarChart3 className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无 TOP 查询数据</p>
</div>
) : (
<ResponsiveContainer width="100%" height={320}>
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
<XAxis dataKey="name" tick={{ fill: 'var(--dg-text-muted)', fontSize: 12 }} />
<YAxis
tick={{ fill: 'var(--dg-text-muted)', fontSize: 12 }}
label={{ value: 'ms', position: 'insideTopRight', fill: 'var(--dg-text-muted)', fontSize: 11 }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--dg-bg-secondary)',
border: '1px solid var(--dg-border)',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [
`${value.toFixed(1)} ms`,
name === 'avgTime' ? '平均时间' : '最大时间',
]}
/>
<Bar dataKey="avgTime" fill="#6366f1" radius={[4, 4, 0, 0]} name="avgTime" />
<Bar dataKey="maxTime" fill="#a855f7" radius={[4, 4, 0, 0]} name="maxTime" />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Top queries table */}
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-400" />
查询详情
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{topLoading ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">加载中...</div>
) : topQueries.length === 0 ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">暂无数据</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)]">
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium w-10">#</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">SQL</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">执行次数</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">平均时间</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">最大时间</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">总时间</th>
</tr>
</thead>
<tbody>
{topQueries.map((q, idx) => (
<tr key={idx} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-4 py-3 text-[var(--dg-text-muted)] font-mono text-xs">{idx + 1}</td>
<td className="px-4 py-3 max-w-md">
<code className="text-xs font-mono text-[var(--dg-text-secondary)]">
{truncateSql(q.sql, 100)}
</code>
</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-blue-500/10 text-blue-400 text-xs rounded-md font-medium">
{q.count}
</span>
</td>
<td className="px-4 py-3">
<span className={`text-xs font-medium ${queryTimeColor(q.avg_time_ms / 1000)}`}>
{q.avg_time_ms.toFixed(1)} ms
</span>
</td>
<td className="px-4 py-3">
<span className={`text-xs font-medium ${queryTimeColor(q.max_time_ms / 1000)}`}>
{q.max_time_ms.toFixed(1)} ms
</span>
</td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)] text-xs">
{q.total_time_ms >= 1000
? `${(q.total_time_ms / 1000).toFixed(2)}s`
: `${q.total_time_ms.toFixed(0)}ms`}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
);
}