feat(sql-analysis): 完善慢 SQL 监控与执行计划诊断

- 完善慢查询采集、聚合、迁移与跨进程持久化

- 加固只读诊断边界并补齐多数据库执行计划解析

- 优化慢 SQL 面板、计划图、响应式布局与交互体验

- 补充后端、前端及 SQL Server 会话回归测试
This commit is contained in:
Syngnat
2026-07-11 16:25:07 +08:00
parent 5c80f6e9a2
commit 034e968888
49 changed files with 4817 additions and 686 deletions

View File

@@ -38,12 +38,14 @@ const requiredKeys = [
'sql_analysis.workbench.view.slow_query',
'sql_analysis.workbench.view.diagnose',
'sql_analysis.workbench.editor.placeholder',
'sql_analysis.workbench.editor.aria_label',
'sql_analysis.workbench.editor.hint',
'sql_analysis.workbench.action.run',
'sql_analysis.explain.error.query_required',
'sql_analysis.explain.error.run_failed',
'sql_analysis.explain.loading',
'sql_analysis.explain.error.title',
'sql_analysis.explain.action.retry',
'sql_analysis.explain.empty',
'sql_analysis.explain.view.plan',
'sql_analysis.explain.view.raw',
@@ -84,10 +86,27 @@ const requiredKeys = [
'sql_analysis.sidebar.suggestions.rows',
'sql_analysis.sidebar.suggestions.table',
'sql_analysis.slow_query.error.load_failed',
'sql_analysis.slow_query.error.copy_failed',
'sql_analysis.slow_query.message.cleared',
'sql_analysis.slow_query.message.copied',
'sql_analysis.slow_query.not_diagnosable',
'sql_analysis.slow_query.error.clear_failed',
'sql_analysis.slow_query.action.copy',
'sql_analysis.slow_query.action.load',
'sql_analysis.slow_query.action.load_more',
'sql_analysis.slow_query.action.retry',
'sql_analysis.slow_query.action.view_full',
'sql_analysis.slow_query.clear_confirm.title',
'sql_analysis.slow_query.clear_confirm.description',
'sql_analysis.slow_query.details.title',
'sql_analysis.slow_query.search.placeholder',
'sql_analysis.slow_query.search.aria_label',
'sql_analysis.slow_query.search.empty',
'sql_analysis.slow_query.scope_note',
'sql_analysis.slow_query.sort.aria_label',
'sql_analysis.slow_query.sort.duration',
'sql_analysis.slow_query.sort.rows_read',
'sql_analysis.slow_query.sort.frequency',
'sql_analysis.slow_query.sort.rows_returned',
'sql_analysis.slow_query.sort.recent',
'sql_analysis.slow_query.tooltip.clear_current',
'sql_analysis.slow_query.loading',
@@ -97,6 +116,14 @@ const requiredKeys = [
'sql_analysis.slow_query.current_connection',
'sql_analysis.slow_query.metric.rows_read',
'sql_analysis.slow_query.metric.rows_returned',
'sql_analysis.slow_query.metric.average_duration',
'sql_analysis.slow_query.metric.executions',
'sql_analysis.slow_query.summary.statements',
'sql_analysis.slow_query.summary.executions',
'sql_analysis.slow_query.summary.max_duration',
'sql_analysis.slow_query.summary.rows_returned',
'sql_analysis.slow_query.truncated',
'sql_analysis.slow_query.unsupported_diagnosis',
'sql_analysis.slow_query.preview.empty',
'sql_analysis.slow_query.relative.just_now',
'sql_analysis.slow_query.relative.minutes_ago',
@@ -132,6 +159,7 @@ describe('SQL analysis workbench i18n', () => {
"t('sql_analysis.workbench.view.slow_query')",
"t('sql_analysis.workbench.view.diagnose')",
"t('sql_analysis.workbench.editor.placeholder')",
"t('sql_analysis.workbench.editor.aria_label')",
"t('sql_analysis.workbench.editor.hint')",
"t('sql_analysis.workbench.action.run')",
].forEach((text) => {
@@ -160,6 +188,7 @@ describe('SQL analysis workbench i18n', () => {
"t('sql_analysis.explain.error.run_failed')",
"t('sql_analysis.explain.loading')",
"t('sql_analysis.explain.error.title')",
"t('sql_analysis.explain.action.retry')",
"t('sql_analysis.explain.empty')",
"t('sql_analysis.explain.view.plan')",
"t('sql_analysis.explain.view.raw')",
@@ -205,8 +234,13 @@ describe('SQL analysis workbench i18n', () => {
"t('sql_analysis.slow_query.message.cleared')",
"t('sql_analysis.slow_query.error.clear_failed')",
"t('sql_analysis.slow_query.sort.duration')",
"t('sql_analysis.slow_query.sort.rows_read')",
"t('sql_analysis.slow_query.sort.frequency')",
"t('sql_analysis.slow_query.sort.rows_returned')",
"t('sql_analysis.slow_query.sort.recent')",
"t('sql_analysis.slow_query.search.placeholder')",
"t('sql_analysis.slow_query.search.aria_label')",
"t('sql_analysis.slow_query.clear_confirm.title')",
"t('sql_analysis.slow_query.clear_confirm.description')",
"t('sql_analysis.slow_query.tooltip.clear_current')",
"t('sql_analysis.slow_query.loading')",
"t('sql_analysis.slow_query.error.title')",
@@ -215,6 +249,16 @@ describe('SQL analysis workbench i18n', () => {
"t('sql_analysis.slow_query.current_connection')",
"t('sql_analysis.slow_query.metric.rows_read')",
"t('sql_analysis.slow_query.metric.rows_returned')",
"t('sql_analysis.slow_query.metric.average_duration'",
"t('sql_analysis.slow_query.metric.executions'",
"t('sql_analysis.slow_query.action.copy')",
"t('sql_analysis.slow_query.action.load')",
"t('sql_analysis.slow_query.action.view_full')",
"t('sql_analysis.slow_query.details.title')",
"t('sql_analysis.slow_query.scope_note'",
"t('sql_analysis.slow_query.truncated')",
"t('sql_analysis.slow_query.not_diagnosable')",
"t('sql_analysis.slow_query.unsupported_diagnosis')",
"t('sql_analysis.slow_query.preview.empty')",
"t('sql_analysis.slow_query.relative.just_now')",
"t('sql_analysis.slow_query.relative.minutes_ago'",
@@ -224,7 +268,7 @@ describe('SQL analysis workbench i18n', () => {
expect(slowQueryPanelSource).toContain(text)
})
expect(slowQueryPanelSource).toContain('record.sqlPreview')
expect(slowQueryPanelSource).toContain('getSlowQuerySql(record)')
expect(slowQueryPanelSource).toContain('record.dbType')
})

View File

@@ -37,4 +37,44 @@ describe('SQL analysis workbench wiring', () => {
expect(source).toContain('gn-explain-report-switcher')
expect(source).not.toContain('<Tabs')
})
it('keeps the editor draft separate from the SQL submitted for diagnosis', () => {
const source = readFileSync(new URL('./explain/SqlAnalysisWorkbench.tsx', import.meta.url), 'utf8')
expect(source).toContain('submittedSql')
expect(source).toContain('setSubmittedSql')
expect(source).toContain('sql={submittedSql}')
expect(source).toContain("event.key === 'Enter'")
expect(source).toContain('event.ctrlKey || event.metaKey')
})
it('loads a slow query into the editor without immediately diagnosing it', () => {
const source = readFileSync(new URL('./explain/SqlAnalysisWorkbench.tsx', import.meta.url), 'utf8')
const handler = source.slice(source.indexOf('const handlePickSlowQuery'), source.indexOf('const slowQueryLoadKey'))
expect(handler).toContain('setSqlDraft(nextSql)')
expect(handler).toContain("setActiveView('diagnose')")
expect(handler).toContain("setSubmittedSql('')")
expect(handler).toContain('setDiagnoseRunKey(0)')
expect(handler).not.toContain('setDiagnoseRunKey((previous)')
})
it('guards diagnose and slow-query responses against stale requests', () => {
const explainSource = readFileSync(new URL('./explain/ExplainWorkbench.tsx', import.meta.url), 'utf8')
const slowQuerySource = readFileSync(new URL('./explain/SlowQueryPanel.tsx', import.meta.url), 'utf8')
expect(explainSource).toContain('requestSequenceRef')
expect(slowQuerySource).toContain('requestSequenceRef')
})
it('disables execution-plan diagnosis for unsupported datasource types', () => {
const capabilitiesSource = readFileSync(new URL('../utils/dataSourceCapabilities.ts', import.meta.url), 'utf8')
const workbenchSource = readFileSync(new URL('./explain/SqlAnalysisWorkbench.tsx', import.meta.url), 'utf8')
const queryEditorSource = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8')
expect(capabilitiesSource).toContain('supportsExplainDiagnosis')
expect(workbenchSource).toContain('disabled: !supportsDiagnosis')
expect(workbenchSource).toContain("t('sql_analysis.slow_query.unsupported_diagnosis')")
expect(queryEditorSource).toContain('!currentConnectionCapabilities.supportsExplainDiagnosis')
})
})

View File

@@ -1245,6 +1245,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
() => connections.filter(c => getDataSourceCapabilities(c.config).supportsQueryEditor),
[connections]
);
const currentConnectionCapabilities = useMemo(
() => getDataSourceCapabilities(
connections.find(connection => connection.id === currentConnectionId)?.config,
),
[connections, currentConnectionId],
);
const addSqlLog = useStore(state => state.addSqlLog);
const sqlLogCount = useStore(state => state.sqlLogs.length);
@@ -1358,6 +1364,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
message.warning(translate('query_editor.message.connection_not_found'));
return;
}
if (view === 'diagnose' && !currentConnectionCapabilities.supportsExplainDiagnosis) {
message.warning(translate('sql_analysis.slow_query.unsupported_diagnosis' as any));
return;
}
const dbName = String(currentDb || tab.dbName || '').trim();
addTab(buildSqlAnalysisWorkbenchTab({
connectionId,
@@ -1366,7 +1376,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
view,
}));
},
[addTab, currentConnectionId, currentDb, tab.dbName],
[addTab, currentConnectionCapabilities.supportsExplainDiagnosis, currentConnectionId, currentDb, tab.dbName],
);
const handleCloseSqlSnippetPicker = useCallback(() => {
@@ -7498,6 +7508,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
)}
</span>
),
disabled: !currentConnectionCapabilities.supportsExplainDiagnosis,
onClick: () => openSqlAnalysisWorkbench('diagnose', getCurrentQuery()),
},
{

View File

@@ -0,0 +1,401 @@
.gn-explain-graph {
width: 100%;
height: 100%;
min-height: 280px;
color: var(--gn-fg-2);
background: var(--gn-bg-panel);
}
.gn-explain-graph .react-flow__controls {
overflow: hidden;
border: 1px solid var(--gn-br-2);
border-radius: 8px;
box-shadow: var(--gn-shadow-sm);
}
.gn-explain-graph .react-flow__controls-button {
color: var(--gn-fg-2);
background: var(--gn-bg-panel);
border-bottom-color: var(--gn-br-2);
}
.gn-explain-graph .react-flow__controls-button:hover {
background: var(--gn-bg-hover);
}
.gn-explain-graph .react-flow__controls-button svg {
fill: currentColor;
}
.gn-explain-node {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 3px;
width: 240px;
min-height: 128px;
padding: 9px 10px;
overflow: hidden;
font: inherit;
font-size: 12px;
color: var(--gn-fg-2);
text-align: left;
cursor: pointer;
background: var(--gn-bg-panel);
border: 2px solid var(--gn-br-3);
border-radius: 8px;
box-shadow: var(--gn-shadow-sm);
}
.gn-explain-node:hover {
background: var(--gn-bg-panel-2);
box-shadow: var(--gn-shadow-md);
}
.gn-explain-node:focus-visible,
.gn-explain-suggestion__select:focus-visible,
.gn-explain-suggestion__copy:focus-visible {
outline: 2px solid var(--gn-accent);
outline-offset: 2px;
}
.gn-explain-node--selected {
background: var(--gn-bg-selected);
box-shadow: 0 0 0 3px var(--gn-accent-soft);
}
.gn-explain-node__label {
display: -webkit-box;
overflow: hidden;
font-weight: 650;
line-height: 1.35;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.gn-explain-node__field {
display: flex;
min-width: 0;
color: var(--gn-fg-3);
}
.gn-explain-node__field-label {
flex: 0 0 auto;
color: var(--gn-fg-4);
}
.gn-explain-node__field code {
min-width: 0;
overflow: hidden;
font-family: var(--gn-font-mono);
font-size: 11px;
color: var(--gn-fg-2);
text-overflow: ellipsis;
white-space: nowrap;
}
.gn-explain-node__metrics,
.gn-explain-node__flags {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
}
.gn-explain-node__metrics {
margin-top: 2px;
color: var(--gn-fg-3);
}
.gn-explain-node__flags {
margin-top: 2px;
}
.gn-explain-flag {
padding: 1px 6px;
font-size: 10px;
font-weight: 600;
line-height: 1.5;
border-radius: 4px;
}
.gn-explain-flag--danger {
color: var(--gn-danger);
background: color-mix(in srgb, var(--gn-danger) 14%, var(--gn-bg-panel));
}
.gn-explain-flag--warning {
color: var(--gn-warn);
background: var(--gn-warn-soft);
}
.gn-explain-flag--info {
color: var(--gn-info);
background: var(--gn-info-soft);
}
.gn-explain-sidebar {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
padding: 12px;
color: var(--gn-fg-2);
}
.gn-explain-card {
padding: 11px;
background: var(--gn-bg-panel-2);
border: 1px solid var(--gn-br-2);
border-radius: 8px;
box-shadow: var(--gn-shadow-sm);
}
.gn-explain-card__title {
margin: 0 0 9px;
font-size: 13px;
font-weight: 650;
line-height: 1.4;
color: var(--gn-fg-1);
}
.gn-explain-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px 14px;
margin: 0;
font-size: 12px;
}
.gn-explain-stats__item {
display: flex;
gap: 8px;
justify-content: space-between;
min-width: 0;
}
.gn-explain-stats__item dt,
.gn-explain-details__row dt {
color: var(--gn-fg-4);
}
.gn-explain-stats__item dd,
.gn-explain-details__row dd {
margin: 0;
font-weight: 650;
color: var(--gn-fg-1);
}
.gn-explain-warning {
display: flex;
gap: 7px;
align-items: center;
margin-top: 7px;
font-size: 11px;
}
.gn-explain-warning__dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
background: currentColor;
border-radius: 2px;
}
.gn-explain-warning--danger {
color: var(--gn-danger);
}
.gn-explain-warning--warning {
color: var(--gn-warn);
}
.gn-explain-warning--info {
color: var(--gn-info);
}
.gn-explain-warnings {
display: grid;
gap: 4px;
margin: 9px 0 0;
padding-left: 18px;
font-size: 11px;
color: var(--gn-fg-3);
}
.gn-explain-details {
display: grid;
gap: 6px;
margin: 0;
font-size: 12px;
}
.gn-explain-details__row {
display: grid;
grid-template-columns: minmax(80px, 0.4fr) minmax(0, 1fr);
gap: 9px;
}
.gn-explain-details__row dd {
overflow-wrap: anywhere;
}
.gn-explain-extra {
margin-top: 9px;
font-size: 11px;
color: var(--gn-fg-3);
}
.gn-explain-extra summary {
cursor: pointer;
}
.gn-explain-extra summary:focus-visible {
outline: 2px solid var(--gn-accent);
outline-offset: 2px;
}
.gn-explain-extra pre {
max-height: 160px;
margin: 6px 0 0;
padding: 8px;
overflow: auto;
font-family: var(--gn-font-mono);
font-size: 11px;
color: var(--gn-fg-2);
white-space: pre-wrap;
overflow-wrap: anywhere;
background: var(--gn-bg-input);
border: 1px solid var(--gn-br-1);
border-radius: 6px;
}
.gn-explain-suggestions {
flex: 1;
min-height: 160px;
}
.gn-explain-suggestions__empty {
padding: 24px 0;
font-size: 12px;
color: var(--gn-fg-4);
text-align: center;
}
.gn-explain-suggestions__list {
display: grid;
gap: 8px;
}
.gn-explain-suggestion {
overflow: hidden;
background: var(--gn-bg-panel);
border: 1px solid var(--gn-br-1);
border-left-width: 3px;
border-radius: 6px;
}
.gn-explain-suggestion__select {
display: block;
width: 100%;
padding: 8px 9px 6px;
font: inherit;
font-size: 12px;
color: var(--gn-fg-2);
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
}
.gn-explain-suggestion__select:hover:not(:disabled) {
background: var(--gn-bg-hover);
}
.gn-explain-suggestion__select:disabled {
cursor: default;
}
.gn-explain-suggestion__heading {
display: flex;
gap: 8px;
align-items: baseline;
justify-content: space-between;
margin-bottom: 4px;
}
.gn-explain-suggestion__severity {
font-size: 10px;
font-weight: 700;
}
.gn-explain-suggestion__rows,
.gn-explain-suggestion__table {
font-size: 11px;
color: var(--gn-fg-4);
}
.gn-explain-suggestion__reason {
display: block;
overflow-wrap: anywhere;
}
.gn-explain-suggestion__table {
display: block;
margin-top: 5px;
}
.gn-explain-suggestion__table code {
font-family: var(--gn-font-mono);
color: var(--gn-fg-2);
}
.gn-explain-suggestion__index {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: start;
margin: 0 9px 8px;
padding: 6px;
background: var(--gn-bg-input);
border: 1px solid var(--gn-br-1);
border-radius: 5px;
}
.gn-explain-suggestion__index code {
display: -webkit-box;
overflow: hidden;
font-family: var(--gn-font-mono);
font-size: 11px;
line-height: 1.45;
color: var(--gn-fg-2);
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.gn-explain-suggestion__copy {
color: var(--gn-fg-3);
}
@media (max-width: 980px) {
.gn-explain-sidebar {
padding: 10px;
}
.gn-explain-stats {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 640px) {
.gn-explain-node {
width: 220px;
}
.gn-explain-details__row {
grid-template-columns: minmax(0, 1fr);
gap: 2px;
}
}

View File

@@ -1,11 +1,10 @@
import { useCallback, useMemo } from 'react'
import { memo, useCallback, useEffect, useMemo } from 'react'
import ReactFlow, {
Background,
BackgroundVariant,
Controls,
type Edge,
type Node,
type NodeMouseHandler,
Position,
ReactFlowProvider,
useEdgesState,
@@ -13,29 +12,21 @@ import ReactFlow, {
} from 'reactflow'
import dagre from 'dagre'
import 'reactflow/dist/style.css'
import './ExplainAnalysis.css'
import {
type ExplainEdge,
type ExplainNode,
opTypeColor,
formatNumber,
} from '../../utils/explainTypes'
import { useI18n } from '../../i18n/provider'
// 执行计划图主组件。
// 使用 react-flow 渲染扁平节点数组dagre 自动计算树形布局。
//
// 设计要点:
// - 自定义节点ExplainGraphNodeData按 opType 着色 + 警告 flag 边框高亮
// - 点击节点触发 onSelectNode 回调(详情抽屉联动)
// - 节点尺寸自适应内容,避免长 SQL/表名截断
// - 通过 React.memo 避免不必要的重渲染88W 数据下很重要)
const NODE_WIDTH = 220
const NODE_HEIGHT = 80
const NODE_WIDTH = 240
const NODE_HEIGHT = 128
export interface ExplainGraphNodeData {
node: ExplainNode
isSelected: boolean
onSelect?: (nodeId: string) => void
}
interface ExplainGraphProps {
@@ -54,37 +45,43 @@ export default function ExplainGraph(props: ExplainGraphProps) {
}
function ExplainGraphInner({ nodes, edges, selectedNodeId, onSelectNode }: ExplainGraphProps) {
// Selection is deliberately excluded: highlighting a node must not run dagre again.
const { rfNodes, rfEdges } = useMemo(
() => layoutWithDagre(nodes, edges, selectedNodeId),
[nodes, edges, selectedNodeId],
() => layoutWithDagre(nodes, edges),
[nodes, edges],
)
const [nodeState, , onNodesChange] = useNodesState(rfNodes)
const [edgeState, , onEdgesChange] = useEdgesState(rfEdges)
const handleNodeClick: NodeMouseHandler = useCallback(
(_event, node) => {
onSelectNode?.(node.id)
},
[onSelectNode],
const [nodeState, setNodeState, onNodesChange] = useNodesState(
applyGraphNodeState(rfNodes, selectedNodeId, onSelectNode),
)
const [edgeState, setEdgeState, onEdgesChange] = useEdgesState(rfEdges)
// useNodesState/useEdgesState only consume their initial value. Keep controlled
// ReactFlow state in sync when an asynchronously loaded plan replaces the props.
useEffect(() => {
setNodeState(applyGraphNodeState(rfNodes, selectedNodeId, onSelectNode))
}, [onSelectNode, rfNodes, selectedNodeId, setNodeState])
useEffect(() => {
setEdgeState(rfEdges)
}, [rfEdges, setEdgeState])
const handlePaneClick = useCallback(() => {
onSelectNode?.(null)
}, [onSelectNode])
return (
<div className="gn-explain-graph" style={{ width: '100%', height: '100%' }}>
<div className="gn-explain-graph">
<ReactFlow
nodes={nodeState}
edges={edgeState}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
onPaneClick={handlePaneClick}
nodeTypes={{ explain: ExplainGraphNodeRenderer }}
nodeTypes={EXPLAIN_NODE_TYPES}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.2}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
@@ -94,133 +91,186 @@ function ExplainGraphInner({ nodes, edges, selectedNodeId, onSelectNode }: Expla
)
}
// layoutWithDagre 用 dagre 计算 react-flow 节点位置。
// 默认从上到下TB布局符合执行计划的"父子层级"心智模型。
function layoutWithDagre(
// layoutWithDagre only owns topology and positions. Interactive state is applied
// afterwards so selection and callback changes do not trigger an expensive layout.
export 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(() => ({}))
const graph = new dagre.graphlib.Graph()
graph.setGraph({ rankdir: 'TB', nodesep: 40, ranksep: 60, marginx: 20, marginy: 20 })
graph.setDefaultEdgeLabel(() => ({}))
for (const node of nodes) {
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
graph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
}
for (const edge of edges) {
g.setEdge(edge.from, edge.to, { label: edge.label })
graph.setEdge(edge.from, edge.to, { label: edge.label })
}
dagre.layout(g)
dagre.layout(graph)
const rfNodes: Node<ExplainGraphNodeData>[] = nodes.map((node) => {
const pos = g.node(node.id)
const position = graph.node(node.id)
return {
id: node.id,
type: 'explain',
position: { x: pos?.x ?? 0, y: pos?.y ?? 0 },
data: { node, isSelected: node.id === selectedNodeId },
position: { x: position?.x ?? 0, y: position?.y ?? 0 },
data: { node, isSelected: false },
targetPosition: Position.Top,
sourcePosition: Position.Bottom,
draggable: false,
selectable: false,
focusable: false,
}
})
const rfEdges: Edge[] = edges.map((edge, idx) => ({
id: `e-${edge.from}-${edge.to}-${idx}`,
const rfEdges: Edge[] = edges.map((edge, index) => ({
id: `e-${edge.from}-${edge.to}-${index}`,
source: edge.from,
target: edge.to,
label: edge.label,
type: 'smoothstep',
style: { stroke: 'var(--gn-explain-edge, #adb5bd)', strokeWidth: 1.5 },
style: { stroke: 'var(--gn-fg-5)', strokeWidth: 1.5 },
}))
return { rfNodes, rfEdges }
}
import { memo } from 'react'
export function applyGraphNodeState(
nodes: Node<ExplainGraphNodeData>[],
selectedNodeId?: string,
onSelectNode?: (nodeId: string | null) => void,
): Node<ExplainGraphNodeData>[] {
return nodes.map((node) => ({
...node,
data: {
...node.data,
isSelected: node.id === selectedNodeId,
onSelect: onSelectNode,
},
}))
}
const ExplainGraphNodeRenderer = memo(function ExplainGraphNodeRenderer({
data,
}: {
data: ExplainGraphNodeData
}) {
const { t } = useI18n()
const { node, isSelected } = data
const color = opTypeColor(node.opType)
const { language, t } = useI18n()
const { node, isSelected, onSelect } = data
const operationColor = resolveOperationColor(node.opType)
const hasFullScan = node.flags?.includes('FULL_SCAN')
const hasFilesort = node.flags?.includes('FILESORT')
const hasTempTable = node.flags?.includes('TEMP_TABLE')
const operationLabel = node.opDetail || formatOperationLabel(node.opType)
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',
<button
type="button"
className={`gn-explain-node${isSelected ? ' gn-explain-node--selected' : ''}`}
style={{ borderColor: isSelected ? 'var(--gn-accent)' : operationColor }}
aria-pressed={isSelected}
title={operationLabel}
onClick={(event) => {
event.stopPropagation()
onSelect?.(node.id)
}}
>
<div style={{ fontWeight: 600, color, marginBottom: 4 }}>{node.opDetail || node.opType}</div>
<span
className="gn-explain-node__label"
style={{ color: operationColor }}
title={operationLabel}
>
{operationLabel}
</span>
{node.table && (
<div style={{ color: 'var(--gn-text-muted, #495057)', marginBottom: 2 }}>
<span style={{ opacity: 0.6 }}>{t('sql_analysis.explain_graph.label.table')}</span>
<code style={{ fontSize: 11 }}>{node.table}</code>
</div>
<span className="gn-explain-node__field" title={node.table}>
<span className="gn-explain-node__field-label">
{t('sql_analysis.explain_graph.label.table')}
</span>
<code>{node.table}</code>
</span>
)}
{node.index && (
<div style={{ color: 'var(--gn-text-muted, #495057)', marginBottom: 2 }}>
<span style={{ opacity: 0.6 }}>{t('sql_analysis.explain_graph.label.index')}</span>
<code style={{ fontSize: 11 }}>{node.index}</code>
</div>
<span className="gn-explain-node__field" title={node.index}>
<span className="gn-explain-node__field-label">
{t('sql_analysis.explain_graph.label.index')}
</span>
<code>{node.index}</code>
</span>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 4, flexWrap: 'wrap' }}>
{node.estRows !== undefined && node.estRows > 0 && (
<span style={{ color: 'var(--gn-text-muted, #495057)' }}>
{t('sql_analysis.explain_graph.metric.est_rows')} <strong>{formatNumber(node.estRows)}</strong>
<span className="gn-explain-node__metrics">
{isFiniteMetric(node.estRows) && (
<span>
{t('sql_analysis.explain_graph.metric.est_rows')}{' '}
<strong>{formatNumber(node.estRows, language)}</strong>
</span>
)}
{node.actualRows !== undefined && node.actualRows > 0 && (
<span style={{ color: 'var(--gn-text-muted, #495057)' }}>
{t('sql_analysis.explain_graph.metric.actual_rows')} <strong>{formatNumber(node.actualRows)}</strong>
{isFiniteMetric(node.actualRows) && (
<span>
{t('sql_analysis.explain_graph.metric.actual_rows')}{' '}
<strong>{formatNumber(node.actualRows, language)}</strong>
</span>
)}
{node.cost !== undefined && node.cost > 0 && (
<span style={{ color: 'var(--gn-text-muted, #495057)' }}>
{t('sql_analysis.explain_graph.metric.cost')} <strong>{node.cost.toFixed(1)}</strong>
{isFiniteMetric(node.cost) && (
<span>
{t('sql_analysis.explain_graph.metric.cost')}{' '}
<strong>{node.cost?.toFixed(1)}</strong>
</span>
)}
</div>
</span>
{(hasFullScan || hasFilesort || hasTempTable) && (
<div style={{ display: 'flex', gap: 4, marginTop: 6, flexWrap: 'wrap' }}>
{hasFullScan && <FlagBadge color="#fa5252" text={t('sql_analysis.explain_graph.flag.full_scan')} />}
{hasFilesort && <FlagBadge color="#f08c00" text={t('sql_analysis.explain_graph.flag.filesort')} />}
{hasTempTable && <FlagBadge color="#7048e8" text={t('sql_analysis.explain_graph.flag.temp_table')} />}
</div>
<span className="gn-explain-node__flags">
{hasFullScan && (
<FlagBadge tone="danger" text={t('sql_analysis.explain_graph.flag.full_scan')} />
)}
{hasFilesort && (
<FlagBadge tone="warning" text={t('sql_analysis.explain_graph.flag.filesort')} />
)}
{hasTempTable && (
<FlagBadge tone="info" text={t('sql_analysis.explain_graph.flag.temp_table')} />
)}
</span>
)}
</div>
</button>
)
})
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>
)
const EXPLAIN_NODE_TYPES = { explain: ExplainGraphNodeRenderer }
function FlagBadge({ tone, text }: { tone: 'danger' | 'warning' | 'info'; text: string }) {
return <span className={`gn-explain-flag gn-explain-flag--${tone}`}>{text}</span>
}
function isFiniteMetric(value?: number): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
function formatOperationLabel(operation: string): string {
const normalized = String(operation || '')
.trim()
.toLocaleLowerCase()
.replace(/[_-]+/g, ' ')
return normalized ? normalized.charAt(0).toLocaleUpperCase() + normalized.slice(1) : '-'
}
function resolveOperationColor(operation: string): string {
switch (operation) {
case 'SCAN':
case 'MATERIALIZE':
return 'var(--gn-danger)'
case 'INDEX_SCAN':
case 'INDEX_ONLY':
return 'var(--gn-accent)'
case 'JOIN':
case 'AGGREGATE':
case 'SUBQUERY':
case 'UNION':
case 'WINDOW':
return 'var(--gn-info)'
case 'SORT':
return 'var(--gn-warn)'
default:
return 'var(--gn-fg-3)'
}
}

View File

@@ -0,0 +1,63 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { formatMs, formatNumber, formatPercent } from '../../utils/explainTypes'
const graphSource = readFileSync(new URL('./ExplainGraph.tsx', import.meta.url), 'utf8')
const sidebarSource = readFileSync(new URL('./ExplainSidebar.tsx', import.meta.url), 'utf8')
const workbenchSource = readFileSync(new URL('./ExplainWorkbench.tsx', import.meta.url), 'utf8')
describe('SQL explain graph and sidebar presentation', () => {
it('keeps dagre layout independent from selection and synchronizes controlled props', () => {
expect(graphSource).toContain('() => layoutWithDagre(nodes, edges)')
expect(graphSource).toContain('[nodes, edges]')
expect(graphSource).not.toContain('layoutWithDagre(nodes, edges, selectedNodeId)')
expect(graphSource).toContain(
'setNodeState(applyGraphNodeState(rfNodes, selectedNodeId, onSelectNode))',
)
expect(graphSource).toContain('setEdgeState(rfEdges)')
})
it('remounts the graph after each successful report so fitView runs for the new plan', () => {
expect(workbenchSource).toContain('setReportRevision((revision) => revision + 1)')
expect(workbenchSource).toContain('key={reportRevision}')
expect(graphSource).toContain('fitView')
})
it('treats zero as a real metric and formats backend enum values for display', () => {
expect(sidebarSource).toContain('hasMetricValue(stats.totalCost)')
expect(sidebarSource).toContain('hasMetricValue(node.estRows)')
expect(sidebarSource).toContain('hasMetricValue(node.bufferHit)')
expect(sidebarSource).toContain(".replace(/[_-]+/g, ' ')")
})
it('localizes known severities and flags instead of exposing raw enums', () => {
expect(sidebarSource).toContain("case 'critical':")
expect(sidebarSource).toContain("t('security_update.severity.high')")
expect(sidebarSource).toContain("t('common.warning')")
expect(sidebarSource).toContain("t('data_sync.log.level.info')")
expect(sidebarSource).toContain("case 'FULL_SCAN':")
expect(sidebarSource).toContain("t('sql_analysis.explain_graph.flag.full_scan')")
expect(sidebarSource).toContain('formatExplainEnumLabel(flag)')
})
it('uses stable node types, native focus targets, and a dedicated index copy action', () => {
expect(graphSource).toContain('nodeTypes={EXPLAIN_NODE_TYPES}')
expect(graphSource).toContain('type="button"')
expect(graphSource).toContain('className="gn-explain-node__label"')
expect(sidebarSource).toContain('className="gn-explain-suggestion__select"')
expect(sidebarSource).toContain('className="gn-explain-suggestion__copy"')
expect(sidebarSource).toContain('navigator.clipboard.writeText')
})
it('formats explain metrics with the active UI language', () => {
expect(formatNumber(12_345, 'de-DE')).toBe('12.345')
expect(formatPercent(0.125, 'de-DE')).toBe(
new Intl.NumberFormat('de-DE', {
style: 'percent',
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(0.125),
)
expect(formatMs(1_500, 'de-DE')).toBe('1,50s')
})
})

View File

@@ -1,18 +1,17 @@
import { useMemo } from 'react'
import { useId, useMemo } from 'react'
import { CopyOutlined } from '@ant-design/icons'
import { Button, Tooltip, message } from 'antd'
import {
type ExplainNode,
type ExplainStats,
type IndexSuggestion,
severityColor,
severityRank,
formatNumber,
formatPercent,
formatMs,
} from '../../utils/explainTypes'
import { useI18n } from '../../i18n/provider'
// 诊断侧栏:节点详情 + 统计条 + 索引建议列表的合集组件。
// 拆分为一个文件减少模块碎片化plan 原拆 3 个文件)。
import './ExplainAnalysis.css'
interface ExplainSidebarProps {
stats: ExplainStats
@@ -22,25 +21,27 @@ interface ExplainSidebarProps {
onSelectSuggestion?: (suggestion: IndexSuggestion) => void
}
type Translate = (key: string) => string
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].sort((left, right) => {
const leftRank = severityRank[left.severity] ?? 99
const rightRank = severityRank[right.severity] ?? 99
if (leftRank !== rightRank) return leftRank - rightRank
return (right.estRows ?? 0) - (left.estRows ?? 0)
}),
[suggestions],
)
return (
<div className="gn-explain-sidebar" style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 12 }}>
<aside className="gn-explain-sidebar">
<ExplainStatsBar stats={stats} warnings={warnings} />
{selectedNode && <ExplainNodeDetail node={selectedNode} />}
<IndexSuggestionList suggestions={sortedSuggestions} onSelect={onSelectSuggestion} />
</div>
</aside>
)
}
@@ -51,100 +52,134 @@ function ExplainStatsBar({
stats: ExplainStats
warnings?: string[]
}) {
const { t } = useI18n()
const { language, t } = useI18n()
const titleId = useId()
const statsList = [
{ label: t('sql_analysis.sidebar.stats.total_cost'), value: stats.totalCost ? stats.totalCost.toFixed(1) : '-' },
{ label: t('sql_analysis.sidebar.stats.total_duration'), value: formatMs(stats.totalDurationMs) },
{ label: t('sql_analysis.sidebar.stats.rows_read'), value: formatNumber(stats.rowsRead) },
{ label: t('sql_analysis.sidebar.stats.buffer_hit'), value: formatPercent(stats.bufferHitRate) },
{ label: t('sql_analysis.sidebar.stats.max_est_rows'), value: formatNumber(stats.maxEstRows) },
{
label: t('sql_analysis.sidebar.stats.total_cost'),
value: hasMetricValue(stats.totalCost) ? stats.totalCost.toFixed(1) : '-',
},
{
label: t('sql_analysis.sidebar.stats.total_duration'),
value: formatMs(stats.totalDurationMs, language),
},
{
label: t('sql_analysis.sidebar.stats.rows_read'),
value: formatNumber(stats.rowsRead, language),
},
{
label: t('sql_analysis.sidebar.stats.buffer_hit'),
value: formatPercent(stats.bufferHitRate, language),
},
{
label: t('sql_analysis.sidebar.stats.max_est_rows'),
value: formatNumber(stats.maxEstRows, language),
},
]
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 }}>{t('sql_analysis.sidebar.stats.title')}</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>
<section className="gn-explain-card" aria-labelledby={titleId}>
<h3 id={titleId} className="gn-explain-card__title">
{t('sql_analysis.sidebar.stats.title')}
</h3>
<dl className="gn-explain-stats">
{statsList.map((stat) => (
<div key={stat.label} className="gn-explain-stats__item">
<dt>{stat.label}</dt>
<dd>{stat.value}</dd>
</div>
))}
</div>
{stats.hasFullScan && <WarningRow color="#fa5252" text={t('sql_analysis.sidebar.warning.full_scan')} />}
{stats.hasFilesort && <WarningRow color="#f08c00" text={t('sql_analysis.sidebar.warning.filesort')} />}
{stats.hasTempTable && <WarningRow color="#7048e8" text={t('sql_analysis.sidebar.warning.temp_table')} />}
{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>
</dl>
{stats.hasFullScan && (
<WarningRow tone="danger" text={t('sql_analysis.sidebar.warning.full_scan')} />
)}
</div>
{stats.hasFilesort && (
<WarningRow tone="warning" text={t('sql_analysis.sidebar.warning.filesort')} />
)}
{stats.hasTempTable && (
<WarningRow tone="info" text={t('sql_analysis.sidebar.warning.temp_table')} />
)}
{warnings && warnings.length > 0 && (
<ul className="gn-explain-warnings">
{warnings.map((warning, index) => (
<li key={`${warning}-${index}`}>{warning}</li>
))}
</ul>
)}
</section>
)
}
function WarningRow({ color, text }: { color: string; text: string }) {
function WarningRow({
tone,
text,
}: {
tone: 'danger' | 'warning' | 'info'
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 }} />
<div className={`gn-explain-warning gn-explain-warning--${tone}`}>
<span className="gn-explain-warning__dot" aria-hidden="true" />
{text}
</div>
)
}
function ExplainNodeDetail({ node }: { node: ExplainNode }) {
const { t } = useI18n()
const { language, t } = useI18n()
const titleId = useId()
const rows: Array<[string, string]> = []
rows.push([t('sql_analysis.sidebar.node.op_type'), node.opType])
rows.push([t('sql_analysis.sidebar.node.op_type'), formatExplainEnumLabel(node.opType)])
if (node.opDetail) rows.push([t('sql_analysis.sidebar.node.op_detail'), node.opDetail])
if (node.table) rows.push([t('sql_analysis.sidebar.node.table'), node.table])
if (node.index) rows.push([t('sql_analysis.sidebar.node.index'), node.index])
if (node.estRows) rows.push([t('sql_analysis.sidebar.node.est_rows'), formatNumber(node.estRows)])
if (node.actualRows) rows.push([t('sql_analysis.sidebar.node.actual_rows'), formatNumber(node.actualRows)])
if (node.loops) rows.push([t('sql_analysis.sidebar.node.loops'), formatNumber(node.loops)])
if (node.cost) rows.push([t('sql_analysis.sidebar.node.cost'), node.cost.toFixed(2)])
if (node.durationMs) rows.push([t('sql_analysis.sidebar.node.duration'), formatMs(node.durationMs)])
if (node.bufferHit !== undefined && node.bufferHit > 0)
rows.push([t('sql_analysis.sidebar.node.buffer_hit'), formatPercent(node.bufferHit)])
if (node.flags && node.flags.length > 0) rows.push([t('sql_analysis.sidebar.node.flags'), node.flags.join(', ')])
if (hasMetricValue(node.estRows)) {
rows.push([t('sql_analysis.sidebar.node.est_rows'), formatNumber(node.estRows, language)])
}
if (hasMetricValue(node.actualRows)) {
rows.push([t('sql_analysis.sidebar.node.actual_rows'), formatNumber(node.actualRows, language)])
}
if (hasMetricValue(node.loops)) {
rows.push([t('sql_analysis.sidebar.node.loops'), formatNumber(node.loops, language)])
}
if (hasMetricValue(node.cost)) {
rows.push([t('sql_analysis.sidebar.node.cost'), node.cost.toFixed(2)])
}
if (hasMetricValue(node.durationMs)) {
rows.push([t('sql_analysis.sidebar.node.duration'), formatMs(node.durationMs, language)])
}
if (hasMetricValue(node.bufferHit)) {
rows.push([t('sql_analysis.sidebar.node.buffer_hit'), formatPercent(node.bufferHit, language)])
}
if (node.flags && node.flags.length > 0) {
rows.push([
t('sql_analysis.sidebar.node.flags'),
node.flags.map((flag) => localizeExplainFlag(flag, t)).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 }}>{t('sql_analysis.sidebar.node.title')}</div>
<div style={{ fontSize: 12, display: 'flex', flexDirection: 'column', gap: 4 }}>
<section className="gn-explain-card" aria-labelledby={titleId}>
<h3 id={titleId} className="gn-explain-card__title">
{t('sql_analysis.sidebar.node.title')}
</h3>
<dl className="gn-explain-details">
{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 key={label} className="gn-explain-details__row">
<dt>{label}</dt>
<dd title={value}>{value}</dd>
</div>
))}
</div>
</dl>
{node.extra && Object.keys(node.extra).length > 0 && (
<details style={{ marginTop: 8, fontSize: 11 }}>
<summary style={{ cursor: 'pointer', color: 'var(--gn-text-muted, #6c757d)' }}>
<details className="gn-explain-extra">
<summary>
{t('sql_analysis.sidebar.node.extra', { count: Object.keys(node.extra).length })}
</summary>
<pre style={{ marginTop: 4, fontSize: 11, maxHeight: 120, overflow: 'auto' }}>
{JSON.stringify(node.extra, null, 2)}
</pre>
<pre>{JSON.stringify(node.extra, null, 2)}</pre>
</details>
)}
</div>
</section>
)
}
@@ -153,35 +188,31 @@ function IndexSuggestionList({
onSelect,
}: {
suggestions: IndexSuggestion[]
onSelect?: (s: IndexSuggestion) => void
onSelect?: (suggestion: IndexSuggestion) => void
}) {
const { t } = useI18n()
const titleId = useId()
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 }}>
<section className="gn-explain-card gn-explain-suggestions" aria-labelledby={titleId}>
<h3 id={titleId} className="gn-explain-card__title">
{t('sql_analysis.sidebar.suggestions.title', { count: suggestions.length })}
</div>
</h3>
{suggestions.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--gn-text-muted, #6c757d)', padding: '20px 0', textAlign: 'center' }}>
<div className="gn-explain-suggestions__empty">
{t('sql_analysis.sidebar.suggestions.empty')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{suggestions.map((s, idx) => (
<SuggestionCard key={`${s.rule}-${idx}`} suggestion={s} onSelect={onSelect} />
<div className="gn-explain-suggestions__list">
{suggestions.map((suggestion, index) => (
<SuggestionCard
key={`${suggestion.rule}-${suggestion.affectedNodeId ?? ''}-${index}`}
suggestion={suggestion}
onSelect={onSelect}
/>
))}
</div>
)}
</div>
</section>
)
}
@@ -190,51 +221,119 @@ function SuggestionCard({
onSelect,
}: {
suggestion: IndexSuggestion
onSelect?: (s: IndexSuggestion) => void
onSelect?: (suggestion: IndexSuggestion) => void
}) {
const { t } = useI18n()
const color = severityColor(suggestion.severity)
const { language, t } = useI18n()
const toneColor = resolveSuggestionTone(suggestion.severity)
const copyLabel = t('data_grid.toolbar.copy')
const copyIndexSql = async () => {
if (!suggestion.suggestedIndex) return
try {
if (typeof navigator?.clipboard?.writeText !== 'function') {
throw new Error(t('query_editor.results_panel.message.copy_unsupported'))
}
await navigator.clipboard.writeText(suggestion.suggestedIndex)
void message.success(t('data_grid.message.copied_to_clipboard'))
} catch {
void message.error(t('connection_modal.message.copy_failed'))
}
}
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}
<article className="gn-explain-suggestion" style={{ borderLeftColor: toneColor }}>
<button
type="button"
className="gn-explain-suggestion__select"
disabled={!onSelect}
onClick={() => onSelect?.(suggestion)}
>
<span className="gn-explain-suggestion__heading">
<span className="gn-explain-suggestion__severity" style={{ color: toneColor }}>
{localizeSuggestionSeverity(suggestion.severity, t)}
</span>
{hasMetricValue(suggestion.estRows) && (
<span className="gn-explain-suggestion__rows">
{t('sql_analysis.sidebar.suggestions.rows', {
count: formatNumber(suggestion.estRows, language),
})}
</span>
)}
</span>
{suggestion.estRows !== undefined && suggestion.estRows > 0 && (
<span style={{ color: 'var(--gn-text-muted, #6c757d)', fontSize: 11 }}>
{t('sql_analysis.sidebar.suggestions.rows', { count: formatNumber(suggestion.estRows) })}
<span className="gn-explain-suggestion__reason">{suggestion.reason}</span>
{suggestion.affectedTable && (
<span className="gn-explain-suggestion__table">
{t('sql_analysis.sidebar.suggestions.table', { table: '' }).trim()}{' '}
<code>{suggestion.affectedTable}</code>
</span>
)}
</div>
<div style={{ marginBottom: 4 }}>{suggestion.reason}</div>
</button>
{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)' }}>
{t('sql_analysis.sidebar.suggestions.table', { table: suggestion.affectedTable }).replace(suggestion.affectedTable, '')}
<code>{suggestion.affectedTable}</code>
<div className="gn-explain-suggestion__index">
<code title={suggestion.suggestedIndex}>{suggestion.suggestedIndex}</code>
<Tooltip title={copyLabel}>
<Button
type="text"
size="small"
className="gn-explain-suggestion__copy"
icon={<CopyOutlined />}
aria-label={copyLabel}
onClick={() => void copyIndexSql()}
/>
</Tooltip>
</div>
)}
</div>
</article>
)
}
export function hasMetricValue(value?: number): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
export function formatExplainEnumLabel(value: string): string {
const normalized = String(value || '')
.trim()
.toLocaleLowerCase()
.replace(/[_-]+/g, ' ')
return normalized ? normalized.charAt(0).toLocaleUpperCase() + normalized.slice(1) : '-'
}
export function localizeSuggestionSeverity(severity: string, t: Translate): string {
switch (severity) {
case 'critical':
return t('security_update.severity.high')
case 'warning':
return t('common.warning')
case 'info':
return t('data_sync.log.level.info')
default:
return formatExplainEnumLabel(severity)
}
}
export function localizeExplainFlag(flag: string, t: Translate): string {
switch (flag) {
case 'FULL_SCAN':
return t('sql_analysis.explain_graph.flag.full_scan')
case 'FILESORT':
return t('sql_analysis.explain_graph.flag.filesort')
case 'TEMP_TABLE':
return t('sql_analysis.explain_graph.flag.temp_table')
default:
return formatExplainEnumLabel(flag)
}
}
function resolveSuggestionTone(severity: string): string {
switch (severity) {
case 'critical':
return 'var(--gn-danger)'
case 'warning':
return 'var(--gn-warn)'
case 'info':
return 'var(--gn-info)'
default:
return 'var(--gn-fg-3)'
}
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { CSSProperties } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ApartmentOutlined, CodeOutlined } from '@ant-design/icons'
import { Empty, Modal, Segmented, Spin, Typography } from 'antd'
import { Alert, Button, Empty, Modal, Segmented, Spin, Typography, theme } from 'antd'
import { DiagnoseQuery } from '../../../wailsjs/go/app/App'
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'
import { useI18n } from '../../i18n/provider'
@@ -23,7 +24,7 @@ import ExplainSidebar from './ExplainSidebar'
// └──────────────────────────────┴──────────────────┘
// 底部 tab执行计划 | 原文(调试用)
const { Title, Text, Paragraph } = Typography
const { Title, Text } = Typography
interface ExplainWorkbenchProps {
open: boolean
@@ -42,36 +43,52 @@ interface ExplainReportViewProps {
export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReportViewProps) {
const { t } = useI18n()
const { token } = theme.useToken()
const [loading, setLoading] = useState(false)
const [report, setReport] = useState<DiagnoseReport | null>(null)
const [reportRevision, setReportRevision] = useState(0)
const [error, setError] = useState<string | null>(null)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [activeView, setActiveView] = useState<'plan' | 'raw'>('plan')
const hasRequestedRun = runKey !== null && runKey !== undefined && runKey !== ''
const requestSequenceRef = useRef(0)
const requestInputRef = useRef({ config, dbName, sql, t })
requestInputRef.current = { config, dbName, sql, t }
const runDiagnose = useCallback(async () => {
if (!sql.trim()) {
setError(t('sql_analysis.explain.error.query_required'))
const currentInput = requestInputRef.current
const requestSequence = ++requestSequenceRef.current
if (!currentInput.sql.trim()) {
setError(currentInput.t('sql_analysis.explain.error.query_required'))
return
}
setLoading(true)
setError(null)
setReport(null)
setSelectedNodeId(null)
try {
const result = await DiagnoseQuery(buildRpcConnectionConfig(config), dbName, sql)
const result = await DiagnoseQuery(
buildRpcConnectionConfig(currentInput.config),
currentInput.dbName,
currentInput.sql,
)
if (requestSequence !== requestSequenceRef.current) return
if (!result.success) {
setError(result.message || t('sql_analysis.explain.error.run_failed'))
setError(result.message || currentInput.t('sql_analysis.explain.error.run_failed'))
} else {
const data = result.data as DiagnoseReport
setReport(data)
setReportRevision((revision) => revision + 1)
}
} catch (cause) {
if (requestSequence === requestSequenceRef.current) {
setError(cause instanceof Error ? cause.message : String(cause))
}
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
if (requestSequence === requestSequenceRef.current) {
setLoading(false)
}
}
}, [config, dbName, sql, t])
}, [])
useEffect(() => {
if (!hasRequestedRun) {
@@ -80,6 +97,10 @@ export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReport
void runDiagnose()
}, [hasRequestedRun, runDiagnose, runKey])
useEffect(() => () => {
requestSequenceRef.current += 1
}, [])
useEffect(() => {
if (report) {
setActiveView('plan')
@@ -97,25 +118,56 @@ export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReport
}
}, [])
const reportStyle = {
'--gn-fg-1': token.colorText,
'--gn-fg-2': token.colorText,
'--gn-fg-3': token.colorTextSecondary,
'--gn-fg-4': token.colorTextTertiary,
'--gn-fg-5': token.colorTextQuaternary,
'--gn-bg-panel': token.colorBgContainer,
'--gn-bg-panel-2': token.colorFillQuaternary,
'--gn-bg-input': token.colorBgContainer,
'--gn-bg-hover': token.colorFillTertiary,
'--gn-bg-selected': token.colorPrimaryBg,
'--gn-br-1': token.colorBorderSecondary,
'--gn-br-2': token.colorBorder,
'--gn-br-3': token.colorBorder,
'--gn-shadow-sm': token.boxShadowTertiary,
'--gn-shadow-md': token.boxShadowSecondary,
'--gn-accent': token.colorPrimary,
'--gn-accent-soft': token.colorPrimaryBg,
'--gn-danger': token.colorError,
'--gn-warn': token.colorWarning,
'--gn-warn-soft': token.colorWarningBg,
'--gn-info': token.colorInfo,
'--gn-info-soft': token.colorInfoBg,
'--gn-font-mono': token.fontFamilyCode,
} as CSSProperties
return (
<div className="gn-explain-report-view">
<div className="gn-explain-report-view" style={reportStyle}>
<style>{reportViewStyles}</style>
{loading && (
{loading && !report && (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<Spin tip={t('sql_analysis.explain.loading')} />
</div>
)}
{error && (
<Paragraph type="danger" style={{ padding: 16 }}>
<Text strong>{t('sql_analysis.explain.error.title')}</Text>
{error}
</Paragraph>
<Alert
type="error"
showIcon
message={t('sql_analysis.explain.error.title')}
description={error}
action={<Button size="small" onClick={() => void runDiagnose()}>{t('sql_analysis.explain.action.retry')}</Button>}
style={{ marginBottom: 12 }}
/>
)}
{!loading && !error && !report && !hasRequestedRun && (
<Empty description={t('sql_analysis.explain.empty')} style={{ padding: '48px 0' }} />
)}
{!loading && !error && report && (
<div className="gn-explain-report-shell">
{!error && report && (
<Spin spinning={loading} tip={t('sql_analysis.explain.loading')} className="gn-explain-report-spinner">
<div className="gn-explain-report-shell">
<div className="gn-explain-report-switcher-row">
<Segmented
value={activeView}
@@ -154,6 +206,7 @@ export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReport
<div className="gn-explain-plan-view">
<div className="gn-explain-plan-graph">
<ExplainGraph
key={reportRevision}
nodes={report.plan.nodes}
edges={report.plan.edges ?? []}
selectedNodeId={selectedNodeId ?? undefined}
@@ -176,7 +229,8 @@ export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReport
height: '100%',
margin: 0,
overflow: 'auto',
background: 'var(--gn-code-bg, #f1f3f5)',
background: 'var(--gn-bg-panel-2, #f8fafc)',
color: 'var(--gn-fg-1, #111827)',
padding: 12,
borderRadius: 4,
fontSize: 12,
@@ -190,7 +244,8 @@ export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReport
</pre>
)}
</div>
</div>
</div>
</Spin>
)}
</div>
)
@@ -235,6 +290,14 @@ const reportViewStyles = `
flex-direction: column;
overflow: hidden;
}
.gn-explain-report-spinner {
flex: 1 1 auto;
min-height: 0;
}
.gn-explain-report-spinner > .ant-spin-container {
height: 100%;
min-height: 0;
}
.gn-explain-report-switcher-row {
flex: 0 0 auto;
display: flex;
@@ -300,4 +363,21 @@ const reportViewStyles = `
min-height: 0;
overflow-y: auto;
}
@media (max-width: 900px) {
.gn-explain-plan-view {
flex-direction: column;
overflow-y: auto;
}
.gn-explain-plan-graph {
width: 100%;
min-width: 0;
min-height: 360px;
flex: 0 0 min(55vh, 480px);
}
.gn-explain-plan-sidebar {
width: 100%;
flex: 0 0 auto;
overflow: visible;
}
}
`

View File

@@ -0,0 +1,254 @@
.gn-slow-query-panel {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 12px;
color: var(--slow-query-text);
}
.gn-slow-query-toolbar {
display: grid;
grid-template-columns: minmax(180px, 1fr) auto auto;
align-items: center;
gap: 10px;
}
.gn-slow-query-search {
min-width: 0;
}
.gn-slow-query-sort {
max-width: 100%;
overflow-x: auto;
}
.gn-slow-query-toolbar-actions,
.gn-slow-query-card-actions {
display: flex;
align-items: center;
gap: 8px;
}
.gn-slow-query-summary {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.gn-slow-query-scope-note {
display: flex;
align-items: flex-start;
gap: 7px;
font-size: 12px;
line-height: 1.5;
}
.gn-slow-query-scope-note > .anticon {
margin-top: 2px;
color: var(--slow-query-primary);
}
.gn-slow-query-summary > span {
padding: 3px 9px;
border: 1px solid var(--slow-query-border);
border-radius: 999px;
background: var(--slow-query-bg-subtle);
color: var(--slow-query-text-secondary);
font-size: 12px;
}
.gn-slow-query-alert {
flex: 0 0 auto;
}
.gn-slow-query-content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
scrollbar-gutter: stable;
}
.gn-slow-query-content > .ant-spin-nested-loading,
.gn-slow-query-content > .ant-spin-nested-loading > .ant-spin-container {
min-height: 100%;
}
.gn-slow-query-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr));
gap: 10px;
}
.gn-slow-query-card {
display: flex;
min-width: 0;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid var(--slow-query-border);
border-radius: var(--slow-query-radius);
background: var(--slow-query-bg);
box-shadow: var(--slow-query-shadow);
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.gn-slow-query-card:hover,
.gn-slow-query-card:focus-within {
border-color: var(--slow-query-border-hover);
}
.gn-slow-query-card-header,
.gn-slow-query-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.gn-slow-query-metrics,
.gn-slow-query-context {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 6px 12px;
color: var(--slow-query-text-secondary);
font-size: 12px;
}
.gn-slow-query-context {
justify-content: flex-end;
white-space: nowrap;
}
.gn-slow-query-duration {
color: var(--slow-query-text);
font-size: 14px;
}
.gn-slow-query-duration.is-critical {
color: var(--slow-query-error);
}
.gn-slow-query-duration.is-warning {
color: var(--slow-query-warning);
}
.gn-slow-query-sql {
display: -webkit-box;
min-height: 44px;
max-height: 96px;
margin: 0;
overflow: hidden;
color: var(--slow-query-text);
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.55;
overflow-wrap: anywhere;
white-space: pre-wrap;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.gn-slow-query-card-footer > .ant-typography {
margin: 0;
font-size: 12px;
}
.gn-slow-query-load-more {
margin-top: 12px;
}
.gn-slow-query-full-sql {
max-height: min(62vh, 640px);
margin: 0;
overflow: auto;
padding: 14px;
border: 1px solid var(--gn-br-1, #e5e7eb);
border-radius: 8px;
background: var(--gn-bg-panel-2, #f8fafc);
color: var(--gn-fg-1, #111827);
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.6;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.gn-slow-query-modal {
max-width: calc(100vw - 32px);
top: 24px;
}
.gn-slow-query-modal-title {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.gn-slow-query-modal-title > .anticon {
color: var(--ant-color-error, #ff4d4f);
}
.gn-slow-query-modal-title > .ant-typography {
margin: 0;
}
.gn-slow-query-modal-title > .ant-typography-secondary {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.gn-slow-query-modal-body {
height: min(68vh, 720px);
min-height: 420px;
}
@media (max-width: 860px) {
.gn-slow-query-toolbar {
grid-template-columns: minmax(0, 1fr) auto;
}
.gn-slow-query-sort {
grid-column: 1 / -1;
grid-row: 2;
}
.gn-slow-query-toolbar-actions {
grid-column: 2;
grid-row: 1;
}
.gn-slow-query-list {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.gn-slow-query-card-header,
.gn-slow-query-card-footer {
align-items: flex-start;
flex-direction: column;
}
.gn-slow-query-context,
.gn-slow-query-card-actions {
width: 100%;
justify-content: flex-start;
}
.gn-slow-query-card-actions > .ant-btn,
.gn-slow-query-card-actions > .ant-tooltip-disabled-compatible-wrapper {
flex: 1 1 0;
}
}
@media (prefers-reduced-motion: reduce) {
.gn-slow-query-card {
transition: none;
}
}

View File

@@ -1,37 +1,52 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Empty, Modal, Segmented, Spin, Tooltip, Typography, message } from 'antd'
import { ReloadOutlined, DeleteOutlined, ThunderboltOutlined } from '@ant-design/icons'
import type { CSSProperties } from 'react'
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import {
Alert,
Button,
Empty,
Input,
Modal,
Segmented,
Spin,
Tooltip,
Typography,
message,
theme,
} from 'antd'
import {
CopyOutlined,
DeleteOutlined,
EyeOutlined,
ImportOutlined,
InfoCircleOutlined,
ReloadOutlined,
SearchOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'
import { ClearSlowQueries, GetSlowQueries } from '../../../wailsjs/go/app/App'
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'
import { getDataSourceCapabilities } from '../../utils/dataSourceCapabilities'
import { useI18n } from '../../i18n/provider'
import type { ConnectionConfig } from '../../types'
import { formatMs, formatNumber } from '../../utils/explainTypes'
import {
buildSlowQuerySummary,
filterSlowQueryRecords,
getSlowQueryPreview,
getSlowQuerySql,
getVisibleSlowQueryRecords,
isSlowQueryRecordDiagnosable,
type SlowQueryRecord,
} from './slowQueryModel'
import './SlowQueryPanel.css'
// 慢 SQL 历史面板。
// 从 GetSlowQueries 加载 TopN按 duration / rowsRead / recent 切换排序。
// 点击条目可触发 onPickQuery把 SQL 带到外部工作台或编辑器。
//
// 设计要点:
// - 独立 Modal不依赖 Sidebar 内部布局Sidebar.tsx 已经 9000+ 行,避免污染)
// - 用户从 Sidebar 一个独立按钮触发
// - SQL 指纹去重由后端完成,前端只展示
const { Title, Text } = Typography
const INITIAL_VISIBLE_COUNT = 30
const VISIBLE_COUNT_STEP = 30
const SLOW_QUERY_THRESHOLD_MS = 500
const SLOW_QUERY_FETCH_LIMIT = 500
const { Title, Text, Paragraph } = Typography
type SortBy = 'duration' | 'rowsRead' | 'recent'
interface SlowQueryRecord {
id?: string
connectionFp?: string
sqlFp?: string
sqlPreview?: string
dbType?: string
durationMs?: number
rowsRead?: number
rowsReturned?: number
planHash?: string
executedAt?: string
}
type SortBy = 'duration' | 'frequency' | 'rowsReturned' | 'recent'
interface SlowQueryPanelProps {
open: boolean
@@ -54,105 +69,236 @@ export function SlowQueryPanelContent({
onPickQuery,
activeToken,
}: SlowQueryPanelContentProps) {
const { t } = useI18n()
const { t, language } = useI18n()
const { token } = theme.useToken()
const [loading, setLoading] = useState(false)
const [clearing, setClearing] = useState(false)
const [records, setRecords] = useState<SlowQueryRecord[]>([])
const [error, setError] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<SortBy>('duration')
const [keyword, setKeyword] = useState('')
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT)
const deferredKeyword = useDeferredValue(keyword)
const requestSequenceRef = useRef(0)
const supportsDiagnosis = getDataSourceCapabilities(config).supportsExplainDiagnosis
const reload = useCallback(async () => {
const requestSequence = ++requestSequenceRef.current
setLoading(true)
setError(null)
try {
const result = await GetSlowQueries(buildRpcConnectionConfig(config), dbName, sortBy, 100)
const result = await GetSlowQueries(
buildRpcConnectionConfig(config),
dbName,
sortBy,
SLOW_QUERY_FETCH_LIMIT,
)
if (requestSequence !== requestSequenceRef.current) return
if (!result.success) {
setError(result.message || t('sql_analysis.slow_query.error.load_failed'))
setRecords([])
} else {
setRecords((result.data as SlowQueryRecord[]) ?? [])
return
}
setRecords((result.data as SlowQueryRecord[]) ?? [])
setVisibleCount(INITIAL_VISIBLE_COUNT)
} catch (cause) {
if (requestSequence === requestSequenceRef.current) {
setError(cause instanceof Error ? cause.message : String(cause))
}
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
if (requestSequence === requestSequenceRef.current) {
setLoading(false)
}
}
}, [config, dbName, sortBy, t])
useEffect(() => {
if (activeToken === null || activeToken === undefined || activeToken === '') {
return
}
if (activeToken === null || activeToken === undefined || activeToken === '') return
void reload()
return () => {
requestSequenceRef.current += 1
}
}, [activeToken, reload])
const handleClear = useCallback(async () => {
const result = await ClearSlowQueries(buildRpcConnectionConfig(config), dbName)
if (result.success) {
message.success(t('sql_analysis.slow_query.message.cleared'))
setRecords([])
} else {
message.error(result.message || t('sql_analysis.slow_query.error.clear_failed'))
}
useEffect(() => {
setVisibleCount(INITIAL_VISIBLE_COUNT)
}, [deferredKeyword])
const [, setRelativeTimeTick] = useState(0)
useEffect(() => {
if (records.length === 0) return
const timer = window.setInterval(() => setRelativeTimeTick((tick) => tick + 1), 60_000)
return () => window.clearInterval(timer)
}, [records.length])
const handleClear = useCallback(() => {
Modal.confirm({
title: t('sql_analysis.slow_query.clear_confirm.title'),
content: t('sql_analysis.slow_query.clear_confirm.description'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
okButtonProps: { danger: true },
onOk: async () => {
setClearing(true)
try {
const result = await ClearSlowQueries(buildRpcConnectionConfig(config), dbName)
if (!result.success) {
throw new Error(result.message || t('sql_analysis.slow_query.error.clear_failed'))
}
requestSequenceRef.current += 1
setLoading(false)
setRecords([])
setError(null)
message.success(t('sql_analysis.slow_query.message.cleared'))
} catch (cause) {
const errorMessage = cause instanceof Error ? cause.message : String(cause)
message.error(errorMessage || t('sql_analysis.slow_query.error.clear_failed'))
throw cause
} finally {
setClearing(false)
}
},
})
}, [config, dbName, t])
const handlePick = useCallback(
(record: SlowQueryRecord) => {
if (record.sqlPreview && onPickQuery) {
onPickQuery(record.sqlPreview)
}
},
[onPickQuery],
const filteredRecords = useMemo(
() => filterSlowQueryRecords(records, deferredKeyword),
[deferredKeyword, records],
)
const visibleRecords = useMemo(
() => getVisibleSlowQueryRecords(filteredRecords, visibleCount),
[filteredRecords, visibleCount],
)
const summary = useMemo(() => buildSlowQuerySummary(filteredRecords), [filteredRecords])
const sorted = useMemo(() => records, [records]) // 后端已排序,前端不再排
const panelStyle = {
'--slow-query-bg': token.colorBgContainer,
'--slow-query-bg-subtle': token.colorFillQuaternary,
'--slow-query-border': token.colorBorderSecondary,
'--slow-query-border-hover': token.colorPrimaryBorder,
'--slow-query-text': token.colorText,
'--slow-query-text-secondary': token.colorTextSecondary,
'--slow-query-primary': token.colorPrimary,
'--slow-query-error': token.colorError,
'--slow-query-warning': token.colorWarning,
'--slow-query-radius': `${token.borderRadiusLG}px`,
'--slow-query-shadow': token.boxShadowTertiary,
} as CSSProperties
return (
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<section className="gn-slow-query-panel" style={panelStyle} aria-busy={loading}>
<div className="gn-slow-query-toolbar">
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
prefix={<SearchOutlined aria-hidden />}
placeholder={t('sql_analysis.slow_query.search.placeholder')}
aria-label={t('sql_analysis.slow_query.search.aria_label')}
name="slow-query-search"
autoComplete="off"
allowClear
className="gn-slow-query-search"
/>
<Segmented
value={sortBy}
onChange={(v) => setSortBy(v as SortBy)}
onChange={(value) => setSortBy(value as SortBy)}
aria-label={t('sql_analysis.slow_query.sort.aria_label')}
className="gn-slow-query-sort"
options={[
{ label: t('sql_analysis.slow_query.sort.duration'), value: 'duration' },
{ label: t('sql_analysis.slow_query.sort.rows_read'), value: 'rowsRead' },
{ label: t('sql_analysis.slow_query.sort.frequency'), value: 'frequency' },
{ label: t('sql_analysis.slow_query.sort.rows_returned'), value: 'rowsReturned' },
{ label: t('sql_analysis.slow_query.sort.recent'), value: 'recent' },
]}
/>
<div style={{ display: 'flex', gap: 8 }}>
<div className="gn-slow-query-toolbar-actions">
<Tooltip title={t('common.refresh')}>
<Button icon={<ReloadOutlined />} onClick={() => void reload()} loading={loading} />
<Button
icon={<ReloadOutlined />}
onClick={() => void reload()}
loading={loading}
aria-label={t('common.refresh')}
/>
</Tooltip>
<Tooltip title={t('sql_analysis.slow_query.tooltip.clear_current')}>
<Button danger icon={<DeleteOutlined />} onClick={() => void handleClear()} disabled={records.length === 0} />
<Button
danger
icon={<DeleteOutlined />}
onClick={handleClear}
loading={clearing}
disabled={records.length === 0}
aria-label={t('sql_analysis.slow_query.tooltip.clear_current')}
/>
</Tooltip>
</div>
</div>
{loading && (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin tip={t('sql_analysis.slow_query.loading')} />
<div className="gn-slow-query-scope-note">
<InfoCircleOutlined aria-hidden />
<Text type="secondary">
{t('sql_analysis.slow_query.scope_note', {
threshold: SLOW_QUERY_THRESHOLD_MS,
limit: SLOW_QUERY_FETCH_LIMIT,
})}
</Text>
</div>
{records.length > 0 && (
<div className="gn-slow-query-summary" aria-live="polite">
<span>{t('sql_analysis.slow_query.summary.statements', { count: summary.statementCount })}</span>
<span>{t('sql_analysis.slow_query.summary.executions', { count: summary.executionCount })}</span>
<span>{t('sql_analysis.slow_query.summary.max_duration', { duration: formatMs(summary.maxDurationMs, language) })}</span>
<span>{t('sql_analysis.slow_query.summary.rows_returned', { count: formatNumber(summary.rowsReturned, language) })}</span>
</div>
)}
{error && (
<Paragraph type="danger" style={{ padding: 16 }}>
<Text strong>{t('sql_analysis.slow_query.error.title')}</Text>
{error}
</Paragraph>
<Alert
type="error"
showIcon
closable
onClose={() => setError(null)}
message={t('sql_analysis.slow_query.error.title')}
description={error}
action={<Button size="small" onClick={() => void reload()}>{t('sql_analysis.slow_query.action.retry')}</Button>}
className="gn-slow-query-alert"
/>
)}
{!loading && !error && sorted.length === 0 && (
<Empty description={t('sql_analysis.slow_query.empty', { threshold: 500 })} style={{ padding: '40px 0' }} />
)}
{!loading && !error && sorted.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minHeight: 0, overflowY: 'auto' }}>
{sorted.map((r, idx) => (
<SlowQueryCard key={r.id ?? idx} record={r} onPick={() => handlePick(r)} />
))}
</div>
)}
</div>
<div className="gn-slow-query-content">
<Spin spinning={loading} tip={t('sql_analysis.slow_query.loading')}>
{!error && !loading && records.length === 0 && (
<Empty description={t('sql_analysis.slow_query.empty', { threshold: SLOW_QUERY_THRESHOLD_MS })} />
)}
{records.length > 0 && filteredRecords.length === 0 && (
<Empty description={t('sql_analysis.slow_query.search.empty')} />
)}
{visibleRecords.length > 0 && (
<div className="gn-slow-query-list" role="list">
{visibleRecords.map((record, index) => (
<SlowQueryCard
key={record.id ?? `${record.sqlFp ?? 'slow-query'}:${index}`}
record={record}
onPickQuery={onPickQuery}
supportsDiagnosis={supportsDiagnosis}
/>
))}
</div>
)}
</Spin>
{visibleRecords.length < filteredRecords.length && (
<Button
block
className="gn-slow-query-load-more"
onClick={() => setVisibleCount((count) => count + VISIBLE_COUNT_STEP)}
>
{t('sql_analysis.slow_query.action.load_more', {
shown: visibleRecords.length,
total: filteredRecords.length,
})}
</Button>
)}
</div>
</section>
)
}
@@ -163,20 +309,18 @@ export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQu
open={open}
onCancel={onClose}
footer={null}
width="70%"
style={{ top: 40 }}
width={1040}
className="gn-slow-query-modal"
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ThunderboltOutlined style={{ color: '#fa5252' }} />
<Title level={5} style={{ margin: 0 }}>{t('sql_analysis.slow_query.title')}</Title>
<Text type="secondary" style={{ fontSize: 12 }}>
{dbName || t('sql_analysis.slow_query.current_connection')}
</Text>
<div className="gn-slow-query-modal-title">
<ThunderboltOutlined aria-hidden />
<Title level={5}>{t('sql_analysis.slow_query.title')}</Title>
<Text type="secondary">{dbName || t('sql_analysis.slow_query.current_connection')}</Text>
</div>
}
destroyOnClose
>
<div style={{ minHeight: 480, height: '60vh' }}>
<div className="gn-slow-query-modal-body">
<SlowQueryPanelContent
config={config}
dbName={dbName}
@@ -191,73 +335,159 @@ export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQu
)
}
function SlowQueryCard({ record, onPick }: { record: SlowQueryRecord; onPick: () => void }) {
const { t } = useI18n()
const duration = record.durationMs ?? 0
const durationColor = duration >= 5000 ? '#fa5252' : duration >= 1000 ? '#f08c00' : '#495057'
function SlowQueryCard({
record,
onPickQuery,
supportsDiagnosis,
}: {
record: SlowQueryRecord
onPickQuery?: (sql: string) => void
supportsDiagnosis: boolean
}) {
const { t, language } = useI18n()
const { token } = theme.useToken()
const { sql, truncated } = getSlowQuerySql(record)
const sqlPreview = getSlowQueryPreview(record)
const [detailsOpen, setDetailsOpen] = useState(false)
const duration = record.maxDurationMs ?? record.durationMs ?? 0
const rowsRead = record.maxRowsRead ?? record.rowsRead ?? 0
const rowsReturned = record.maxRowsReturned ?? record.rowsReturned ?? 0
const executionCount = Math.max(1, Number(record.executionCount) || 0)
const severity = duration >= 5000 ? 'critical' : duration >= 1000 ? 'warning' : 'normal'
const recordDiagnosable = isSlowQueryRecordDiagnosable(record)
const canLoad = Boolean(sql && !truncated && supportsDiagnosis && recordDiagnosable && onPickQuery)
const diagnosisHint = truncated
? t('sql_analysis.slow_query.truncated')
: !supportsDiagnosis
? t('sql_analysis.slow_query.unsupported_diagnosis')
: !recordDiagnosable
? t('sql_analysis.slow_query.not_diagnosable')
: undefined
const handleCopy = useCallback(async () => {
if (!sql) return
try {
await navigator.clipboard.writeText(sql)
message.success(t('sql_analysis.slow_query.message.copied'))
} catch {
message.error(t('sql_analysis.slow_query.error.copy_failed'))
}
}, [sql, t])
return (
<div
onClick={onPick}
style={{
border: '1px solid var(--gn-border, #dee2e6)',
borderRadius: 4,
padding: 10,
background: 'var(--gn-card-bg, #f8f9fa)',
cursor: 'pointer',
fontSize: 12,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<span style={{ color: durationColor, fontWeight: 600 }}>{formatMs(duration)}</span>
{record.rowsRead !== undefined && record.rowsRead > 0 && (
<span style={{ color: 'var(--gn-text-muted, #6c757d)' }}>
{t('sql_analysis.slow_query.metric.rows_read')} <strong>{formatNumber(record.rowsRead)}</strong>
</span>
<article className="gn-slow-query-card" role="listitem">
<div className="gn-slow-query-card-header">
<div className="gn-slow-query-metrics">
<strong className={`gn-slow-query-duration is-${severity}`}>{formatMs(duration, language)}</strong>
{record.avgDurationMs !== undefined && (
<span>{t('sql_analysis.slow_query.metric.average_duration', { duration: formatMs(record.avgDurationMs, language) })}</span>
)}
{record.rowsReturned !== undefined && record.rowsReturned > 0 && (
<span style={{ color: 'var(--gn-text-muted, #6c757d)' }}>
{t('sql_analysis.slow_query.metric.rows_returned')} <strong>{formatNumber(record.rowsReturned)}</strong>
</span>
<span>{t('sql_analysis.slow_query.metric.executions', { count: executionCount })}</span>
{rowsRead > 0 && (
<span>{t('sql_analysis.slow_query.metric.rows_read')} <strong>{formatNumber(rowsRead, language)}</strong></span>
)}
{rowsReturned > 0 && (
<span>{t('sql_analysis.slow_query.metric.rows_returned')} <strong>{formatNumber(rowsReturned, language)}</strong></span>
)}
</div>
<div style={{ color: 'var(--gn-text-muted, #6c757d)' }}>
{record.dbType && <code style={{ marginRight: 8 }}>{record.dbType}</code>}
{record.executedAt && formatRelativeTime(record.executedAt, t)}
<div className="gn-slow-query-context">
{record.dbType && <code>{record.dbType}</code>}
{record.executedAt && (
<Tooltip title={formatAbsoluteTime(record.executedAt, language)}>
<time dateTime={record.executedAt}>{formatRelativeTime(record.executedAt, t)}</time>
</Tooltip>
)}
</div>
</div>
<pre
style={{
margin: 0,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
fontFamily: 'ui-monospace, Consolas, monospace',
fontSize: 11,
maxHeight: 80,
overflow: 'hidden',
}}
>
{record.sqlPreview || t('sql_analysis.slow_query.preview.empty')}
</pre>
</div>
<pre className="gn-slow-query-sql">{sqlPreview || t('sql_analysis.slow_query.preview.empty')}</pre>
<div className="gn-slow-query-card-footer">
{truncated ? (
<Text type="warning">{t('sql_analysis.slow_query.truncated')}</Text>
) : !supportsDiagnosis ? (
<Text type="secondary">{t('sql_analysis.slow_query.unsupported_diagnosis')}</Text>
) : !recordDiagnosable ? (
<Text type="secondary">{t('sql_analysis.slow_query.not_diagnosable')}</Text>
) : <span />}
<div className="gn-slow-query-card-actions">
{sql !== sqlPreview && (
<Button size="small" icon={<EyeOutlined />} onClick={() => setDetailsOpen(true)}>
{t('sql_analysis.slow_query.action.view_full')}
</Button>
)}
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => void handleCopy()}
disabled={!sql}
>
{t('sql_analysis.slow_query.action.copy')}
</Button>
<Tooltip title={diagnosisHint}>
<span>
<Button
size="small"
type="primary"
icon={<ImportOutlined />}
onClick={() => onPickQuery?.(sql)}
disabled={!canLoad}
>
{t('sql_analysis.slow_query.action.load')}
</Button>
</span>
</Tooltip>
</div>
</div>
{detailsOpen && (
<Modal
open
width={880}
title={t('sql_analysis.slow_query.details.title')}
onCancel={() => setDetailsOpen(false)}
footer={[
<Button key="copy" icon={<CopyOutlined />} onClick={() => void handleCopy()}>
{t('sql_analysis.slow_query.action.copy')}
</Button>,
<Button key="close" type="primary" onClick={() => setDetailsOpen(false)}>
{t('common.close')}
</Button>,
]}
destroyOnClose
>
<pre
className="gn-slow-query-full-sql"
style={{
background: token.colorFillQuaternary,
borderColor: token.colorBorderSecondary,
color: token.colorText,
}}
>
{sql}
</pre>
</Modal>
)}
</article>
)
}
// formatRelativeTime 把 ISO 时间字符串格式化为相对时间("3分钟前")。
function formatRelativeTime(
isoTime: string,
t: ReturnType<typeof useI18n>['t'],
): string {
const ts = Date.parse(isoTime)
if (isNaN(ts)) return ''
const diffMs = Date.now() - ts
function formatAbsoluteTime(isoTime: string, locale: string): string {
const timestamp = Date.parse(isoTime)
if (Number.isNaN(timestamp)) return isoTime
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'medium' }).format(timestamp)
}
function formatRelativeTime(isoTime: string, t: ReturnType<typeof useI18n>['t']): string {
const timestamp = Date.parse(isoTime)
if (Number.isNaN(timestamp)) return ''
const diffMs = Math.max(0, Date.now() - timestamp)
if (diffMs < 60_000) return t('sql_analysis.slow_query.relative.just_now')
if (diffMs < 3600_000) {
if (diffMs < 3_600_000) {
return t('sql_analysis.slow_query.relative.minutes_ago', { count: Math.floor(diffMs / 60_000) })
}
if (diffMs < 86400_000) {
return t('sql_analysis.slow_query.relative.hours_ago', { count: Math.floor(diffMs / 3600_000) })
if (diffMs < 86_400_000) {
return t('sql_analysis.slow_query.relative.hours_ago', { count: Math.floor(diffMs / 3_600_000) })
}
return t('sql_analysis.slow_query.relative.days_ago', { count: Math.floor(diffMs / 86400_000) })
return t('sql_analysis.slow_query.relative.days_ago', { count: Math.floor(diffMs / 86_400_000) })
}

View File

@@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Alert, Button, Input, Segmented, Typography, message } from 'antd'
import { HistoryOutlined, SearchOutlined } from '@ant-design/icons'
import { useStore } from '../../store'
import type { ConnectionConfig, TabData } from '../../types'
import { useI18n } from '../../i18n/provider'
import { getDataSourceCapabilities } from '../../utils/dataSourceCapabilities'
import { ExplainReportView } from './ExplainWorkbench'
import { SlowQueryPanelContent } from './SlowQueryPanel'
@@ -34,31 +35,49 @@ export default function SqlAnalysisWorkbench({ tab }: { tab: TabData }) {
() => (connection ? normalizeConnectionConfig(connection) : null),
[connection],
)
const supportsDiagnosis = connectionConfig
? getDataSourceCapabilities(connectionConfig).supportsExplainDiagnosis
: false
const dbName = String(tab.dbName || '').trim()
const [activeView, setActiveView] = useState<SqlAnalysisViewKey>(() => resolveRequestedView(tab))
const [sqlDraft, setSqlDraft] = useState(() => String(tab.query || ''))
const [submittedSql, setSubmittedSql] = useState(() => String(tab.query || ''))
const [diagnoseRunKey, setDiagnoseRunKey] = useState(0)
useEffect(() => {
const nextView = resolveRequestedView(tab)
const nextSql = String(tab.query || '')
setActiveView(nextView)
if (nextSql) {
setSqlDraft(nextSql)
}
setSqlDraft(nextSql)
if (nextView === 'diagnose' && nextSql.trim()) {
setSubmittedSql(nextSql)
setDiagnoseRunKey((previous) => previous + 1)
} else if (nextView === 'slow-query') {
setSubmittedSql('')
setDiagnoseRunKey(0)
}
}, [tab.query, tab.sqlAnalysisRequestKey, tab.sqlAnalysisView])
useEffect(() => {
if (!connectionConfig || supportsDiagnosis) return
setActiveView('slow-query')
setSubmittedSql('')
setDiagnoseRunKey(0)
}, [connectionConfig, supportsDiagnosis])
const triggerDiagnose = useCallback(() => {
if (!supportsDiagnosis) {
message.warning(t('sql_analysis.slow_query.unsupported_diagnosis'))
return
}
if (!sqlDraft.trim()) {
message.warning(t('sql_analysis.workbench.validation.sql_required'))
return
}
setActiveView('diagnose')
setSubmittedSql(sqlDraft)
setDiagnoseRunKey((previous) => previous + 1)
}, [sqlDraft, t])
}, [sqlDraft, supportsDiagnosis, t])
const handlePickSlowQuery = useCallback((sql: string) => {
const nextSql = String(sql || '')
@@ -66,10 +85,24 @@ export default function SqlAnalysisWorkbench({ tab }: { tab: TabData }) {
return
}
setSqlDraft(nextSql)
setSubmittedSql('')
setDiagnoseRunKey(0)
setActiveView('diagnose')
setDiagnoseRunKey((previous) => previous + 1)
}, [])
const handleViewChange = useCallback((value: string | number) => {
const nextView = value as SqlAnalysisViewKey
if (nextView === 'diagnose' && !supportsDiagnosis) {
message.warning(t('sql_analysis.slow_query.unsupported_diagnosis'))
return
}
if (nextView === 'diagnose') {
setSubmittedSql('')
setDiagnoseRunKey(0)
}
setActiveView(nextView)
}, [supportsDiagnosis, t])
const slowQueryLoadKey = useMemo(
() =>
activeView === 'slow-query' && connectionConfig
@@ -107,7 +140,7 @@ export default function SqlAnalysisWorkbench({ tab }: { tab: TabData }) {
</div>
<Segmented
value={activeView}
onChange={(value) => setActiveView(value as SqlAnalysisViewKey)}
onChange={handleViewChange}
className="gn-sql-analysis-view-switcher"
options={[
{
@@ -121,6 +154,7 @@ export default function SqlAnalysisWorkbench({ tab }: { tab: TabData }) {
},
{
value: 'diagnose',
disabled: !supportsDiagnosis,
label: (
<span className="gn-sql-analysis-view-switcher-label">
<SearchOutlined />
@@ -148,7 +182,17 @@ export default function SqlAnalysisWorkbench({ tab }: { tab: TabData }) {
<Input.TextArea
value={sqlDraft}
onChange={(event) => setSqlDraft(event.target.value)}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
event.preventDefault()
triggerDiagnose()
}
}}
placeholder={t('sql_analysis.workbench.editor.placeholder')}
aria-label={t('sql_analysis.workbench.editor.aria_label')}
name="sql-analysis-query"
autoComplete="off"
spellCheck={false}
autoSize={{ minRows: 5, maxRows: 10 }}
/>
<div className="gn-sql-analysis-editor-actions">
@@ -162,7 +206,7 @@ export default function SqlAnalysisWorkbench({ tab }: { tab: TabData }) {
<ExplainReportView
config={connectionConfig}
dbName={dbName}
sql={sqlDraft}
sql={submittedSql}
runKey={diagnoseRunKey > 0 ? diagnoseRunKey : null}
/>
</div>

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import {
buildSlowQuerySummary,
filterSlowQueryRecords,
getSlowQueryPreview,
getSlowQuerySql,
getVisibleSlowQueryRecords,
isSlowQueryRecordDiagnosable,
} from './slowQueryModel'
describe('slowQueryModel', () => {
it('prefers the complete SQL text and keeps legacy preview records loadable', () => {
expect(getSlowQuerySql({ sqlText: 'select * from orders', sqlPreview: 'select *' })).toEqual({
sql: 'select * from orders',
truncated: false,
})
expect(getSlowQuerySql({ sqlPreview: 'select 1' })).toEqual({ sql: 'select 1', truncated: false })
expect(getSlowQuerySql({ sqlPreview: 'select * from a very_long_table…' })).toEqual({
sql: 'select * from a very_long_table…',
truncated: true,
})
expect(getSlowQuerySql({ sqlPreview: 'select * from a very_long_table...' })).toEqual({
sql: 'select * from a very_long_table...',
truncated: true,
})
expect(getSlowQuerySql({ sqlText: 'select * fro', sqlTruncated: true })).toEqual({
sql: 'select * fro',
truncated: true,
})
})
it('filters records by SQL, database type and fingerprint without mutating their order', () => {
const records = [
{ id: '1', sqlText: 'SELECT * FROM orders', dbType: 'postgresql', sqlFp: 'fp-orders' },
{ id: '2', sqlPreview: 'SELECT * FROM users', dbType: 'mysql', sqlFp: 'fp-users' },
]
expect(filterSlowQueryRecords(records, ' ORDERS ')).toEqual([records[0]])
expect(filterSlowQueryRecords(records, 'MYSQL')).toEqual([records[1]])
expect(filterSlowQueryRecords(records, 'fp-users')).toEqual([records[1]])
expect(filterSlowQueryRecords(records, '')).toEqual(records)
})
it('summarizes aggregated and legacy records consistently', () => {
expect(buildSlowQuerySummary([
{ executionCount: 3, maxDurationMs: 2400, durationMs: 1800, rowsReturned: 20 },
{ durationMs: 900, maxRowsReturned: 80 },
])).toEqual({
statementCount: 2,
executionCount: 4,
maxDurationMs: 2400,
rowsReturned: 80,
})
})
it('renders the bounded preview by default even when a full SQL text is available', () => {
const longSql = `SELECT * FROM audit_log WHERE payload = '${'x'.repeat(1000)}'`
expect(getSlowQueryPreview({ sqlText: longSql, sqlPreview: 'SELECT * FROM audit_log…' })).toBe(
'SELECT * FROM audit_log…',
)
expect(getSlowQueryPreview({ sqlText: longSql }, 120)).toHaveLength(121)
expect(getSlowQueryPreview({ sqlText: longSql }, 120)).toMatch(/…$/)
})
it('limits the initial card count and supports incremental reveal', () => {
const records = Array.from({ length: 75 }, (_, index) => ({ id: String(index) }))
expect(getVisibleSlowQueryRecords(records, 30)).toHaveLength(30)
expect(getVisibleSlowQueryRecords(records, 60)).toHaveLength(60)
expect(getVisibleSlowQueryRecords(records, 100)).toHaveLength(75)
})
it('only loads newly classified single read-only statements for diagnosis', () => {
expect(isSlowQueryRecordDiagnosable({ sqlText: 'SELECT 1' })).toBe(true)
expect(isSlowQueryRecordDiagnosable({ sqlText: 'SELECT 1', statementCount: 1, diagnosable: true })).toBe(true)
expect(isSlowQueryRecordDiagnosable({ sqlText: 'UPDATE users SET active = 1', statementCount: 1, diagnosable: false })).toBe(false)
expect(isSlowQueryRecordDiagnosable({ sqlText: 'SELECT 1; SELECT 2', statementCount: 2, diagnosable: false })).toBe(false)
})
})

View File

@@ -0,0 +1,95 @@
export interface SlowQueryRecord {
id?: string
connectionFp?: string
sqlFp?: string
sqlText?: string
sqlPreview?: string
sqlTruncated?: boolean
diagnosable?: boolean
statementCount?: number
dbType?: string
durationMs?: number
maxDurationMs?: number
avgDurationMs?: number
executionCount?: number
rowsRead?: number
maxRowsRead?: number
rowsReturned?: number
maxRowsReturned?: number
planHash?: string
executedAt?: string
}
export interface SlowQuerySummary {
statementCount: number
executionCount: number
maxDurationMs: number
rowsReturned: number
}
export function getSlowQuerySql(record: SlowQueryRecord): { sql: string; truncated: boolean } {
const fullSql = String(record.sqlText || '').trim()
const preview = String(record.sqlPreview || '').trim()
const legacyPreviewLooksTruncated = record.sqlTruncated === undefined
&& !fullSql
&& /(?:…|\.\.\.)\s*$/.test(preview)
return {
sql: fullSql || preview,
truncated: record.sqlTruncated === true || legacyPreviewLooksTruncated,
}
}
export function getSlowQueryPreview(record: SlowQueryRecord, maxLength = 600): string {
const { sql } = getSlowQuerySql(record)
const preview = String(record.sqlPreview || '').trim() || sql
if (preview.length <= maxLength) return preview
return `${preview.slice(0, maxLength)}`
}
export function isSlowQueryRecordDiagnosable(record: SlowQueryRecord): boolean {
const statementCount = Number(record.statementCount) || 0
if (statementCount <= 0) return true
return statementCount === 1 && record.diagnosable !== false
}
export function filterSlowQueryRecords(
records: SlowQueryRecord[],
keyword: string,
): SlowQueryRecord[] {
const normalizedKeyword = keyword.trim().toLocaleLowerCase()
if (!normalizedKeyword) return records
return records.filter((record) => {
const { sql } = getSlowQuerySql(record)
return [sql, record.dbType, record.sqlFp]
.filter(Boolean)
.some((value) => String(value).toLocaleLowerCase().includes(normalizedKeyword))
})
}
export function buildSlowQuerySummary(records: SlowQueryRecord[]): SlowQuerySummary {
return records.reduce<SlowQuerySummary>((summary, record) => ({
statementCount: summary.statementCount + 1,
executionCount: summary.executionCount + Math.max(1, Number(record.executionCount) || 0),
maxDurationMs: Math.max(
summary.maxDurationMs,
Number(record.maxDurationMs) || Number(record.durationMs) || 0,
),
rowsReturned: Math.max(
summary.rowsReturned,
Number(record.maxRowsReturned) || Number(record.rowsReturned) || 0,
),
}), {
statementCount: 0,
executionCount: 0,
maxDurationMs: 0,
rowsReturned: 0,
})
}
export function getVisibleSlowQueryRecords(
records: SlowQueryRecord[],
visibleCount: number,
): SlowQueryRecord[] {
return records.slice(0, Math.max(0, visibleCount))
}

View File

@@ -0,0 +1,36 @@
.gn-slow-query-rail-button {
display: flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--gn-fg-2, #1f2937);
cursor: pointer;
transition: color 150ms ease, background-color 150ms ease;
}
.gn-slow-query-rail-button:hover:not(:disabled) {
background: var(--gn-accent-soft, #dcfce7);
color: var(--gn-accent-2, #15803d);
}
.gn-slow-query-rail-button:focus-visible {
outline: 2px solid var(--gn-accent-2, #15803d);
outline-offset: 2px;
}
.gn-slow-query-rail-button:disabled {
color: var(--gn-fg-5, #9ca3af);
cursor: not-allowed;
opacity: 0.55;
}
@media (prefers-reduced-motion: reduce) {
.gn-slow-query-rail-button {
transition: none;
}
}

View File

@@ -4,6 +4,7 @@ import { HistoryOutlined } from '@ant-design/icons'
import { useStore } from '../../store'
import { useI18n } from '../../i18n/provider'
import { buildSqlAnalysisWorkbenchTab } from '../../utils/sqlAnalysisTab'
import './SlowQueryRailButton.css'
// Sidebar 底部的慢 SQL 工作台入口。
//
@@ -69,7 +70,7 @@ export default function SlowQueryRailButton({
<Tooltip title={tooltipText} placement={tooltipPlacement}>
<button
type="button"
className={className}
className={['gn-slow-query-rail-button', className].filter(Boolean).join(' ')}
onClick={() => {
if (buttonDisabled || !activeTab?.connectionId) {
return
@@ -81,22 +82,7 @@ export default function SlowQueryRailButton({
}))
}}
disabled={buttonDisabled}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
border: 'none',
background: 'transparent',
cursor: buttonDisabled ? 'not-allowed' : 'pointer',
color: buttonDisabled
? 'var(--gn-text-muted, #adb5bd)'
: 'var(--gn-text, #495057)',
opacity: buttonDisabled ? 0.5 : 1,
transition: 'opacity 0.15s, color 0.15s',
...style,
}}
style={style}
aria-label={t('sql_analysis.slow_query.rail.aria_label')}
>
<HistoryOutlined style={{ fontSize: 16 }} />