diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cf89390..0471959 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 197e39c..cb03395 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/explain/ExplainGraph.tsx b/frontend/src/components/explain/ExplainGraph.tsx new file mode 100644 index 0000000..2ff2f5c --- /dev/null +++ b/frontend/src/components/explain/ExplainGraph.tsx @@ -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 ( + + + + ) +} + +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 ( +
+ + + + +
+ ) +} + +// layoutWithDagre 用 dagre 计算 react-flow 节点位置。 +// 默认从上到下(TB)布局,符合执行计划的"父子层级"心智模型。 +function layoutWithDagre( + nodes: ExplainNode[], + edges: ExplainEdge[], + selectedNodeId?: string, +): { rfNodes: Node[]; 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[] = 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 ( +
+
{node.opDetail || node.opType}
+ {node.table && ( +
+ 表: + {node.table} +
+ )} + {node.index && ( +
+ 索引: + {node.index} +
+ )} +
+ {node.estRows !== undefined && node.estRows > 0 && ( + + 估算 {formatNumber(node.estRows)} + + )} + {node.actualRows !== undefined && node.actualRows > 0 && ( + + 实际 {formatNumber(node.actualRows)} + + )} + {node.cost !== undefined && node.cost > 0 && ( + + 成本 {node.cost.toFixed(1)} + + )} +
+ {(hasFullScan || hasFilesort || hasTempTable) && ( +
+ {hasFullScan && } + {hasFilesort && } + {hasTempTable && } +
+ )} +
+ ) +}) + +function FlagBadge({ color, text }: { color: string; text: string }) { + return ( + + {text} + + ) +} diff --git a/frontend/src/components/explain/ExplainSidebar.tsx b/frontend/src/components/explain/ExplainSidebar.tsx new file mode 100644 index 0000000..712d660 --- /dev/null +++ b/frontend/src/components/explain/ExplainSidebar.tsx @@ -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 ( +
+ + {selectedNode && } + +
+ ) +} + +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 ( +
+
执行统计
+
+ {statsList.map((s) => ( +
+ {s.label} + {s.value} +
+ ))} +
+ {stats.hasFullScan && } + {stats.hasFilesort && } + {stats.hasTempTable && } + {warnings && warnings.length > 0 && ( +
+ {warnings.map((w, i) => ( +
⚠ {w}
+ ))} +
+ )} +
+ ) +} + +function WarningRow({ color, text }: { color: string; text: string }) { + return ( +
+ + {text} +
+ ) +} + +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 ( +
+
节点详情
+
+ {rows.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+ {node.extra && Object.keys(node.extra).length > 0 && ( +
+ + Extra 字段({Object.keys(node.extra).length}) + +
+            {JSON.stringify(node.extra, null, 2)}
+          
+
+ )} +
+ ) +} + +function IndexSuggestionList({ + suggestions, + onSelect, +}: { + suggestions: IndexSuggestion[] + onSelect?: (s: IndexSuggestion) => void +}) { + return ( +
+
+ 索引建议({suggestions.length}) +
+ {suggestions.length === 0 ? ( +
+ 未发现明显性能问题 +
+ ) : ( +
+ {suggestions.map((s, idx) => ( + + ))} +
+ )} +
+ ) +} + +function SuggestionCard({ + suggestion, + onSelect, +}: { + suggestion: IndexSuggestion + onSelect?: (s: IndexSuggestion) => void +}) { + const color = severityColor(suggestion.severity) + return ( +
onSelect?.(suggestion)} + style={{ + borderLeft: `3px solid ${color}`, + padding: '6px 8px', + background: 'var(--gn-suggestion-bg, #ffffff)', + cursor: onSelect ? 'pointer' : 'default', + fontSize: 12, + }} + > +
+ + {suggestion.severity} + + {suggestion.estRows !== undefined && suggestion.estRows > 0 && ( + + {formatNumber(suggestion.estRows)} 行 + + )} +
+
{suggestion.reason}
+ {suggestion.suggestedIndex && ( + + {suggestion.suggestedIndex} + + )} + {suggestion.affectedTable && ( +
+ 表:{suggestion.affectedTable} +
+ )} +
+ ) +} diff --git a/frontend/src/components/explain/ExplainWorkbench.tsx b/frontend/src/components/explain/ExplainWorkbench.tsx new file mode 100644 index 0000000..c07f033 --- /dev/null +++ b/frontend/src/components/explain/ExplainWorkbench.tsx @@ -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(null) + const [error, setError] = useState(null) + const [selectedNodeId, setSelectedNodeId] = useState(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(() => { + 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 ( + SQL 诊断工作台} + destroyOnClose + > +
+ {loading && ( +
+ +
+ )} + {error && ( + + 诊断失败: + {error} + + )} + {!loading && !error && report && ( + +
+ +
+
+ +
+
+ ), + }, + { + key: 'raw', + label: `原文(${report.plan.rawFormat})`, + children: ( +
+                    {report.plan.rawPayload || '(无原文)'}
+                  
+ ), + }, + ]} + /> + )} + +
+ ) +} diff --git a/frontend/src/utils/explainTypes.ts b/frontend/src/utils/explainTypes.ts new file mode 100644 index 0000000..96510aa --- /dev/null +++ b/frontend/src/utils/explainTypes.ts @@ -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 +} + +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 = { + 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` +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index db1c836..b9bda7d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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'], + }, + }, + }, } })