Files
MyGoNavi/frontend/src/components/explain/ExplainSidebar.tsx
Syngnat f5ae2e51f9 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 端到端联通
2026-06-19 13:04:49 +08:00

235 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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>
)
}