mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-27 16:59:23 +08:00
✨ feat(explain-ui): 新增慢 SQL 历史面板与 Ctrl+Shift+H 快捷键
- 面板组件:SlowQueryPanel 含 TopN 列表 + 耗时/扫描行数/时间三种排序 + 清空操作 - 入口接入:QueryEditor 加 Ctrl+Shift+H 快捷键,点击条目回填 SQL 到编辑器 - Wails 绑定:手动同步 GetSlowQueries 与 ClearSlowQueries 到 App.js/App.d.ts - 颜色提示:耗时 >5s 红色、>1s 橙色,便于一眼识别重查询
This commit is contained in:
@@ -33,6 +33,8 @@ import { SIDEBAR_SQL_EDITOR_DRAG_MIME, decodeSidebarSqlEditorDragPayload, hasSid
|
||||
import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert';
|
||||
// SQL 诊断工作台:lazy 加载避免 reactflow/dagre 进入主 bundle(约 130KB gzipped 独立 chunk)
|
||||
const ExplainWorkbench = lazy(() => import('./explain/ExplainWorkbench'));
|
||||
// 慢 SQL 历史面板:lazy 加载
|
||||
const SlowQueryPanel = lazy(() => import('./explain/SlowQueryPanel'));
|
||||
import { SUPPORTED_LANGUAGES, t as translate } from '../i18n';
|
||||
import {
|
||||
DUCKDB_ROWID_LOCATOR_COLUMN,
|
||||
@@ -2211,6 +2213,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
|
||||
// SQL 诊断工作台:Ctrl+Shift+D 触发(Mac 为 Cmd+Shift+D)
|
||||
const [explainOpen, setExplainOpen] = useState(false);
|
||||
// 慢 SQL 历史:Ctrl+Shift+H 触发
|
||||
const [slowQueryOpen, setSlowQueryOpen] = useState(false);
|
||||
|
||||
// Database Selection
|
||||
const [currentConnectionId, setCurrentConnectionId] = useState<string>(tab.connectionId);
|
||||
@@ -2274,9 +2278,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'D' || e.key === 'd')) {
|
||||
if (!(e.ctrlKey || e.metaKey) || !e.shiftKey) return;
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === 'd') {
|
||||
e.preventDefault();
|
||||
setExplainOpen(true);
|
||||
} else if (key === 'h') {
|
||||
e.preventDefault();
|
||||
setSlowQueryOpen(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
@@ -6157,6 +6166,19 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
|
||||
{/* 慢 SQL 历史:Ctrl+Shift+H 触发 */}
|
||||
<Suspense fallback={null}>
|
||||
{slowQueryOpen && explainConfig && (
|
||||
<SlowQueryPanel
|
||||
open={slowQueryOpen}
|
||||
onClose={() => setSlowQueryOpen(false)}
|
||||
config={explainConfig}
|
||||
dbName={currentDb}
|
||||
onPickQuery={(sql) => setQuery(sql)}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
222
frontend/src/components/explain/SlowQueryPanel.tsx
Normal file
222
frontend/src/components/explain/SlowQueryPanel.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
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 { ClearSlowQueries, GetSlowQueries } from '../../../wailsjs/go/app/App'
|
||||
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'
|
||||
import type { ConnectionConfig } from '../../types'
|
||||
import { formatMs, formatNumber } from '../../utils/explainTypes'
|
||||
|
||||
// 慢 SQL 历史面板。
|
||||
// 从 GetSlowQueries 加载 TopN,按 duration / rowsRead / recent 切换排序。
|
||||
// 点击条目可触发 onPickQuery 把 SQL 回填到 QueryEditor。
|
||||
//
|
||||
// 设计要点:
|
||||
// - 独立 Modal,不依赖 Sidebar 内部布局(Sidebar.tsx 已经 9000+ 行,避免污染)
|
||||
// - 用户从 Sidebar 一个独立按钮触发
|
||||
// - SQL 指纹去重由后端完成,前端只展示
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
interface SlowQueryPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: ConnectionConfig
|
||||
dbName: string
|
||||
onPickQuery?: (sql: string) => void
|
||||
}
|
||||
|
||||
export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQuery }: SlowQueryPanelProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [records, setRecords] = useState<SlowQueryRecord[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [sortBy, setSortBy] = useState<SortBy>('duration')
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await GetSlowQueries(buildRpcConnectionConfig(config), dbName, sortBy, 100)
|
||||
if (!result.success) {
|
||||
setError(result.message || '加载失败')
|
||||
setRecords([])
|
||||
} else {
|
||||
setRecords((result.data as SlowQueryRecord[]) ?? [])
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [config, dbName, sortBy])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
void reload()
|
||||
}
|
||||
}, [open, reload])
|
||||
|
||||
const handleClear = useCallback(async () => {
|
||||
const result = await ClearSlowQueries(buildRpcConnectionConfig(config), dbName)
|
||||
if (result.success) {
|
||||
message.success('已清空慢查询历史')
|
||||
setRecords([])
|
||||
} else {
|
||||
message.error(result.message || '清空失败')
|
||||
}
|
||||
}, [config, dbName])
|
||||
|
||||
const handlePick = useCallback(
|
||||
(record: SlowQueryRecord) => {
|
||||
if (record.sqlPreview && onPickQuery) {
|
||||
onPickQuery(record.sqlPreview)
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[onPickQuery, onClose],
|
||||
)
|
||||
|
||||
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={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<Segmented
|
||||
value={sortBy}
|
||||
onChange={(v) => setSortBy(v as SortBy)}
|
||||
options={[
|
||||
{ label: '按耗时', value: 'duration' },
|
||||
{ label: '按扫描行数', value: 'rowsRead' },
|
||||
{ label: '按时间', value: 'recent' },
|
||||
]}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Tooltip title="刷新">
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void reload()} loading={loading} />
|
||||
</Tooltip>
|
||||
<Tooltip title="清空当前连接的历史">
|
||||
<Button danger icon={<DeleteOutlined />} onClick={() => void handleClear()} disabled={records.length === 0} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Spin tip="加载慢查询历史..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Paragraph type="danger" style={{ padding: 16 }}>
|
||||
<Text strong>加载失败:</Text>
|
||||
{error}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{!loading && !error && sorted.length === 0 && (
|
||||
<Empty description="暂无慢查询记录(阈值 500ms)" style={{ padding: '40px 0' }} />
|
||||
)}
|
||||
|
||||
{!loading && !error && sorted.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{sorted.map((r, idx) => (
|
||||
<SlowQueryCard key={r.id ?? idx} record={r} onPick={() => handlePick(r)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function SlowQueryCard({ record, onPick }: { record: SlowQueryRecord; onPick: () => void }) {
|
||||
const duration = record.durationMs ?? 0
|
||||
const durationColor = duration >= 5000 ? '#fa5252' : duration >= 1000 ? '#f08c00' : '#495057'
|
||||
|
||||
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)' }}>
|
||||
扫描 <strong>{formatNumber(record.rowsRead)}</strong>
|
||||
</span>
|
||||
)}
|
||||
{record.rowsReturned !== undefined && record.rowsReturned > 0 && (
|
||||
<span style={{ color: 'var(--gn-text-muted, #6c757d)' }}>
|
||||
返回 <strong>{formatNumber(record.rowsReturned)}</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)}
|
||||
</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 || '(无 SQL 预览)'}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// formatRelativeTime 把 ISO 时间字符串格式化为相对时间("3分钟前")。
|
||||
function formatRelativeTime(isoTime: string): string {
|
||||
const ts = Date.parse(isoTime)
|
||||
if (isNaN(ts)) return ''
|
||||
const diffMs = Date.now() - ts
|
||||
if (diffMs < 60_000) return '刚刚'
|
||||
if (diffMs < 3600_000) return `${Math.floor(diffMs / 60_000)} 分钟前`
|
||||
if (diffMs < 86400_000) return `${Math.floor(diffMs / 3600_000)} 小时前`
|
||||
return `${Math.floor(diffMs / 86400_000)} 天前`
|
||||
}
|
||||
Reference in New Issue
Block a user