mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 08:53:46 +08:00
✨ feat(sql-analysis): 完善慢 SQL 监控与执行计划诊断
- 完善慢查询采集、聚合、迁移与跨进程持久化 - 加固只读诊断边界并补齐多数据库执行计划解析 - 优化慢 SQL 面板、计划图、响应式布局与交互体验 - 补充后端、前端及 SQL Server 会话回归测试
This commit is contained in:
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
{
|
||||
|
||||
401
frontend/src/components/explain/ExplainAnalysis.css
Normal file
401
frontend/src/components/explain/ExplainAnalysis.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)'
|
||||
}
|
||||
}
|
||||
|
||||
63
frontend/src/components/explain/ExplainGraphSidebar.test.ts
Normal file
63
frontend/src/components/explain/ExplainGraphSidebar.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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)'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
254
frontend/src/components/explain/SlowQueryPanel.css
Normal file
254
frontend/src/components/explain/SlowQueryPanel.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
78
frontend/src/components/explain/slowQueryModel.test.ts
Normal file
78
frontend/src/components/explain/slowQueryModel.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
95
frontend/src/components/explain/slowQueryModel.ts
Normal file
95
frontend/src/components/explain/slowQueryModel.ts
Normal 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))
|
||||
}
|
||||
36
frontend/src/components/sidebar/SlowQueryRailButton.css
Normal file
36
frontend/src/components/sidebar/SlowQueryRailButton.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 }} />
|
||||
|
||||
Reference in New Issue
Block a user