feat(query-editor): 收敛 SQL 分析工作台与结果区日志体验

- 新增 SQL 分析工作台,统一承载慢 SQL 和 SQL 诊断视图
- 将 SQL 执行日志收进结果区首个日志标签并在失败时展示错误摘要
- 调整侧边栏入口、标签展示、多语言文案与定向前端测试覆盖
This commit is contained in:
Syngnat
2026-06-20 14:09:58 +08:00
parent 04019135a0
commit c8c8497a2f
26 changed files with 1539 additions and 539 deletions

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Modal, Spin, Tabs, Typography } from 'antd'
import { ApartmentOutlined, CodeOutlined } from '@ant-design/icons'
import { Empty, Modal, Segmented, Spin, Typography } from 'antd'
import { DiagnoseQuery } from '../../../wailsjs/go/app/App'
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'
import type { ConnectionConfig } from '../../types'
@@ -31,11 +32,20 @@ interface ExplainWorkbenchProps {
sql: string
}
export default function ExplainWorkbench({ open, onClose, config, dbName, sql }: ExplainWorkbenchProps) {
interface ExplainReportViewProps {
config: ConnectionConfig
dbName: string
sql: string
runKey?: string | number | null
}
export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReportViewProps) {
const [loading, setLoading] = useState(false)
const [report, setReport] = useState<DiagnoseReport | null>(null)
const [error, setError] = useState<string | null>(null)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [activeView, setActiveView] = useState<'plan' | 'raw'>('plan')
const hasRequestedRun = runKey !== null && runKey !== undefined && runKey !== ''
const runDiagnose = useCallback(async () => {
if (!sql.trim()) {
@@ -62,10 +72,17 @@ export default function ExplainWorkbench({ open, onClose, config, dbName, sql }:
}, [config, dbName, sql])
useEffect(() => {
if (open) {
void runDiagnose()
if (!hasRequestedRun) {
return
}
}, [open, runDiagnose])
void runDiagnose()
}, [hasRequestedRun, runDiagnose, runKey])
useEffect(() => {
if (report) {
setActiveView('plan')
}
}, [report])
const selectedNode = useMemo<ExplainNode | undefined>(() => {
if (!report || !selectedNodeId) return undefined
@@ -78,6 +95,106 @@ export default function ExplainWorkbench({ open, onClose, config, dbName, sql }:
}
}, [])
return (
<div className="gn-explain-report-view">
<style>{reportViewStyles}</style>
{loading && (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<Spin tip="正在执行 EXPLAIN 并解析计划..." />
</div>
)}
{error && (
<Paragraph type="danger" style={{ padding: 16 }}>
<Text strong></Text>
{error}
</Paragraph>
)}
{!loading && !error && !report && !hasRequestedRun && (
<Empty description="输入 SQL 后运行诊断" style={{ padding: '48px 0' }} />
)}
{!loading && !error && report && (
<div className="gn-explain-report-shell">
<div className="gn-explain-report-switcher-row">
<Segmented
value={activeView}
onChange={(value) => setActiveView(value as 'plan' | 'raw')}
className="gn-explain-report-switcher"
options={[
{
value: 'plan',
label: (
<span className="gn-explain-report-switcher-label">
<ApartmentOutlined />
<span></span>
</span>
),
},
{
value: 'raw',
label: (
<span className="gn-explain-report-switcher-label">
<CodeOutlined />
<span></span>
</span>
),
},
]}
/>
<Text type="secondary" className="gn-explain-report-switcher-meta">
{report.plan.nodes.length}
<span className="gn-explain-report-switcher-meta-separator">/</span>
{report.plan.rawFormat}
</Text>
</div>
<div className="gn-explain-report-content">
{activeView === 'plan' ? (
<div className="gn-explain-plan-view">
<div className="gn-explain-plan-graph">
<ExplainGraph
nodes={report.plan.nodes}
edges={report.plan.edges ?? []}
selectedNodeId={selectedNodeId ?? undefined}
onSelectNode={setSelectedNodeId}
/>
</div>
<div className="gn-explain-plan-sidebar">
<ExplainSidebar
stats={report.plan.stats}
warnings={report.plan.warnings}
suggestions={report.suggestions ?? []}
selectedNode={selectedNode}
onSelectSuggestion={handleSelectSuggestion}
/>
</div>
</div>
) : (
<pre
style={{
height: '100%',
margin: 0,
overflow: 'auto',
background: 'var(--gn-code-bg, #f1f3f5)',
padding: 12,
borderRadius: 4,
fontSize: 12,
fontFamily: 'ui-monospace, Consolas, monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
boxSizing: 'border-box',
}}
>
{report.plan.rawPayload || '(无原文)'}
</pre>
)}
</div>
</div>
)}
</div>
)
}
export default function ExplainWorkbench({ open, onClose, config, dbName, sql }: ExplainWorkbenchProps) {
return (
<Modal
open={open}
@@ -88,71 +205,96 @@ export default function ExplainWorkbench({ open, onClose, config, dbName, sql }:
title={<Title level={5} style={{ margin: 0 }}>SQL </Title>}
destroyOnClose
>
<div style={{ minHeight: 480 }}>
{loading && (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<Spin tip="正在执行 EXPLAIN 并解析计划..." />
</div>
)}
{error && (
<Paragraph type="danger" style={{ padding: 16 }}>
<Text strong></Text>
{error}
</Paragraph>
)}
{!loading && !error && report && (
<Tabs
items={[
{
key: 'plan',
label: `执行计划(${report.plan.nodes.length} 节点)`,
children: (
<div style={{ display: 'flex', gap: 12, height: '70vh', minHeight: 400 }}>
<div style={{ flex: 1, minWidth: 400, position: 'relative' }}>
<ExplainGraph
nodes={report.plan.nodes}
edges={report.plan.edges ?? []}
selectedNodeId={selectedNodeId ?? undefined}
onSelectNode={setSelectedNodeId}
/>
</div>
<div style={{ width: 320, flexShrink: 0, overflowY: 'auto', maxHeight: '70vh' }}>
<ExplainSidebar
stats={report.plan.stats}
warnings={report.plan.warnings}
suggestions={report.suggestions ?? []}
selectedNode={selectedNode}
onSelectSuggestion={handleSelectSuggestion}
/>
</div>
</div>
),
},
{
key: 'raw',
label: `原文(${report.plan.rawFormat}`,
children: (
<pre
style={{
maxHeight: '60vh',
overflow: 'auto',
background: 'var(--gn-code-bg, #f1f3f5)',
padding: 12,
borderRadius: 4,
fontSize: 12,
fontFamily: 'ui-monospace, Consolas, monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{report.plan.rawPayload || '(无原文)'}
</pre>
),
},
]}
/>
)}
<div style={{ minHeight: 480, height: '70vh' }}>
<ExplainReportView
config={config}
dbName={dbName}
sql={sql}
runKey={open ? `${dbName}::${sql}` : null}
/>
</div>
</Modal>
)
}
const reportViewStyles = `
.gn-explain-report-view {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.gn-explain-report-shell {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.gn-explain-report-switcher-row {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.gn-explain-report-switcher {
flex: 0 0 auto;
}
.gn-explain-report-switcher-label {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 88px;
white-space: nowrap;
}
.gn-explain-report-switcher .ant-segmented-group {
display: inline-flex;
align-items: center;
}
.gn-explain-report-switcher .ant-segmented-item {
min-height: 30px;
}
.gn-explain-report-switcher .ant-segmented-item-label {
padding: 5px 12px;
font-size: 13px;
line-height: 20px;
}
.gn-explain-report-switcher-meta {
flex: 0 0 auto;
white-space: nowrap;
}
.gn-explain-report-switcher-meta-separator {
display: inline-block;
margin: 0 6px;
}
.gn-explain-report-content {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.gn-explain-plan-view {
height: 100%;
min-height: 0;
display: flex;
gap: 12px;
}
.gn-explain-plan-graph {
flex: 1 1 auto;
min-width: 320px;
min-height: 0;
position: relative;
}
.gn-explain-plan-sidebar {
width: 320px;
flex: 0 0 320px;
min-height: 0;
overflow-y: auto;
}
`

View File

@@ -8,7 +8,7 @@ import { formatMs, formatNumber } from '../../utils/explainTypes'
// 慢 SQL 历史面板。
// 从 GetSlowQueries 加载 TopN按 duration / rowsRead / recent 切换排序。
// 点击条目可触发 onPickQuery 把 SQL 回填到 QueryEditor
// 点击条目可触发 onPickQuery把 SQL 带到外部工作台或编辑器
//
// 设计要点:
// - 独立 Modal不依赖 Sidebar 内部布局Sidebar.tsx 已经 9000+ 行,避免污染)
@@ -40,7 +40,19 @@ interface SlowQueryPanelProps {
onPickQuery?: (sql: string) => void
}
export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQuery }: SlowQueryPanelProps) {
interface SlowQueryPanelContentProps {
config: ConnectionConfig
dbName: string
onPickQuery?: (sql: string) => void
activeToken?: string | number | null
}
export function SlowQueryPanelContent({
config,
dbName,
onPickQuery,
activeToken,
}: SlowQueryPanelContentProps) {
const [loading, setLoading] = useState(false)
const [records, setRecords] = useState<SlowQueryRecord[]>([])
const [error, setError] = useState<string | null>(null)
@@ -65,10 +77,11 @@ export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQu
}, [config, dbName, sortBy])
useEffect(() => {
if (open) {
void reload()
if (activeToken === null || activeToken === undefined || activeToken === '') {
return
}
}, [open, reload])
void reload()
}, [activeToken, reload])
const handleClear = useCallback(async () => {
const result = await ClearSlowQueries(buildRpcConnectionConfig(config), dbName)
@@ -84,32 +97,15 @@ export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQu
(record: SlowQueryRecord) => {
if (record.sqlPreview && onPickQuery) {
onPickQuery(record.sqlPreview)
onClose()
}
},
[onPickQuery, onClose],
[onPickQuery],
)
const sorted = useMemo(() => records, [records]) // 后端已排序,前端不再排
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width="70%"
style={{ top: 40 }}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ThunderboltOutlined style={{ color: '#fa5252' }} />
<Title level={5} style={{ margin: 0 }}> SQL </Title>
<Text type="secondary" style={{ fontSize: 12 }}>
{dbName || '(当前连接)'}
</Text>
</div>
}
destroyOnClose
>
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Segmented
value={sortBy}
@@ -148,12 +144,46 @@ export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQu
)}
{!loading && !error && sorted.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: '60vh', overflowY: 'auto' }}>
<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>
)
}
export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQuery }: SlowQueryPanelProps) {
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width="70%"
style={{ top: 40 }}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ThunderboltOutlined style={{ color: '#fa5252' }} />
<Title level={5} style={{ margin: 0 }}> SQL </Title>
<Text type="secondary" style={{ fontSize: 12 }}>
{dbName || '(当前连接)'}
</Text>
</div>
}
destroyOnClose
>
<div style={{ minHeight: 480, height: '60vh' }}>
<SlowQueryPanelContent
config={config}
dbName={dbName}
activeToken={open ? `${dbName}:open` : null}
onPickQuery={(sql) => {
onPickQuery?.(sql)
onClose()
}}
/>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,255 @@
import React, { 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 { ExplainReportView } from './ExplainWorkbench'
import { SlowQueryPanelContent } from './SlowQueryPanel'
const { Title, Text } = Typography
type SqlAnalysisViewKey = 'diagnose' | 'slow-query'
const resolveRequestedView = (tab: TabData): SqlAnalysisViewKey =>
tab.sqlAnalysisView === 'slow-query' ? 'slow-query' : 'diagnose'
const normalizeConnectionConfig = (connection: any): ConnectionConfig => ({
...connection.config,
port: Number(connection.config.port),
password: connection.config.password || '',
database: connection.config.database || '',
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
})
export default function SqlAnalysisWorkbench({ tab }: { tab: TabData }) {
const connections = useStore((state) => state.connections)
const connection = useMemo(
() => connections.find((item) => item.id === tab.connectionId) || null,
[connections, tab.connectionId],
)
const connectionConfig = useMemo(
() => (connection ? normalizeConnectionConfig(connection) : null),
[connection],
)
const dbName = String(tab.dbName || '').trim()
const [activeView, setActiveView] = useState<SqlAnalysisViewKey>(() => resolveRequestedView(tab))
const [sqlDraft, setSqlDraft] = 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)
}
if (nextView === 'diagnose' && nextSql.trim()) {
setDiagnoseRunKey((previous) => previous + 1)
}
}, [tab.query, tab.sqlAnalysisRequestKey, tab.sqlAnalysisView])
const triggerDiagnose = useCallback(() => {
if (!sqlDraft.trim()) {
message.warning('请输入要诊断的 SQL')
return
}
setActiveView('diagnose')
setDiagnoseRunKey((previous) => previous + 1)
}, [sqlDraft])
const handlePickSlowQuery = useCallback((sql: string) => {
const nextSql = String(sql || '')
if (!nextSql.trim()) {
return
}
setSqlDraft(nextSql)
setActiveView('diagnose')
setDiagnoseRunKey((previous) => previous + 1)
}, [])
const slowQueryLoadKey = useMemo(
() =>
activeView === 'slow-query' && connectionConfig
? `${tab.sqlAnalysisRequestKey || 'slow-query'}:${tab.connectionId}:${dbName}`
: null,
[activeView, connectionConfig, dbName, tab.connectionId, tab.sqlAnalysisRequestKey],
)
if (!connectionConfig) {
return (
<div className="gn-sql-analysis-workbench">
<style>{workbenchStyles}</style>
<Alert
type="warning"
showIcon
message="当前工作台对应的连接已不可用"
description="请重新选择一个有效连接后再打开 SQL 分析工作台。"
/>
</div>
)
}
return (
<div className="gn-sql-analysis-workbench">
<style>{workbenchStyles}</style>
<div className="gn-sql-analysis-workbench-header">
<div className="gn-sql-analysis-workbench-header-main">
<Title level={5} style={{ margin: 0 }}>
SQL
</Title>
<Text type="secondary">
{connection?.name || tab.connectionId}
{dbName ? ` / ${dbName}` : ''}
</Text>
</div>
<Segmented
value={activeView}
onChange={(value) => setActiveView(value as SqlAnalysisViewKey)}
className="gn-sql-analysis-view-switcher"
options={[
{
value: 'slow-query',
label: (
<span className="gn-sql-analysis-view-switcher-label">
<HistoryOutlined />
<span> SQL</span>
</span>
),
},
{
value: 'diagnose',
label: (
<span className="gn-sql-analysis-view-switcher-label">
<SearchOutlined />
<span>SQL </span>
</span>
),
},
]}
/>
</div>
<div className="gn-sql-analysis-workbench-body">
{activeView === 'slow-query' ? (
<div className="gn-sql-analysis-pane">
<SlowQueryPanelContent
config={connectionConfig}
dbName={dbName}
onPickQuery={handlePickSlowQuery}
activeToken={slowQueryLoadKey}
/>
</div>
) : (
<div className="gn-sql-analysis-pane">
<div className="gn-sql-analysis-editor-block">
<Input.TextArea
value={sqlDraft}
onChange={(event) => setSqlDraft(event.target.value)}
placeholder="输入要诊断的 SQL或从慢 SQL 列表点击条目带入"
autoSize={{ minRows: 5, maxRows: 10 }}
/>
<div className="gn-sql-analysis-editor-actions">
<Text type="secondary"> SQL </Text>
<Button type="primary" icon={<SearchOutlined />} onClick={triggerDiagnose}>
</Button>
</div>
</div>
<div className="gn-sql-analysis-report-shell">
<ExplainReportView
config={connectionConfig}
dbName={dbName}
sql={sqlDraft}
runKey={diagnoseRunKey > 0 ? diagnoseRunKey : null}
/>
</div>
</div>
)}
</div>
</div>
)
}
const workbenchStyles = `
.gn-sql-analysis-workbench {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
overflow: hidden;
box-sizing: border-box;
}
.gn-sql-analysis-workbench-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.gn-sql-analysis-workbench-header-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.gn-sql-analysis-view-switcher {
flex: 0 0 auto;
align-self: flex-start;
}
.gn-sql-analysis-view-switcher-label {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 88px;
white-space: nowrap;
}
.gn-sql-analysis-view-switcher .ant-segmented-group {
display: inline-flex;
align-items: center;
}
.gn-sql-analysis-view-switcher .ant-segmented-item {
min-height: 30px;
}
.gn-sql-analysis-view-switcher .ant-segmented-item-label {
padding: 5px 12px;
font-size: 13px;
line-height: 20px;
}
.gn-sql-analysis-workbench-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.gn-sql-analysis-pane {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.gn-sql-analysis-editor-block {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
.gn-sql-analysis-editor-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.gn-sql-analysis-report-shell {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
`