feat(explain-ui): 新增执行计划图渲染组件与索引建议侧栏

- 依赖引入:新增 reactflow + dagre 用于执行计划 DAG 自动布局
- 构建配置:vite manualChunks 拆分 reactflow/dagre/recharts 独立 chunk,便于按需加载
- 类型镜像:utils/explainTypes.ts 镜像后端 ExplainResult/Node/Stats/IndexSuggestion,含颜色与格式化 helper
- 图渲染:ExplainGraph 自定义节点按 opType 着色 + 警告 flag 边框高亮 + dagre TB 布局
- 侧栏组件:ExplainSidebar 含统计条、节点详情、索引建议按 severity 排序
- 主容器:ExplainWorkbench 含 Modal + 执行计划/原文双 tab,调用 DiagnoseQuery 端到端联通
This commit is contained in:
Syngnat
2026-06-19 13:04:49 +08:00
parent 8e24e40fdd
commit f5ae2e51f9
7 changed files with 963 additions and 0 deletions

View File

@@ -14,9 +14,11 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.6.0",
"@types/dagre": "^0.7.54",
"@types/react-syntax-highlighter": "^15.5.13",
"antd": "^5.12.0",
"clsx": "^2.1.0",
"dagre": "^0.8.5",
"fflate": "^0.8.3",
"mermaid": "^11.13.0",
"react": "^18.2.0",
@@ -24,6 +26,7 @@
"react-markdown": "^10.1.0",
"react-resizable": "^3.1.3",
"react-syntax-highlighter": "^16.1.1",
"reactflow": "^11.11.4",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"sql-formatter": "^15.7.0",
@@ -1214,6 +1217,108 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@reactflow/background": {
"version": "11.3.14",
"resolved": "https://registry.npmmirror.com/@reactflow/background/-/background-11.3.14.tgz",
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/controls": {
"version": "11.2.14",
"resolved": "https://registry.npmmirror.com/@reactflow/controls/-/controls-11.2.14.tgz",
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/core": {
"version": "11.11.4",
"resolved": "https://registry.npmmirror.com/@reactflow/core/-/core-11.11.4.tgz",
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
"license": "MIT",
"dependencies": {
"@types/d3": "^7.4.0",
"@types/d3-drag": "^3.0.1",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/minimap": {
"version": "11.7.14",
"resolved": "https://registry.npmmirror.com/@reactflow/minimap/-/minimap-11.7.14.tgz",
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-resizer": {
"version": "2.2.14",
"resolved": "https://registry.npmmirror.com/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.4",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-toolbar": {
"version": "1.3.14",
"resolved": "https://registry.npmmirror.com/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
@@ -1928,6 +2033,12 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/dagre": {
"version": "0.7.54",
"resolved": "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz",
"integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -2509,6 +2620,12 @@
"chevrotain": "^11.0.0"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
@@ -3081,6 +3198,16 @@
"node": ">=12"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz",
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/dagre-d3-es": {
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
@@ -3387,6 +3514,15 @@
"node": ">=6.9.0"
}
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz",
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/hachure-fill": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
@@ -3675,6 +3811,12 @@
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
@@ -5713,6 +5855,24 @@
"react": "^18.2.0"
}
},
"node_modules/reactflow": {
"version": "11.11.4",
"resolved": "https://registry.npmmirror.com/reactflow/-/reactflow-11.11.4.tgz",
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
"license": "MIT",
"dependencies": {
"@reactflow/background": "11.3.14",
"@reactflow/controls": "11.2.14",
"@reactflow/core": "11.11.4",
"@reactflow/minimap": "11.7.14",
"@reactflow/node-resizer": "2.2.14",
"@reactflow/node-toolbar": "1.3.14"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",

View File

@@ -17,9 +17,11 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.6.0",
"@types/dagre": "^0.7.54",
"@types/react-syntax-highlighter": "^15.5.13",
"antd": "^5.12.0",
"clsx": "^2.1.0",
"dagre": "^0.8.5",
"fflate": "^0.8.3",
"mermaid": "^11.13.0",
"react": "^18.2.0",
@@ -27,6 +29,7 @@
"react-markdown": "^10.1.0",
"react-resizable": "^3.1.3",
"react-syntax-highlighter": "^16.1.1",
"reactflow": "^11.11.4",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"sql-formatter": "^15.7.0",

View File

@@ -0,0 +1,224 @@
import { useCallback, useMemo } from 'react'
import ReactFlow, {
Background,
BackgroundVariant,
Controls,
type Edge,
type Node,
type NodeMouseHandler,
Position,
ReactFlowProvider,
useEdgesState,
useNodesState,
} from 'reactflow'
import dagre from 'dagre'
import 'reactflow/dist/style.css'
import {
type ExplainEdge,
type ExplainNode,
opTypeColor,
formatNumber,
} from '../../utils/explainTypes'
// 执行计划图主组件。
// 使用 react-flow 渲染扁平节点数组dagre 自动计算树形布局。
//
// 设计要点:
// - 自定义节点ExplainGraphNodeData按 opType 着色 + 警告 flag 边框高亮
// - 点击节点触发 onSelectNode 回调(详情抽屉联动)
// - 节点尺寸自适应内容,避免长 SQL/表名截断
// - 通过 React.memo 避免不必要的重渲染88W 数据下很重要)
const NODE_WIDTH = 220
const NODE_HEIGHT = 80
export interface ExplainGraphNodeData {
node: ExplainNode
isSelected: boolean
}
interface ExplainGraphProps {
nodes: ExplainNode[]
edges: ExplainEdge[]
selectedNodeId?: string
onSelectNode?: (nodeId: string | null) => void
}
export default function ExplainGraph(props: ExplainGraphProps) {
return (
<ReactFlowProvider>
<ExplainGraphInner {...props} />
</ReactFlowProvider>
)
}
function ExplainGraphInner({ nodes, edges, selectedNodeId, onSelectNode }: ExplainGraphProps) {
const { rfNodes, rfEdges } = useMemo(
() => layoutWithDagre(nodes, edges, selectedNodeId),
[nodes, edges, selectedNodeId],
)
const [nodeState, , onNodesChange] = useNodesState(rfNodes)
const [edgeState, , onEdgesChange] = useEdgesState(rfEdges)
const handleNodeClick: NodeMouseHandler = useCallback(
(_event, node) => {
onSelectNode?.(node.id)
},
[onSelectNode],
)
const handlePaneClick = useCallback(() => {
onSelectNode?.(null)
}, [onSelectNode])
return (
<div className="gn-explain-graph" style={{ width: '100%', height: '100%' }}>
<ReactFlow
nodes={nodeState}
edges={edgeState}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
onPaneClick={handlePaneClick}
nodeTypes={{ explain: ExplainGraphNodeRenderer }}
fitView
fitViewOptions={{ padding: 0.2 }}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls showInteractive={false} />
</ReactFlow>
</div>
)
}
// layoutWithDagre 用 dagre 计算 react-flow 节点位置。
// 默认从上到下TB布局符合执行计划的"父子层级"心智模型。
function layoutWithDagre(
nodes: ExplainNode[],
edges: ExplainEdge[],
selectedNodeId?: string,
): { rfNodes: Node<ExplainGraphNodeData>[]; rfEdges: Edge[] } {
const g = new dagre.graphlib.Graph()
g.setGraph({ rankdir: 'TB', nodesep: 40, ranksep: 60, marginx: 20, marginy: 20 })
g.setDefaultEdgeLabel(() => ({}))
for (const node of nodes) {
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
}
for (const edge of edges) {
g.setEdge(edge.from, edge.to, { label: edge.label })
}
dagre.layout(g)
const rfNodes: Node<ExplainGraphNodeData>[] = nodes.map((node) => {
const pos = g.node(node.id)
return {
id: node.id,
type: 'explain',
position: { x: pos?.x ?? 0, y: pos?.y ?? 0 },
data: { node, isSelected: node.id === selectedNodeId },
targetPosition: Position.Top,
sourcePosition: Position.Bottom,
draggable: false,
}
})
const rfEdges: Edge[] = edges.map((edge, idx) => ({
id: `e-${edge.from}-${edge.to}-${idx}`,
source: edge.from,
target: edge.to,
label: edge.label,
type: 'smoothstep',
style: { stroke: 'var(--gn-explain-edge, #adb5bd)', strokeWidth: 1.5 },
}))
return { rfNodes, rfEdges }
}
import { memo } from 'react'
const ExplainGraphNodeRenderer = memo(function ExplainGraphNodeRenderer({
data,
}: {
data: ExplainGraphNodeData
}) {
const { node, isSelected } = data
const color = opTypeColor(node.opType)
const hasFullScan = node.flags?.includes('FULL_SCAN')
const hasFilesort = node.flags?.includes('FILESORT')
const hasTempTable = node.flags?.includes('TEMP_TABLE')
return (
<div
className="gn-explain-node"
style={{
width: NODE_WIDTH,
minHeight: NODE_HEIGHT,
border: `2px solid ${isSelected ? 'var(--gn-explain-selected, #1971c2)' : color}`,
background: 'var(--gn-explain-node-bg, #ffffff)',
boxShadow: isSelected ? '0 0 0 3px rgba(25, 113, 194, 0.2)' : 'none',
borderRadius: 6,
padding: 8,
fontSize: 12,
cursor: 'pointer',
}}
>
<div style={{ fontWeight: 600, color, marginBottom: 4 }}>{node.opDetail || node.opType}</div>
{node.table && (
<div style={{ color: 'var(--gn-text-muted, #495057)', marginBottom: 2 }}>
<span style={{ opacity: 0.6 }}></span>
<code style={{ fontSize: 11 }}>{node.table}</code>
</div>
)}
{node.index && (
<div style={{ color: 'var(--gn-text-muted, #495057)', marginBottom: 2 }}>
<span style={{ opacity: 0.6 }}></span>
<code style={{ fontSize: 11 }}>{node.index}</code>
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 4, flexWrap: 'wrap' }}>
{node.estRows !== undefined && node.estRows > 0 && (
<span style={{ color: 'var(--gn-text-muted, #495057)' }}>
<strong>{formatNumber(node.estRows)}</strong>
</span>
)}
{node.actualRows !== undefined && node.actualRows > 0 && (
<span style={{ color: 'var(--gn-text-muted, #495057)' }}>
<strong>{formatNumber(node.actualRows)}</strong>
</span>
)}
{node.cost !== undefined && node.cost > 0 && (
<span style={{ color: 'var(--gn-text-muted, #495057)' }}>
<strong>{node.cost.toFixed(1)}</strong>
</span>
)}
</div>
{(hasFullScan || hasFilesort || hasTempTable) && (
<div style={{ display: 'flex', gap: 4, marginTop: 6, flexWrap: 'wrap' }}>
{hasFullScan && <FlagBadge color="#fa5252" text="全表扫描" />}
{hasFilesort && <FlagBadge color="#f08c00" text="额外排序" />}
{hasTempTable && <FlagBadge color="#7048e8" text="临时表" />}
</div>
)}
</div>
)
})
function FlagBadge({ color, text }: { color: string; text: string }) {
return (
<span
style={{
background: color,
color: 'white',
padding: '1px 6px',
borderRadius: 3,
fontSize: 10,
fontWeight: 500,
}}
>
{text}
</span>
)
}

View File

@@ -0,0 +1,234 @@
import { useMemo } from 'react'
import {
type ExplainNode,
type ExplainStats,
type IndexSuggestion,
severityColor,
severityRank,
formatNumber,
formatPercent,
formatMs,
} from '../../utils/explainTypes'
// 诊断侧栏:节点详情 + 统计条 + 索引建议列表的合集组件。
// 拆分为一个文件减少模块碎片化plan 原拆 3 个文件)。
interface ExplainSidebarProps {
stats: ExplainStats
warnings?: string[]
suggestions: IndexSuggestion[]
selectedNode?: ExplainNode
onSelectSuggestion?: (suggestion: IndexSuggestion) => void
}
export default function ExplainSidebar(props: ExplainSidebarProps) {
const { stats, warnings, suggestions, selectedNode, onSelectSuggestion } = props
const sortedSuggestions = useMemo(
() =>
[...suggestions].sort((a, b) => {
const ra = severityRank[a.severity] ?? 99
const rb = severityRank[b.severity] ?? 99
if (ra !== rb) return ra - rb
return (b.estRows ?? 0) - (a.estRows ?? 0)
}),
[suggestions],
)
return (
<div className="gn-explain-sidebar" style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 12 }}>
<ExplainStatsBar stats={stats} warnings={warnings} />
{selectedNode && <ExplainNodeDetail node={selectedNode} />}
<IndexSuggestionList suggestions={sortedSuggestions} onSelect={onSelectSuggestion} />
</div>
)
}
function ExplainStatsBar({
stats,
warnings,
}: {
stats: ExplainStats
warnings?: string[]
}) {
const statsList = [
{ label: '总成本', value: stats.totalCost ? stats.totalCost.toFixed(1) : '-' },
{ label: '总耗时', value: formatMs(stats.totalDurationMs) },
{ label: '扫描行数', value: formatNumber(stats.rowsRead) },
{ label: '缓冲命中', value: formatPercent(stats.bufferHitRate) },
{ label: '最大单节点行数', value: formatNumber(stats.maxEstRows) },
]
return (
<div
style={{
background: 'var(--gn-card-bg, #f8f9fa)',
border: '1px solid var(--gn-border, #dee2e6)',
borderRadius: 6,
padding: 10,
}}
>
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 13 }}></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 12px', fontSize: 12 }}>
{statsList.map((s) => (
<div key={s.label} style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--gn-text-muted, #6c757d)' }}>{s.label}</span>
<strong>{s.value}</strong>
</div>
))}
</div>
{stats.hasFullScan && <WarningRow color="#fa5252" text="存在全表扫描" />}
{stats.hasFilesort && <WarningRow color="#f08c00" text="存在额外排序" />}
{stats.hasTempTable && <WarningRow color="#7048e8" text="使用临时表" />}
{warnings && warnings.length > 0 && (
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--gn-text-muted, #6c757d)' }}>
{warnings.map((w, i) => (
<div key={i}> {w}</div>
))}
</div>
)}
</div>
)
}
function WarningRow({ color, text }: { color: string; text: string }) {
return (
<div style={{ marginTop: 6, fontSize: 11, color }}>
<span style={{ display: 'inline-block', width: 8, height: 8, background: color, marginRight: 6, borderRadius: 2 }} />
{text}
</div>
)
}
function ExplainNodeDetail({ node }: { node: ExplainNode }) {
const rows: Array<[string, string]> = []
rows.push(['操作类型', node.opType])
if (node.opDetail) rows.push(['操作详情', node.opDetail])
if (node.table) rows.push(['表', node.table])
if (node.index) rows.push(['索引', node.index])
if (node.estRows) rows.push(['估算行数', formatNumber(node.estRows)])
if (node.actualRows) rows.push(['实际行数', formatNumber(node.actualRows)])
if (node.loops) rows.push(['循环次数', formatNumber(node.loops)])
if (node.cost) rows.push(['成本', node.cost.toFixed(2)])
if (node.durationMs) rows.push(['耗时', formatMs(node.durationMs)])
if (node.bufferHit !== undefined && node.bufferHit > 0)
rows.push(['缓冲命中', formatPercent(node.bufferHit)])
if (node.flags && node.flags.length > 0) rows.push(['标志', node.flags.join(', ')])
return (
<div
style={{
background: 'var(--gn-card-bg, #f8f9fa)',
border: '1px solid var(--gn-border, #dee2e6)',
borderRadius: 6,
padding: 10,
}}
>
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 13 }}></div>
<div style={{ fontSize: 12, display: 'flex', flexDirection: 'column', gap: 4 }}>
{rows.map(([label, value]) => (
<div key={label} style={{ display: 'flex', gap: 8 }}>
<span style={{ color: 'var(--gn-text-muted, #6c757d)', minWidth: 80 }}>{label}</span>
<span style={{ wordBreak: 'break-all' }}>{value}</span>
</div>
))}
</div>
{node.extra && Object.keys(node.extra).length > 0 && (
<details style={{ marginTop: 8, fontSize: 11 }}>
<summary style={{ cursor: 'pointer', color: 'var(--gn-text-muted, #6c757d)' }}>
Extra {Object.keys(node.extra).length}
</summary>
<pre style={{ marginTop: 4, fontSize: 11, maxHeight: 120, overflow: 'auto' }}>
{JSON.stringify(node.extra, null, 2)}
</pre>
</details>
)}
</div>
)
}
function IndexSuggestionList({
suggestions,
onSelect,
}: {
suggestions: IndexSuggestion[]
onSelect?: (s: IndexSuggestion) => void
}) {
return (
<div
style={{
background: 'var(--gn-card-bg, #f8f9fa)',
border: '1px solid var(--gn-border, #dee2e6)',
borderRadius: 6,
padding: 10,
flex: 1,
minHeight: 200,
}}
>
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 13 }}>
{suggestions.length}
</div>
{suggestions.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--gn-text-muted, #6c757d)', padding: '20px 0', textAlign: 'center' }}>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{suggestions.map((s, idx) => (
<SuggestionCard key={`${s.rule}-${idx}`} suggestion={s} onSelect={onSelect} />
))}
</div>
)}
</div>
)
}
function SuggestionCard({
suggestion,
onSelect,
}: {
suggestion: IndexSuggestion
onSelect?: (s: IndexSuggestion) => void
}) {
const color = severityColor(suggestion.severity)
return (
<div
onClick={() => onSelect?.(suggestion)}
style={{
borderLeft: `3px solid ${color}`,
padding: '6px 8px',
background: 'var(--gn-suggestion-bg, #ffffff)',
cursor: onSelect ? 'pointer' : 'default',
fontSize: 12,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ color, fontWeight: 600, textTransform: 'uppercase', fontSize: 10 }}>
{suggestion.severity}
</span>
{suggestion.estRows !== undefined && suggestion.estRows > 0 && (
<span style={{ color: 'var(--gn-text-muted, #6c757d)', fontSize: 11 }}>
{formatNumber(suggestion.estRows)}
</span>
)}
</div>
<div style={{ marginBottom: 4 }}>{suggestion.reason}</div>
{suggestion.suggestedIndex && (
<code
style={{
display: 'block',
padding: 4,
background: 'var(--gn-code-bg, #f1f3f5)',
fontSize: 11,
borderRadius: 3,
}}
>
{suggestion.suggestedIndex}
</code>
)}
{suggestion.affectedTable && (
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--gn-text-muted, #6c757d)' }}>
<code>{suggestion.affectedTable}</code>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,158 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Modal, Spin, Tabs, Typography } from 'antd'
import { DiagnoseQuery } from '../../../wailsjs/go/app/App'
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'
import type { ConnectionConfig } from '../../types'
import type { DiagnoseReport, ExplainNode, IndexSuggestion } from '../../utils/explainTypes'
import ExplainGraph from './ExplainGraph'
import ExplainSidebar from './ExplainSidebar'
// SQL 诊断工作台主容器。
// 通过 React.lazy 在 QueryEditor 触发"诊断"时延迟加载(避免 react-flow 进入主 bundle
//
// UI 结构:
// ┌─────────────────────────────────────────────────┐
// │ Modal诊断工作台 │
// ├──────────────────────────────┬──────────────────┤
// │ react-flow 执行计划图 │ 侧栏 │
// │ (点击节点联动) │ - 统计条 │
// │ │ - 节点详情 │
// │ │ - 索引建议 │
// └──────────────────────────────┴──────────────────┘
// 底部 tab执行计划 | 原文(调试用)
const { Title, Text, Paragraph } = Typography
interface ExplainWorkbenchProps {
open: boolean
onClose: () => void
config: ConnectionConfig
dbName: string
sql: string
}
export default function ExplainWorkbench({ open, onClose, config, dbName, sql }: ExplainWorkbenchProps) {
const [loading, setLoading] = useState(false)
const [report, setReport] = useState<DiagnoseReport | null>(null)
const [error, setError] = useState<string | null>(null)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const runDiagnose = useCallback(async () => {
if (!sql.trim()) {
setError('查询语句为空')
return
}
setLoading(true)
setError(null)
setReport(null)
setSelectedNodeId(null)
try {
const result = await DiagnoseQuery(buildRpcConnectionConfig(config), dbName, sql)
if (!result.success) {
setError(result.message || '诊断失败')
} else {
const data = result.data as DiagnoseReport
setReport(data)
}
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}, [config, dbName, sql])
useEffect(() => {
if (open) {
void runDiagnose()
}
}, [open, runDiagnose])
const selectedNode = useMemo<ExplainNode | undefined>(() => {
if (!report || !selectedNodeId) return undefined
return report.plan.nodes.find((n) => n.id === selectedNodeId)
}, [report, selectedNodeId])
const handleSelectSuggestion = useCallback((s: IndexSuggestion) => {
if (s.affectedNodeId) {
setSelectedNodeId(s.affectedNodeId)
}
}, [])
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width="90%"
style={{ top: 20 }}
title={<Title level={5} style={{ margin: 0 }}>SQL </Title>}
destroyOnClose
>
<div style={{ minHeight: 480 }}>
{loading && (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<Spin tip="正在执行 EXPLAIN 并解析计划..." />
</div>
)}
{error && (
<Paragraph type="danger" style={{ padding: 16 }}>
<Text strong></Text>
{error}
</Paragraph>
)}
{!loading && !error && report && (
<Tabs
items={[
{
key: 'plan',
label: `执行计划(${report.plan.nodes.length} 节点)`,
children: (
<div style={{ display: 'flex', gap: 12, height: '70vh', minHeight: 400 }}>
<div style={{ flex: 1, minWidth: 400, position: 'relative' }}>
<ExplainGraph
nodes={report.plan.nodes}
edges={report.plan.edges ?? []}
selectedNodeId={selectedNodeId ?? undefined}
onSelectNode={setSelectedNodeId}
/>
</div>
<div style={{ width: 320, flexShrink: 0, overflowY: 'auto', maxHeight: '70vh' }}>
<ExplainSidebar
stats={report.plan.stats}
warnings={report.plan.warnings}
suggestions={report.suggestions ?? []}
selectedNode={selectedNode}
onSelectSuggestion={handleSelectSuggestion}
/>
</div>
</div>
),
},
{
key: 'raw',
label: `原文(${report.plan.rawFormat}`,
children: (
<pre
style={{
maxHeight: '60vh',
overflow: 'auto',
background: 'var(--gn-code-bg, #f1f3f5)',
padding: 12,
borderRadius: 4,
fontSize: 12,
fontFamily: 'ui-monospace, Consolas, monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{report.plan.rawPayload || '(无原文)'}
</pre>
),
},
]}
/>
)}
</div>
</Modal>
)
}

View File

@@ -0,0 +1,172 @@
// SQL 诊断工作台前端类型定义。
//
// 本文件镜像后端 internal/connection/explain.go 的数据结构。
// 当 Wails 重新生成 models.ts 后,可逐步迁移到 import { connection } from '../wailsjs/go/models'
// 但在过渡期保持独立类型便于前端独立开发。
// 节点操作类型(与后端 ExplainOp* 常量对齐)。
export type ExplainOpType =
| 'SCAN' // 全表扫描
| 'INDEX_SCAN' // 索引扫描
| 'INDEX_ONLY' // 覆盖索引
| 'JOIN'
| 'AGGREGATE'
| 'SORT'
| 'LIMIT'
| 'FILTER'
| 'SUBQUERY'
| 'UNION'
| 'WINDOW'
| 'MATERIALIZE'
| 'INSERT'
| 'UPDATE'
| 'DELETE'
| 'OTHER'
// 节点警告标志(用于 UI 高亮 + 规则匹配)。
export type ExplainNodeFlag =
| 'FULL_SCAN'
| 'FILESORT'
| 'TEMP_TABLE'
| 'NO_INDEX'
| 'HIGH_COST'
| 'LOW_BUFFER_HIT'
| 'UNCERTAIN_ROWS'
// EXPLAIN 原文格式。
export type ExplainFormat = 'json' | 'table' | 'xml' | 'text'
// 建议严重度。
export type IndexSuggestionSeverity = 'critical' | 'warning' | 'info'
export interface ExplainNode {
id: string
parentId?: string
opType: ExplainOpType | string
opDetail?: string
table?: string
index?: string
estRows?: number
actualRows?: number
loops?: number
cost?: number
durationMs?: number
bufferHit?: number
flags?: ExplainNodeFlag[] | string[]
extra?: Record<string, unknown>
}
export interface ExplainEdge {
from: string
to: string
label?: string
}
export interface ExplainStats {
totalCost?: number
totalDurationMs?: number
rowsRead?: number
bufferHitRate?: number
hasFullScan: boolean
hasFilesort: boolean
hasTempTable: boolean
maxEstRows?: number
}
export interface ExplainResult {
dbType: string
sourceSql: string
nodes: ExplainNode[]
edges?: ExplainEdge[]
stats: ExplainStats
warnings?: string[]
rawFormat: ExplainFormat | string
rawPayload?: string
}
export interface IndexSuggestion {
severity: IndexSuggestionSeverity | string
rule: string
reason: string
suggestedIndex?: string
affectedNodeId?: string
affectedTable?: string
estRows?: number
}
export interface DiagnoseReport {
plan: ExplainResult
suggestions: IndexSuggestion[]
}
// severityRank 用于 UI 排序critical 最前。
export const severityRank: Record<string, number> = {
critical: 0,
warning: 1,
info: 2,
}
// opTypeTheme 按 OpType 返回主题色 token对应 v2-theme.css 的 CSS 变量)。
// 颜色规则SCAN 红橙警告、JOIN 蓝、AGGREGATE 紫、SORT 黄、其他灰。
export function opTypeColor(opType: string): string {
switch (opType) {
case 'SCAN':
return 'var(--gn-explain-scan, #e8590c)'
case 'INDEX_SCAN':
return 'var(--gn-explain-index-scan, #1971c2)'
case 'INDEX_ONLY':
return 'var(--gn-explain-index-only, #2f9e44)'
case 'JOIN':
return 'var(--gn-explain-join, #1971c2)'
case 'AGGREGATE':
return 'var(--gn-explain-aggregate, #6741d9)'
case 'SORT':
return 'var(--gn-explain-sort, #f08c00)'
case 'LIMIT':
return 'var(--gn-explain-limit, #495057)'
case 'FILTER':
return 'var(--gn-explain-filter, #495057)'
case 'SUBQUERY':
return 'var(--gn-explain-subquery, #7048e8)'
case 'MATERIALIZE':
return 'var(--gn-explain-materialize, #e8590c)'
default:
return 'var(--gn-explain-other, #868e96)'
}
}
// severityColor 用于建议列表的左侧色条。
export function severityColor(severity: string): string {
switch (severity) {
case 'critical':
return 'var(--gn-explain-critical, #fa5252)'
case 'warning':
return 'var(--gn-explain-warning, #f08c00)'
case 'info':
return 'var(--gn-explain-info, #1c7ed6)'
default:
return 'var(--gn-explain-other, #868e96)'
}
}
// formatNumber 容错格式化大数字(千分位)。
export function formatNumber(n?: number): string {
if (n === undefined || n === null || isNaN(n)) return '-'
if (Math.abs(n) >= 10000) {
return new Intl.NumberFormat('en-US').format(n)
}
return String(n)
}
// formatPercent 把 0-1 的小数格式化为百分比字符串。
export function formatPercent(ratio?: number): string {
if (ratio === undefined || ratio === null || isNaN(ratio)) return '-'
return `${(ratio * 100).toFixed(1)}%`
}
// formatMs 把毫秒格式化为人类可读(>1s 显示秒)。
export function formatMs(ms?: number): string {
if (ms === undefined || ms === null || isNaN(ms)) return '-'
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
return `${ms.toFixed(1)}ms`
}

View File

@@ -28,5 +28,17 @@ export default defineConfig({
build: {
outDir: 'dist', // Standard Wails output directory
emptyOutDir: true,
rollupOptions: {
output: {
// 拆分大体积三方依赖到独立 chunk避免主 bundle 过大
// reactflow + dagre 约 130KB gzipped单独成 chunk 可按需加载
// recharts 用于诊断面板统计条,与执行计划图无强依赖,单独 chunk
manualChunks: {
reactflow: ['reactflow'],
dagre: ['dagre'],
charts: ['recharts'],
},
},
},
}
})