diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 11961e4..6d0461d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,6 @@ import { Layout, Button, ConfigProvider, theme, message, Spin, Slider, Progress, import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined, CodeOutlined, RightOutlined } from '@ant-design/icons'; import { BrowserOpenURL, Environment, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowUnfullscreen, WindowUnmaximise } from '../wailsjs/runtime'; import Sidebar from './components/Sidebar'; -import SlowQueryRailButton from './components/sidebar/SlowQueryRailButton'; import TabManager from './components/TabManager'; import ConnectionModal from './components/ConnectionModal'; import SnippetSettingsModal from './components/SnippetSettingsModal'; @@ -2088,13 +2087,23 @@ function App() { const { - handleCloseLogPanel, + handleCloseLogPanel: handleCloseLegacyLogPanel, handleLogResizeStart, - handleToggleLogPanel, + handleToggleLogPanel: toggleLegacyLogPanel, isLogPanelOpen, logGhostRef, logPanelHeight, } = useAppLogPanelResize(); + const handleToggleLogPanel = useCallback(() => { + if (isV2Ui) { + window.dispatchEvent(new CustomEvent('gonavi:show-sql-execution-log')); + return; + } + toggleLegacyLogPanel(); + }, [isV2Ui, toggleLegacyLogPanel]); + const handleCloseLogPanel = useCallback(() => { + handleCloseLegacyLogPanel(); + }, [handleCloseLegacyLogPanel]); const handleCreateConnection = useCallback(() => { setSecurityUpdateRepairSource(null); @@ -2887,18 +2896,6 @@ function App() { onFocusCommandSearch={handleFocusSidebarSearch} /> - {/* 慢 SQL 历史入口:浮动在 Sidebar 右下角,独立组件不依赖 Sidebar 内部 state */} - {!connectionWorkbenchState.ready && (
)}
- {isLogPanelOpen && ( - )} diff --git a/frontend/src/components/LogPanel.test.tsx b/frontend/src/components/LogPanel.test.tsx index 6f7b36e..720852c 100644 --- a/frontend/src/components/LogPanel.test.tsx +++ b/frontend/src/components/LogPanel.test.tsx @@ -82,15 +82,16 @@ vi.mock("@ant-design/icons", async () => { ClearOutlined: Icon, CloseOutlined: Icon, ClockCircleOutlined: Icon, + RobotOutlined: Icon, }; }); -const renderLogPanel = () => { +const renderLogPanel = (props: Partial> = {}) => { let renderer!: ReactTestRenderer; act(() => { renderer = create( undefined}> - + , ); }); @@ -144,6 +145,36 @@ describe("LogPanel i18n", () => { expect(renderedText).toContain("OK"); }); + it("renders the current execution error summary inside the embedded log tab", () => { + storeState.sqlLogs = [ + { + id: "log-err", + timestamp: Date.UTC(2026, 5, 20, 5, 40, 0), + sql: "SELECT * FROM message;", + status: "error", + duration: 18, + message: "driver exploded", + }, + ]; + const onDiagnoseExecutionError = vi.fn(); + const renderer = renderLogPanel({ + variant: "embedded", + executionError: "Table 'missav_bot.message' doesn't exist", + onDiagnoseExecutionError, + }); + const renderedText = textContent(renderer.toJSON()); + + expect(renderedText).toContain("Execution failed"); + expect(renderedText).toContain("Table 'missav_bot.message' doesn't exist"); + expect(renderedText).toContain("AI diagnose"); + + const diagnoseButton = renderer.root.findAll((node) => node.type === "button" && textContent(node).includes("AI diagnose"))[0]; + act(() => { + diagnoseButton.props.onClick?.(); + }); + expect(onDiagnoseExecutionError).toHaveBeenCalledTimes(1); + }); + it("does not keep migrated Chinese UI literals in LogPanel source", () => { const source = readFileSync(new URL("./LogPanel.tsx", import.meta.url), "utf8"); diff --git a/frontend/src/components/LogPanel.tsx b/frontend/src/components/LogPanel.tsx index 5a4acc8..3894c9a 100644 --- a/frontend/src/components/LogPanel.tsx +++ b/frontend/src/components/LogPanel.tsx @@ -1,16 +1,26 @@ import React from 'react'; import { Table, Tag, Button, Tooltip, Empty } from 'antd'; -import { ClearOutlined, CloseOutlined, BugOutlined } from '@ant-design/icons'; +import { ClearOutlined, CloseOutlined, BugOutlined, RobotOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { useI18n } from '../i18n/provider'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; interface LogPanelProps { - height: number; - onClose: () => void; - onResizeStart: (e: React.MouseEvent) => void; + height?: number; + onClose?: () => void; + onResizeStart?: (e: React.MouseEvent) => void; + variant?: 'panel' | 'embedded'; + executionError?: string; + onDiagnoseExecutionError?: () => void; } -const LogPanel: React.FC = ({ height, onClose, onResizeStart }) => { +const LogPanel: React.FC = ({ + height = 260, + onClose, + onResizeStart, + variant = 'panel', + executionError, + onDiagnoseExecutionError, +}) => { const { t } = useI18n(); const sqlLogs = useStore(state => state.sqlLogs); const clearSqlLogs = useStore(state => state.clearSqlLogs); @@ -49,6 +59,8 @@ const LogPanel: React.FC = ({ height, onClose, onResizeStart }) = const logScrollbarThumbHover = darkMode ? `rgba(255, 255, 255, ${Math.max(0.28, opacity * 0.48)})` : `rgba(0, 0, 0, ${Math.max(0.18, opacity * 0.36)})`; + const isEmbedded = variant === 'embedded'; + const logCountLabel = sqlLogs.length.toLocaleString(); const columns = [ { @@ -86,6 +98,189 @@ const LogPanel: React.FC = ({ height, onClose, onResizeStart }) = } ]; + const logTable = ( +
+ {sqlLogs.length === 0 ? ( +
+ {t('log_panel.empty')}} + /> +
+ ) : ( + + )} + + ); + + const sharedStyles = ( + + ); + + if (isEmbedded) { + return ( +
+
+
+
+ +
+
+
+ {t('log_panel.description')} +
+
+ {logCountLabel} +
+
+
+ +
+ {executionError && ( +
+
+
+ + {t('query_editor.result.execution_failed')} +
+
+ {executionError} +
+ {onDiagnoseExecutionError && ( +
+ +
+ )} +
+
+ )} + {logTable} + {sharedStyles} +
+ ); + } + return (
= ({ height, onClose, onResizeStart }) = zIndex: 100 }}> {/* Resize Handle */} -
+ {onResizeStart && ( +
+ )} {/* Toolbar */}
= ({ height, onClose, onResizeStart }) =
- {/* List */} -
- {sqlLogs.length === 0 ? ( -
- {t('log_panel.empty')}} - /> -
- ) : ( -
- )} - - + {logTable} + {sharedStyles} ); }; diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 4329786..16a2194 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { readV2ThemeCss } from '../test/readV2ThemeCss'; import { setCurrentLanguage } from '../i18n'; +import { I18nProvider } from '../i18n/provider'; import type { SavedQuery, TabData } from '../types'; import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator'; import { clearQueryTabDraft, clearSQLFileTabDraft, getQueryTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts'; @@ -29,6 +30,14 @@ const storeState = vi.hoisted(() => ({ }, }, ], + sqlLogs: [] as Array<{ + id: string; + timestamp: number; + sql: string; + status: 'success' | 'error'; + duration: number; + }>, + clearSqlLogs: vi.fn(), addSqlLog: vi.fn(), addTab: vi.fn(), setActiveContext: vi.fn(), @@ -348,9 +357,20 @@ vi.mock('./DataGrid', () => ({ GONAVI_ROW_KEY: '__gonavi_row_key__', })); +vi.mock('./LogPanel', () => ({ + default: ({ variant, executionError }: { variant?: string; executionError?: string }) => ( +
+ SQL 执行日志 + {executionError ? ` ${executionError}` : ''} +
+ ), +})); + vi.mock('@ant-design/icons', () => { const Icon = () => ; return { + BugOutlined: Icon, + ClearOutlined: Icon, PlayCircleOutlined: Icon, SaveOutlined: Icon, FormatPainterOutlined: Icon, @@ -377,10 +397,30 @@ vi.mock('antd', () => { const Form: any = ({ children }: any) =>
{children}; Form.Item = ({ children }: any) => <>{children}; Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }]; + const Table = ({ dataSource, columns }: { dataSource: any[]; columns: any[] }) => ( +
+ {dataSource.map((record) => ( +
+ {columns.map((column) => ( +
+ {column.render + ? column.render(record[column.dataIndex], record) + : record[column.dataIndex]} +
+ ))} +
+ ))} +
+ ); + const Empty = ({ description }: { description?: React.ReactNode }) =>
{description}
; + (Empty as any).PRESENTED_IMAGE_SIMPLE = 'simple'; return { Button, Space, + Table, + Tag: ({ children }: { children?: React.ReactNode }) => {children}, + Empty, message: messageApi, Modal: ({ children, open, onOk, okText = '确认' }: any) => (open ? (
@@ -609,6 +649,8 @@ describe('QueryEditor external SQL save', () => { backendApp.DBGetTables.mockResolvedValue({ success: true, data: [] }); backendApp.GenerateQueryID.mockResolvedValue('query-1'); storeState.connections = createDefaultConnections(); + storeState.sqlLogs = []; + storeState.clearSqlLogs.mockReset(); storeState.connections[0].config.type = 'mysql'; storeState.connections[0].config.database = 'main'; storeState.appearance.uiVersion = 'legacy'; @@ -667,7 +709,11 @@ describe('QueryEditor external SQL save', () => { let renderer!: ReactTestRenderer; await act(async () => { - renderer = create(); + renderer = create( + undefined}> + + , + ); }); expect(textContent(renderer.toJSON())).not.toContain('等待执行 SQL'); @@ -678,7 +724,11 @@ describe('QueryEditor external SQL save', () => { let renderer!: ReactTestRenderer; await act(async () => { - renderer = create(); + renderer = create( + undefined}> + + , + ); }); await act(async () => { @@ -902,6 +952,90 @@ describe('QueryEditor external SQL save', () => { renderer.unmount(); }); + it('opens the embedded sql execution log tab from the shared log toggle event in v2', async () => { + storeState.appearance.uiVersion = 'v2'; + storeState.sqlLogs = [{ + id: 'log-1', + timestamp: Date.now(), + sql: 'select 1', + status: 'success', + duration: 12, + }]; + + const windowListeners: Record void)[]> = {}; + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { + windowListeners[type] ||= []; + windowListeners[type].push(listener); + }), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }), + cancelAnimationFrame: vi.fn(), + innerHeight: 900, + }); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + expect(textContent(renderer.toJSON())).not.toContain('SQL 执行日志'); + + await act(async () => { + windowListeners['gonavi:show-sql-execution-log']?.forEach((listener) => listener()); + }); + + expect(textContent(renderer.toJSON())).toContain('SQL 执行日志'); + expect(storeState.updateQueryTabDraft).toHaveBeenCalledWith('tab-1', { + resultPanelVisible: true, + }); + + await act(async () => { + windowListeners['gonavi:show-sql-execution-log']?.forEach((listener) => listener()); + }); + + expect(textContent(renderer.toJSON())).not.toContain('SQL 执行日志'); + expect(storeState.updateQueryTabDraft).toHaveBeenLastCalledWith('tab-1', { + resultPanelVisible: false, + }); + + renderer.unmount(); + }); + + it('shows execution failures inside the embedded sql log tab in v2', async () => { + storeState.appearance.uiVersion = 'v2'; + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: false, + message: 'driver exploded', + data: [], + }); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + await findButton(renderer, '运行').props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const rendered = textContent(renderer.toJSON()); + expect(rendered).toContain('SQL 执行日志'); + expect(rendered).toContain('driver exploded'); + expect(renderer.root.findAll((node) => node.props?.['data-log-panel'] === 'embedded')).toHaveLength(1); + expect(renderer.root.findAll((node) => node.props?.['data-tab-key'] === '__gonavi_sql_execution_log__')).toHaveLength(1); + + renderer.unmount(); + }); + it('keeps query result panel visibility isolated per tab', async () => { storeState.appearance.uiVersion = 'v2'; storeState.queryOptions.showQueryResultsPanel = false; diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index b339419..313d9f2 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -29,6 +29,14 @@ const storeState = vi.hoisted(() => ({ }, }, ], + sqlLogs: [] as Array<{ + id: string; + timestamp: number; + sql: string; + status: 'success' | 'error'; + duration: number; + }>, + clearSqlLogs: vi.fn(), addSqlLog: vi.fn(), addTab: vi.fn(), setActiveContext: vi.fn(), @@ -351,6 +359,8 @@ vi.mock('./DataGrid', () => ({ vi.mock('@ant-design/icons', () => { const Icon = () => ; return { + BugOutlined: Icon, + ClearOutlined: Icon, PlayCircleOutlined: Icon, SaveOutlined: Icon, FormatPainterOutlined: Icon, @@ -377,10 +387,30 @@ vi.mock('antd', () => { const Form: any = ({ children }: any) =>
{children}; Form.Item = ({ children }: any) => <>{children}; Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }]; + const Table = ({ dataSource, columns }: { dataSource: any[]; columns: any[] }) => ( +
+ {dataSource.map((record) => ( +
+ {columns.map((column) => ( +
+ {column.render + ? column.render(record[column.dataIndex], record) + : record[column.dataIndex]} +
+ ))} +
+ ))} +
+ ); + const Empty = ({ description }: { description?: React.ReactNode }) =>
{description}
; + (Empty as any).PRESENTED_IMAGE_SIMPLE = 'simple'; return { Button, Space, + Table, + Tag: ({ children }: { children?: React.ReactNode }) => {children}, + Empty, message: messageApi, Modal: ({ children, open, onOk, okText = '确认' }: any) => (open ? (
@@ -609,6 +639,8 @@ describe('QueryEditor external SQL save', () => { backendApp.DBGetTables.mockResolvedValue({ success: true, data: [] }); backendApp.GenerateQueryID.mockResolvedValue('query-1'); storeState.connections = createDefaultConnections(); + storeState.sqlLogs = []; + storeState.clearSqlLogs.mockReset(); storeState.connections[0].config.type = 'mysql'; storeState.connections[0].config.database = 'main'; storeState.appearance.uiVersion = 'legacy'; @@ -2303,6 +2335,20 @@ describe('QueryEditor external SQL save', () => { expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tab-text {'); }); + it('embeds the sql execution log as a result tab instead of a standalone workspace panel in v2', () => { + const panelSource = readFileSync(new URL('./QueryEditorResultsPanel.tsx', import.meta.url), 'utf8'); + const editorSource = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8'); + + expect(panelSource).toContain('QUERY_EDITOR_SQL_LOG_TAB_KEY'); + expect(panelSource).toContain(' { const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8'); const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8'); diff --git a/frontend/src/components/QueryEditor.sql-analysis-workbench.test.ts b/frontend/src/components/QueryEditor.sql-analysis-workbench.test.ts new file mode 100644 index 0000000..072190a --- /dev/null +++ b/frontend/src/components/QueryEditor.sql-analysis-workbench.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +describe('SQL analysis workbench wiring', () => { + it('routes QueryEditor diagnose and slow-query actions to the sql-analysis workbench tab', () => { + const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8') + + expect(source).toContain("buildSqlAnalysisWorkbenchTab") + expect(source).toContain("openSqlAnalysisWorkbench('diagnose', getCurrentQuery())") + expect(source).toContain("openSqlAnalysisWorkbench('slow-query')") + expect(source).not.toContain('const [explainOpen, setExplainOpen]') + expect(source).not.toContain('const [slowQueryOpen, setSlowQueryOpen]') + expect(source).not.toContain(' { + const source = readFileSync(new URL('./sidebar/SlowQueryRailButton.tsx', import.meta.url), 'utf8') + + expect(source).toContain('buildSqlAnalysisWorkbenchTab') + expect(source).toContain("view: 'slow-query'") + expect(source).not.toContain('SlowQueryPanel') + }) + + it('uses a compact segmented switcher in the sql-analysis workbench header', () => { + const source = readFileSync(new URL('./explain/SqlAnalysisWorkbench.tsx', import.meta.url), 'utf8') + + expect(source).toContain('Segmented') + expect(source).toContain('gn-sql-analysis-view-switcher') + expect(source).not.toContain(' { + const source = readFileSync(new URL('./explain/ExplainWorkbench.tsx', import.meta.url), 'utf8') + + expect(source).toContain('Segmented') + expect(source).toContain('gn-explain-report-switcher') + expect(source).not.toContain(' import('./explain/ExplainWorkbench')); -// 慢 SQL 历史面板:lazy 加载 -const SlowQueryPanel = lazy(() => import('./explain/SlowQueryPanel')); import { SUPPORTED_LANGUAGES, t as translate } from '../i18n'; +import { buildSqlAnalysisWorkbenchTab } from '../utils/sqlAnalysisTab'; import { DUCKDB_ROWID_LOCATOR_COLUMN, ORACLE_ROWID_LOCATOR_COLUMN, @@ -48,7 +45,10 @@ import { getColumnDefinitionName, getColumnDefinitionType, } from '../utils/columnDefinition'; -import QueryEditorResultsPanel, { type QueryEditorResultSet } from './QueryEditorResultsPanel'; +import QueryEditorResultsPanel, { + QUERY_EDITOR_SQL_LOG_TAB_KEY, + type QueryEditorResultSet, +} from './QueryEditorResultsPanel'; import { SQL_EDITOR_AUTO_COMMIT_DELAY_OPTIONS } from './QueryEditorTransactionSettings'; import QueryEditorTransactionToolbar from './QueryEditorTransactionToolbar'; import QueryEditorToolbar from './QueryEditorToolbar'; @@ -230,10 +230,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const [saveModalMode, setSaveModalMode] = useState<'save' | 'rename'>('save'); const [saveForm] = Form.useForm(); - // SQL 诊断工作台与慢 SQL 历史:通过快捷键管理系统注册(避免与 toggleTheme/toggleLogPanel 冲突) - const [explainOpen, setExplainOpen] = useState(false); - const [slowQueryOpen, setSlowQueryOpen] = useState(false); - // Database Selection const [currentConnectionId, setCurrentConnectionId] = useState(tab.connectionId); const [currentDb, setCurrentDb] = useState(tab.dbName || ''); @@ -278,22 +274,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc [connections] ); - // SQL 诊断工作台:从 currentConnectionId 解析 ConnectionConfig(复用 SavedConnection 模式) - const explainConfig = useMemo(() => { - if (!currentConnectionId) return null; - const conn = connections.find(c => c.id === currentConnectionId); - if (!conn) return null; - return { - ...conn.config, - port: Number(conn.config.port), - password: conn.config.password || '', - database: conn.config.database || '', - useSSH: conn.config.useSSH || false, - ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }, - } as any; - }, [connections, currentConnectionId]); - const addSqlLog = useStore(state => state.addSqlLog); + const sqlLogCount = useStore(state => state.sqlLogs.length); const addTab = useStore(state => state.addTab); const setActiveContext = useStore(state => state.setActiveContext); const updateQueryTabDraft = useStore(state => state.updateQueryTabDraft); @@ -334,23 +316,41 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc [activeShortcutPlatform, shortcutOptions], ); + const openSqlAnalysisWorkbench = useCallback( + (view: 'diagnose' | 'slow-query', nextSql?: string) => { + const connectionId = String(currentConnectionId || '').trim(); + if (!connectionId) { + message.warning(translate('query_editor.message.connection_not_found')); + return; + } + const dbName = String(currentDb || tab.dbName || '').trim(); + addTab(buildSqlAnalysisWorkbenchTab({ + connectionId, + dbName: dbName || undefined, + query: typeof nextSql === 'string' && nextSql.trim() ? nextSql : undefined, + view, + })); + }, + [addTab, currentConnectionId, currentDb, tab.dbName], + ); + // SQL 诊断 / 慢 SQL 历史的快捷键监听(必须在 binding 声明之后) useEffect(() => { if (!isActive) return; const handler = (e: KeyboardEvent) => { if (diagnoseQueryShortcutBinding?.enabled && isShortcutMatch(e, diagnoseQueryShortcutBinding.combo)) { e.preventDefault(); - setExplainOpen(true); + openSqlAnalysisWorkbench('diagnose', getCurrentQuery()); return; } if (showSlowQueriesShortcutBinding?.enabled && isShortcutMatch(e, showSlowQueriesShortcutBinding.combo)) { e.preventDefault(); - setSlowQueryOpen(true); + openSqlAnalysisWorkbench('slow-query'); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [isActive, diagnoseQueryShortcutBinding, showSlowQueriesShortcutBinding]); + }, [diagnoseQueryShortcutBinding, isActive, openSqlAnalysisWorkbench, showSlowQueriesShortcutBinding]); const selectCurrentStatementShortcutBinding = useMemo( () => resolveShortcutBinding(shortcutOptions, 'selectCurrentStatement', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], @@ -381,6 +381,17 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return nextVisible; }); }, [tab.id, updateQueryTabDraft]); + const handleShowSqlExecutionLog = useCallback(() => { + if (!isActive) { + return; + } + if (isResultPanelVisible && activeResultKey === QUERY_EDITOR_SQL_LOG_TAB_KEY) { + updateResultPanelVisibility(false); + return; + } + updateResultPanelVisibility(true); + setActiveResultKey(QUERY_EDITOR_SQL_LOG_TAB_KEY); + }, [activeResultKey, isActive, isResultPanelVisible, updateResultPanelVisibility]); const sqlEditorCommitMode = sqlEditorTransactionOptions?.commitMode === 'auto' ? 'auto' : 'manual'; const sqlEditorAutoCommitDelayMs = SQL_EDITOR_AUTO_COMMIT_DELAY_OPTIONS.some((item) => item.value === sqlEditorTransactionOptions?.autoCommitDelayMs) ? Number(sqlEditorTransactionOptions?.autoCommitDelayMs) @@ -2960,15 +2971,15 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc dbName: currentDb }); if (!res.success) { - const prefix = statements.length > 1 - ? translate('query_editor.message.statement_failed_prefix', { index: idx + 1 }) - : ''; - updateResultPanelVisibility(true); - setExecutionError(formatSqlExecutionError(res.message, { prefix })); - setResultSets([]); - setActiveResultKey(''); - return; - } + const prefix = statements.length > 1 + ? translate('query_editor.message.statement_failed_prefix', { index: idx + 1 }) + : ''; + updateResultPanelVisibility(true); + setExecutionError(formatSqlExecutionError(res.message, { prefix })); + setResultSets([]); + setActiveResultKey(QUERY_EDITOR_SQL_LOG_TAB_KEY); + return; + } if (Array.isArray(res.data)) { let rows = (res.data as any[]) || []; let truncated = false; @@ -3223,7 +3234,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc updateResultPanelVisibility(true); setExecutionError(formatSqlExecutionError(res.message)); setResultSets([]); - setActiveResultKey(''); + setActiveResultKey(QUERY_EDITOR_SQL_LOG_TAB_KEY); return; } @@ -3400,7 +3411,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc updateResultPanelVisibility(true); setExecutionError(formattedError); setResultSets([]); - setActiveResultKey(''); + setActiveResultKey(QUERY_EDITOR_SQL_LOG_TAB_KEY); } finally { if (runSeqRef.current === runSeq) setLoading(false); // Clear query ID after execution completes @@ -3881,7 +3892,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc )} ), - onClick: () => setExplainOpen(true), + onClick: () => openSqlAnalysisWorkbench('diagnose', getCurrentQuery()), }, { key: 'show-slow-queries', @@ -3895,7 +3906,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc )} ), - onClick: () => setSlowQueryOpen(true), + onClick: () => openSqlAnalysisWorkbench('slow-query'), }, ]; @@ -3979,6 +3990,17 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; }, [isActive, handleQuickSave]); + useEffect(() => { + const handleOpenSqlExecutionLog = () => { + handleShowSqlExecutionLog(); + }; + + window.addEventListener('gonavi:show-sql-execution-log', handleOpenSqlExecutionLog as EventListener); + return () => { + window.removeEventListener('gonavi:show-sql-execution-log', handleOpenSqlExecutionLog as EventListener); + }; + }, [handleShowSqlExecutionLog]); + const handleSave = async () => { try { const values = await saveForm.validateFields(); @@ -4178,6 +4200,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc activeResultKey={activeResultKey} loading={loading} executionError={executionError} + sqlLogCount={sqlLogCount} darkMode={darkMode} isV2Ui={isV2Ui} currentDb={currentDb} @@ -4211,31 +4234,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc - {/* SQL 诊断工作台:Ctrl+Shift+D 触发,lazy 加载避免 reactflow 进入主 bundle */} - - {explainOpen && explainConfig && ( - setExplainOpen(false)} - config={explainConfig} - dbName={currentDb} - sql={query} - /> - )} - - - {/* 慢 SQL 历史:Ctrl+Shift+H 触发 */} - - {slowQueryOpen && explainConfig && ( - setSlowQueryOpen(false)} - config={explainConfig} - dbName={currentDb} - onPickQuery={(sql) => setQuery(sql)} - /> - )} - ); }; diff --git a/frontend/src/components/QueryEditorResultsPanel.tsx b/frontend/src/components/QueryEditorResultsPanel.tsx index 92d8e0b..7ecc8ce 100644 --- a/frontend/src/components/QueryEditorResultsPanel.tsx +++ b/frontend/src/components/QueryEditorResultsPanel.tsx @@ -1,12 +1,15 @@ import React from 'react'; import { Button, Dropdown, Tabs, Tooltip, type MenuProps } from 'antd'; -import { CloseOutlined, EyeInvisibleOutlined, RobotOutlined } from '@ant-design/icons'; +import { BugOutlined, CloseOutlined, EyeInvisibleOutlined, RobotOutlined } from '@ant-design/icons'; import type { EditRowLocator } from '../utils/rowLocator'; import type { QueryResultPaginationState } from '../utils/queryResultPagination'; import { t as defaultTranslate } from '../i18n'; import { useOptionalI18n } from '../i18n/provider'; import DataGrid from './DataGrid'; +import LogPanel from './LogPanel'; + +export const QUERY_EDITOR_SQL_LOG_TAB_KEY = '__gonavi_sql_execution_log__'; export type QueryEditorResultSet = { key: string; @@ -33,6 +36,7 @@ interface QueryEditorResultsPanelProps { activeResultKey: string; loading: boolean; executionError: string; + sqlLogCount: number; darkMode: boolean; isV2Ui: boolean; currentDb: string; @@ -58,6 +62,7 @@ const QueryEditorResultsPanel: React.FC = ({ activeResultKey, loading, executionError, + sqlLogCount, darkMode, isV2Ui, currentDb, @@ -76,44 +81,253 @@ const QueryEditorResultsPanel: React.FC = ({ }) => { const i18n = useOptionalI18n(); const t = i18n?.t ?? defaultTranslate; - const resolvedActiveResultKey = activeResultKey || resultSets[0]?.key || ''; + const shouldShowSqlLogTab = sqlLogCount > 0 || activeResultKey === QUERY_EDITOR_SQL_LOG_TAB_KEY; + const logTabCountLabel = sqlLogCount > 999 ? '999+' : String(sqlLogCount); + const resolvedResultSetKey = activeResultKey && resultSets.some((rs) => rs.key === activeResultKey) + ? activeResultKey + : (resultSets[0]?.key || ''); + const hideTooltipTitle = toggleShortcutLabel + ? t('query_editor.results_panel.tooltip.hide_with_shortcut', { shortcut: toggleShortcutLabel }) + : t('query_editor.results_panel.tooltip.hide'); + const toolbarHideButton = ( + + + + ); + + function buildResultTabMenuItems(key: string, index: number): MenuProps['items'] { + return [ + { + key: 'close-other', + label: t('query_editor.results_panel.menu.close_other'), + disabled: resultSets.length <= 1, + onClick: () => onCloseOtherResultTabs(key), + }, + { + key: 'close-left', + label: t('query_editor.results_panel.menu.close_left'), + disabled: index <= 0, + onClick: () => onCloseResultTabsToLeft(key), + }, + { + key: 'close-right', + label: t('query_editor.results_panel.menu.close_right'), + disabled: index >= resultSets.length - 1, + onClick: () => onCloseResultTabsToRight(key), + }, + { type: 'divider' }, + { + key: 'close-all', + label: t('query_editor.results_panel.menu.close_all'), + disabled: resultSets.length === 0, + onClick: onCloseAllResultTabs, + }, + ]; + } + + const buildResultTabItems = () => resultSets.map((rs, idx) => ({ + key: rs.key, + label: ( + +
{ + event.preventDefault(); + }} + > + + + {rs.resultType === 'message' + ? t('query_editor.results_panel.tab.message', { index: idx + 1 }) + : t('query_editor.results_panel.tab.result', { index: idx + 1 })} + + + {(() => { + if (rs.resultType === 'message') { + return i; + } + if (isAffectedRowsResult(rs)) { + return ; + } + if (!Array.isArray(rs.rows)) { + return null; + } + return {rs.rows.length}; + })()} + + { + e.preventDefault(); + e.stopPropagation(); + onCloseResult(rs.key); + }} + > + + + +
+
+ ), + children: (() => { + if (rs.resultType === 'message') { + return ( +
+ {t('query_editor.results_panel.message.title')} +
+ {(rs.messages || []).join('\n')} +
+
+ ); + } + if (isAffectedRowsResult(rs)) { + const affected = Number(rs.rows[0]?.affectedRows ?? 0); + return ( +
+ + {t('query_editor.result.execution_success')} + {t('query_editor.result.affected_rows', { count: affected })} + {Array.isArray(rs.messages) && rs.messages.length > 0 && ( +
+ {rs.messages.join('\n')} +
+ )} +
+ ); + } + return ( +
+ {Array.isArray(rs.messages) && rs.messages.length > 0 && ( +
+ {rs.messages.join('\n')} +
+ )} + { + if (rs.page) { + onResultPageChange(rs.key, rs.page.current, rs.page.pageSize); + return; + } + onReloadResult(rs.key, rs.sql); + }} + pagination={rs.page ? { + current: rs.page.current, + pageSize: rs.page.pageSize, + total: rs.page.total, + totalKnown: rs.page.totalKnown, + } : undefined} + onPageChange={rs.page ? ((page, size) => onResultPageChange(rs.key, page, size)) : undefined} + readOnly={rs.readOnly} + toolbarExtraActions={resolvedResultSetKey === rs.key ? toolbarHideButton : null} + /> +
+ ); + })(), + })); + + const resultTabItems = buildResultTabItems(); + const logTabItem = shouldShowSqlLogTab + ? { + key: QUERY_EDITOR_SQL_LOG_TAB_KEY, + label: ( + +
+ + {t('log_panel.short_title')} + {logTabCountLabel} +
+
+ ), + children: ( + + ), + } + : null; + const tabItems = logTabItem ? [logTabItem, ...resultTabItems] : resultTabItems; + + const resolvedActiveResultKey = (() => { + if (activeResultKey && tabItems.some((item) => item.key === activeResultKey)) { + return activeResultKey; + } + if (resultSets[0]?.key) { + return resultSets[0].key; + } + return shouldShowSqlLogTab ? QUERY_EDITOR_SQL_LOG_TAB_KEY : ''; + })(); const activeResultSet = resultSets.find((rs) => rs.key === resolvedActiveResultKey) || null; const activeResultUsesDataGrid = Boolean( activeResultSet && activeResultSet.resultType !== 'message' && !isAffectedRowsResult(activeResultSet), ); - const hideTooltipTitle = toggleShortcutLabel - ? t('query_editor.results_panel.tooltip.hide_with_shortcut', { shortcut: toggleShortcutLabel }) - : t('query_editor.results_panel.tooltip.hide'); - - const buildResultTabMenuItems = (key: string, index: number): MenuProps['items'] => [ - { - key: 'close-other', - label: t('query_editor.results_panel.menu.close_other'), - disabled: resultSets.length <= 1, - onClick: () => onCloseOtherResultTabs(key), - }, - { - key: 'close-left', - label: t('query_editor.results_panel.menu.close_left'), - disabled: index <= 0, - onClick: () => onCloseResultTabsToLeft(key), - }, - { - key: 'close-right', - label: t('query_editor.results_panel.menu.close_right'), - disabled: index >= resultSets.length - 1, - onClick: () => onCloseResultTabsToRight(key), - }, - { type: 'divider' }, - { - key: 'close-all', - label: t('query_editor.results_panel.menu.close_all'), - disabled: resultSets.length === 0, - onClick: onCloseAllResultTabs, - }, - ]; const hideButton = ( @@ -151,21 +365,6 @@ const QueryEditorResultsPanel: React.FC = ({ } : undefined; - const toolbarHideButton = ( - - - - ); - return ( <> + {loading && ( +
+ +
+ )} + {error && ( + + 诊断失败: + {error} + + )} + {!loading && !error && !report && !hasRequestedRun && ( + + )} + {!loading && !error && report && ( +
+
+ setActiveView(value as 'plan' | 'raw')} + className="gn-explain-report-switcher" + options={[ + { + value: 'plan', + label: ( + + + 执行计划 + + ), + }, + { + value: 'raw', + label: ( + + + 原文 + + ), + }, + ]} + /> + + {report.plan.nodes.length} 节点 + / + {report.plan.rawFormat} + +
+ +
+ {activeView === 'plan' ? ( +
+
+ +
+
+ +
+
+ ) : ( +
+                {report.plan.rawPayload || '(无原文)'}
+              
+ )} +
+
+ )} + + ) +} + +export default function ExplainWorkbench({ open, onClose, config, dbName, sql }: ExplainWorkbenchProps) { return ( SQL 诊断工作台} destroyOnClose > -
- {loading && ( -
- -
- )} - {error && ( - - 诊断失败: - {error} - - )} - {!loading && !error && report && ( - -
- -
-
- -
-
- ), - }, - { - key: 'raw', - label: `原文(${report.plan.rawFormat})`, - children: ( -
-                    {report.plan.rawPayload || '(无原文)'}
-                  
- ), - }, - ]} - /> - )} +
+
) } + +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; + } +` diff --git a/frontend/src/components/explain/SlowQueryPanel.tsx b/frontend/src/components/explain/SlowQueryPanel.tsx index eb7fb14..046cc64 100644 --- a/frontend/src/components/explain/SlowQueryPanel.tsx +++ b/frontend/src/components/explain/SlowQueryPanel.tsx @@ -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([]) const [error, setError] = useState(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 ( - - - 慢 SQL 历史 - - {dbName || '(当前连接)'} - - - } - destroyOnClose - > +
0 && ( -
+
{sorted.map((r, idx) => ( handlePick(r)} /> ))}
)} +
+ ) +} + +export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQuery }: SlowQueryPanelProps) { + return ( + + + 慢 SQL 历史 + + {dbName || '(当前连接)'} + +
+ } + destroyOnClose + > +
+ { + onPickQuery?.(sql) + onClose() + }} + /> +
) } diff --git a/frontend/src/components/explain/SqlAnalysisWorkbench.tsx b/frontend/src/components/explain/SqlAnalysisWorkbench.tsx new file mode 100644 index 0000000..c59ce43 --- /dev/null +++ b/frontend/src/components/explain/SqlAnalysisWorkbench.tsx @@ -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(() => 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 ( +
+ + +
+ ) + } + + return ( +
+ +
+
+ + SQL 分析工作台 + + + {connection?.name || tab.connectionId} + {dbName ? ` / ${dbName}` : ''} + +
+ setActiveView(value as SqlAnalysisViewKey)} + className="gn-sql-analysis-view-switcher" + options={[ + { + value: 'slow-query', + label: ( + + + 慢 SQL + + ), + }, + { + value: 'diagnose', + label: ( + + + SQL 诊断 + + ), + }, + ]} + /> +
+ +
+ {activeView === 'slow-query' ? ( +
+ +
+ ) : ( +
+
+ setSqlDraft(event.target.value)} + placeholder="输入要诊断的 SQL,或从慢 SQL 列表点击条目带入" + autoSize={{ minRows: 5, maxRows: 10 }} + /> +
+ 支持从慢 SQL 列表点击条目直接带入 + +
+
+
+ 0 ? diagnoseRunKey : null} + /> +
+
+ )} +
+
+ ) +} + +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; + } +` diff --git a/frontend/src/components/sidebar/SlowQueryRailButton.tsx b/frontend/src/components/sidebar/SlowQueryRailButton.tsx index e383739..876a152 100644 --- a/frontend/src/components/sidebar/SlowQueryRailButton.tsx +++ b/frontend/src/components/sidebar/SlowQueryRailButton.tsx @@ -1,99 +1,104 @@ -import { lazy, Suspense, useMemo, useState } from 'react' +import { useMemo } from 'react' import { Tooltip } from 'antd' import { HistoryOutlined } from '@ant-design/icons' import { useStore } from '../../store' -import type { ConnectionConfig } from '../../types' -// lazy 加载避免 SlowQueryPanel(react-flow + dagre 约 200KB)进入主 bundle -const SlowQueryPanel = lazy(() => import('../explain/SlowQueryPanel')) +import { buildSqlAnalysisWorkbenchTab } from '../../utils/sqlAnalysisTab' -// Sidebar 顶部的慢 SQL 历史入口。 +// Sidebar 底部的慢 SQL 工作台入口。 // // 设计要点: // - 完全独立组件,不依赖 Sidebar.tsx 内部 state(避免改 Sidebar Props) -// - 自己从 store 读取 tabs/connections,解析当前激活 tab 的连接配置 -// - 通过 lazy import SlowQueryPanel(react-flow/dagre 不进入主 bundle) +// - 自己从 store 读取 tabs/connections,定位当前激活 tab 的连接上下文 +// - 点击后打开/聚焦 SQL 分析工作台,并默认切到慢 SQL 视图 // - 没有激活的连接时按钮禁用,hover 给提示 // -// 挂载位置:由调用方决定(App.tsx 把它放在 Sidebar 容器内) +// 挂载位置:由调用方决定;当前用于 Sidebar 底部 footer。 interface SlowQueryRailButtonProps { /** 自定义 className 用于外层定位 */ className?: string /** 自定义 style(用于绝对定位到 Sidebar 角落) */ style?: React.CSSProperties + /** tooltip 位置 */ + tooltipPlacement?: + | 'top' + | 'right' + | 'bottom' + | 'left' + | 'topLeft' + | 'topRight' + | 'bottomLeft' + | 'bottomRight' + | 'leftTop' + | 'leftBottom' + | 'rightTop' + | 'rightBottom' } -export default function SlowQueryRailButton({ className, style }: SlowQueryRailButtonProps) { - const [open, setOpen] = useState(false) +export default function SlowQueryRailButton({ + className, + style, + tooltipPlacement = 'right', +}: SlowQueryRailButtonProps) { const tabs = useStore(s => s.tabs) const activeTabId = useStore(s => s.activeTabId) const connections = useStore(s => s.connections) + const addTab = useStore(s => s.addTab) - // 解析当前激活 tab 的 ConnectionConfig - const activeConfig = useMemo(() => { - if (!activeTabId) return null - const tab = tabs.find(t => t.id === activeTabId) - if (!tab?.connectionId) return null - const conn = connections.find(c => c.id === tab.connectionId) - if (!conn) return null - return { - ...conn.config, - port: Number(conn.config.port), - password: conn.config.password || '', - database: conn.config.database || '', - useSSH: conn.config.useSSH || false, - ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }, - } as ConnectionConfig - }, [tabs, activeTabId, connections]) + const activeTab = useMemo( + () => tabs.find(t => t.id === activeTabId) || null, + [activeTabId, tabs], + ) + const hasActiveConnection = useMemo( + () => + Boolean( + activeTab?.connectionId && + connections.some(connection => connection.id === activeTab.connectionId), + ), + [activeTab, connections], + ) - const activeTab = tabs.find(t => t.id === activeTabId) - const dbName = activeTab?.dbName || '' - - const buttonDisabled = !activeConfig + const buttonDisabled = !hasActiveConnection const tooltipText = buttonDisabled - ? '请先打开一个数据库连接的查询标签' - : '查看当前连接的慢 SQL 历史(Ctrl+Shift+L)' + ? '请先打开一个数据库连接的标签页' + : '打开当前连接的 SQL 分析工作台' return ( - <> - - - - - {open && activeConfig && ( - - setOpen(false)} - config={activeConfig} - dbName={dbName} - /> - - )} - + + + ) } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 40fd154..766cca2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -436,6 +436,7 @@ export interface TabData { | "query" | "table" | "design" + | "sql-analysis" | "redis-keys" | "redis-command" | "redis-monitor" @@ -479,6 +480,8 @@ export interface TabData { tableExportInitialScope?: TableExportScope; tableExportQueryByScope?: Partial>; tableExportRowCountByScope?: Partial>; + sqlAnalysisView?: "diagnose" | "slow-query"; + sqlAnalysisRequestKey?: string; formatRestoreSnapshot?: { query: string; createdAt: number; diff --git a/frontend/src/utils/sqlAnalysisTab.test.ts b/frontend/src/utils/sqlAnalysisTab.test.ts new file mode 100644 index 0000000..9bd43ba --- /dev/null +++ b/frontend/src/utils/sqlAnalysisTab.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { buildSqlAnalysisWorkbenchTab, resolveSqlAnalysisWorkbenchTabId } from './sqlAnalysisTab' + +describe('sqlAnalysisTab', () => { + it('builds a stable workbench tab per connection and database', () => { + expect(resolveSqlAnalysisWorkbenchTabId('conn-1', 'analytics')).toBe('sql-analysis-conn-1-analytics') + expect(resolveSqlAnalysisWorkbenchTabId('conn-1')).toBe('sql-analysis-conn-1-default') + }) + + it('keeps diagnose requests on the sql-analysis tab with optional seeded sql', () => { + const tab = buildSqlAnalysisWorkbenchTab({ + connectionId: 'conn-1', + dbName: 'analytics', + query: 'select * from orders', + view: 'diagnose', + requestKey: 'diagnose-1', + }) + + expect(tab).toMatchObject({ + id: 'sql-analysis-conn-1-analytics', + title: 'SQL 分析 · analytics', + type: 'sql-analysis', + connectionId: 'conn-1', + dbName: 'analytics', + query: 'select * from orders', + sqlAnalysisView: 'diagnose', + sqlAnalysisRequestKey: 'diagnose-1', + }) + }) + + it('does not clear existing sql when opening the slow-query view without a seeded query', () => { + const tab = buildSqlAnalysisWorkbenchTab({ + connectionId: 'conn-1', + view: 'slow-query', + requestKey: 'slow-1', + }) + + expect(tab.query).toBeUndefined() + expect(tab.sqlAnalysisView).toBe('slow-query') + expect(tab.sqlAnalysisRequestKey).toBe('slow-1') + }) +}) diff --git a/frontend/src/utils/sqlAnalysisTab.ts b/frontend/src/utils/sqlAnalysisTab.ts new file mode 100644 index 0000000..dd74b86 --- /dev/null +++ b/frontend/src/utils/sqlAnalysisTab.ts @@ -0,0 +1,42 @@ +import type { TabData } from '../types' + +export type SqlAnalysisView = 'diagnose' | 'slow-query' + +type BuildSqlAnalysisWorkbenchTabInput = { + connectionId: string + dbName?: string + title?: string + query?: string + view?: SqlAnalysisView + requestKey?: string +} + +export const resolveSqlAnalysisWorkbenchTabId = ( + connectionId: string, + dbName?: string, +): string => { + const normalizedConnectionId = String(connectionId || '').trim() || 'none' + const normalizedDbName = String(dbName || '').trim() || 'default' + return `sql-analysis-${normalizedConnectionId}-${normalizedDbName}` +} + +export const buildSqlAnalysisWorkbenchTab = ( + input: BuildSqlAnalysisWorkbenchTabInput, +): TabData => { + const connectionId = String(input.connectionId || '').trim() + const dbName = String(input.dbName || '').trim() + const view = input.view === 'slow-query' ? 'slow-query' : 'diagnose' + const title = String(input.title || (dbName ? `SQL 分析 · ${dbName}` : 'SQL 分析')).trim() + const query = typeof input.query === 'string' ? input.query : '' + + return { + id: resolveSqlAnalysisWorkbenchTabId(connectionId, dbName || undefined), + title: title || (dbName ? `SQL 分析 · ${dbName}` : 'SQL 分析'), + type: 'sql-analysis', + connectionId, + ...(dbName ? { dbName } : {}), + ...(query.trim() ? { query } : {}), + sqlAnalysisView: view, + sqlAnalysisRequestKey: input.requestKey || `${view}-${Date.now()}`, + } +} diff --git a/frontend/src/utils/tabDisplay.ts b/frontend/src/utils/tabDisplay.ts index 3859496..1dcd40d 100644 --- a/frontend/src/utils/tabDisplay.ts +++ b/frontend/src/utils/tabDisplay.ts @@ -464,6 +464,7 @@ export const getTabDisplayKindLabel = (tab: TabData): string => { if (tab.type === 'design') return 'DESIGN'; if (tab.type === 'table-overview') return 'DB'; if (tab.type === 'table-export') return 'EXPORT'; + if (tab.type === 'sql-analysis') return 'ANALYZE'; if (tab.type.startsWith('redis')) return 'REDIS'; if (tab.type.startsWith('jvm')) return 'JVM'; if (tab.type === 'trigger') return 'TRG'; diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css index 837f951..9ae0397 100644 --- a/frontend/src/v2-theme.css +++ b/frontend/src/v2-theme.css @@ -2588,10 +2588,15 @@ body[data-ui-version="v2"] .gn-v2-sidebar-log-footer { border-top: 0.5px solid var(--gn-br-1); background: var(--gn-bg-panel-2); flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; } body[data-ui-version="v2"] .gn-v2-sidebar-log-button { - width: 100%; + width: auto; + min-width: 0; + flex: 1 1 auto; height: 28px; border: 0; border-radius: 8px; @@ -2617,6 +2622,17 @@ body[data-ui-version="v2"] .gn-v2-sidebar-log-button small { font-weight: 400; } +body[data-ui-version="v2"] .gn-v2-sidebar-slow-query-button { + width: 28px; + height: 28px; + flex: 0 0 28px; + border-radius: 8px; +} + +body[data-ui-version="v2"] .gn-v2-sidebar-slow-query-button:hover { + background: var(--gn-bg-hover) !important; +} + body[data-ui-version="v2"] .gn-v2-empty-workbench { min-height: 100%; display: grid; diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 1542a10..585796f 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -90,6 +90,7 @@ "import_preview.result.failed_rows": "{{count}} Zeilen fehlgeschlagen", "import_preview.result.error_logs": "Fehlerprotokolle:", "log_panel.title": "SQL-Ausfuehrungslog", + "log_panel.short_title": "Logs", "log_panel.description": "Zeichnet Ausfuehrungsstatus, Dauer und Fehler fuer schnelle Nachverfolgung auf.", "log_panel.action.clear": "Logs leeren", "log_panel.action.close": "Panel schliessen", @@ -1576,6 +1577,7 @@ "tab_manager.kind_badge.event": "Ereignis", "tab_manager.kind_badge.routine": "Funktion", "tab_manager.kind_badge.table_export": "Export", + "tab_manager.kind_badge.sql_analysis": "Analyse", "tab_manager.kind_badge.fallback": "Tab", "tab_manager.empty.action.open_ai": "AI öffnen", "tab_manager.empty.aria.start_workbench": "GoNavi-Startarbeitsbereich", @@ -1610,6 +1612,7 @@ "tab_manager.hover.kind.routine": "Funktion / Prozedur", "tab_manager.hover.kind.table": "Tabellendaten", "tab_manager.hover.kind.table_export": "Export-Workbench", + "tab_manager.hover.kind.sql_analysis": "SQL-Analyse-Workbench", "tab_manager.hover.kind.table_overview": "Tabellenübersicht", "tab_manager.hover.kind.trigger": "Trigger", "tab_manager.hover.kind.view": "Ansicht", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 77a7ad2..7f4924c 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -90,6 +90,7 @@ "import_preview.result.failed_rows": "Failed {{count}} rows", "import_preview.result.error_logs": "Error logs:", "log_panel.title": "SQL execution log", + "log_panel.short_title": "Logs", "log_panel.description": "Track execution status, duration, and errors for quick review.", "log_panel.action.clear": "Clear logs", "log_panel.action.close": "Close panel", @@ -1584,6 +1585,7 @@ "tab_manager.kind_badge.event": "Event", "tab_manager.kind_badge.routine": "Func", "tab_manager.kind_badge.table_export": "Export", + "tab_manager.kind_badge.sql_analysis": "Analyze", "tab_manager.kind_badge.fallback": "Tab", "tab_manager.empty.action.open_ai": "Open AI", "tab_manager.empty.aria.start_workbench": "GoNavi start workbench", @@ -1618,6 +1620,7 @@ "tab_manager.hover.kind.routine": "Function / procedure", "tab_manager.hover.kind.table": "Table data", "tab_manager.hover.kind.table_export": "Export workbench", + "tab_manager.hover.kind.sql_analysis": "SQL analysis workbench", "tab_manager.hover.kind.table_overview": "Table overview", "tab_manager.hover.kind.trigger": "Trigger", "tab_manager.hover.kind.view": "View", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index f8d2d27..003eab9 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -90,6 +90,7 @@ "import_preview.result.failed_rows": "{{count}} 行が失敗しました", "import_preview.result.error_logs": "エラーログ:", "log_panel.title": "SQL 実行ログ", + "log_panel.short_title": "ログ", "log_panel.description": "実行状態、所要時間、エラー情報を記録してすばやく確認できます。", "log_panel.action.clear": "ログをクリア", "log_panel.action.close": "パネルを閉じる", @@ -1576,6 +1577,7 @@ "tab_manager.kind_badge.event": "イベント", "tab_manager.kind_badge.routine": "関数", "tab_manager.kind_badge.table_export": "エクスポート", + "tab_manager.kind_badge.sql_analysis": "分析", "tab_manager.kind_badge.fallback": "タブ", "tab_manager.empty.action.open_ai": "AI を開く", "tab_manager.empty.aria.start_workbench": "GoNavi 開始ワークベンチ", @@ -1610,6 +1612,7 @@ "tab_manager.hover.kind.routine": "関数 / プロシージャ", "tab_manager.hover.kind.table": "テーブルデータ", "tab_manager.hover.kind.table_export": "エクスポートワークベンチ", + "tab_manager.hover.kind.sql_analysis": "SQL 分析ワークベンチ", "tab_manager.hover.kind.table_overview": "テーブル概要", "tab_manager.hover.kind.trigger": "トリガー", "tab_manager.hover.kind.view": "ビュー", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 8fbcf56..9308b71 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -90,6 +90,7 @@ "import_preview.result.failed_rows": "Строк с ошибками: {{count}}", "import_preview.result.error_logs": "Журнал ошибок:", "log_panel.title": "Журнал выполнения SQL", + "log_panel.short_title": "Логи", "log_panel.description": "Фиксирует статус выполнения, длительность и ошибки для быстрого анализа.", "log_panel.action.clear": "Очистить журнал", "log_panel.action.close": "Закрыть панель", @@ -1576,6 +1577,7 @@ "tab_manager.kind_badge.event": "Событие", "tab_manager.kind_badge.routine": "Функция", "tab_manager.kind_badge.table_export": "Экспорт", + "tab_manager.kind_badge.sql_analysis": "Анализ", "tab_manager.kind_badge.fallback": "Вкладка", "tab_manager.empty.action.open_ai": "Открыть AI", "tab_manager.empty.aria.start_workbench": "Стартовая рабочая область GoNavi", @@ -1610,6 +1612,7 @@ "tab_manager.hover.kind.routine": "Функция / процедура", "tab_manager.hover.kind.table": "Данные таблицы", "tab_manager.hover.kind.table_export": "Рабочая область экспорта", + "tab_manager.hover.kind.sql_analysis": "Рабочая область анализа SQL", "tab_manager.hover.kind.table_overview": "Обзор таблицы", "tab_manager.hover.kind.trigger": "Триггер", "tab_manager.hover.kind.view": "Представление", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 85ec7ea..d5b1deb 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -90,6 +90,7 @@ "import_preview.result.failed_rows": "失败 {{count}} 行", "import_preview.result.error_logs": "错误日志:", "log_panel.title": "SQL 执行日志", + "log_panel.short_title": "日志", "log_panel.description": "记录执行状态、耗时与错误信息,便于快速回溯。", "log_panel.action.clear": "清空日志", "log_panel.action.close": "关闭面板", @@ -1584,6 +1585,7 @@ "tab_manager.kind_badge.event": "事件", "tab_manager.kind_badge.routine": "函数", "tab_manager.kind_badge.table_export": "导出", + "tab_manager.kind_badge.sql_analysis": "分析", "tab_manager.kind_badge.fallback": "标签", "tab_manager.empty.action.open_ai": "打开 AI", "tab_manager.empty.aria.start_workbench": "GoNavi 起始工作台", @@ -1618,6 +1620,7 @@ "tab_manager.hover.kind.routine": "函数 / 存储过程", "tab_manager.hover.kind.table": "表数据", "tab_manager.hover.kind.table_export": "导出工作台", + "tab_manager.hover.kind.sql_analysis": "SQL 分析工作台", "tab_manager.hover.kind.table_overview": "表概览", "tab_manager.hover.kind.trigger": "触发器", "tab_manager.hover.kind.view": "视图", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index fe41951..24e42c1 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -90,6 +90,7 @@ "import_preview.result.failed_rows": "失敗 {{count}} 列", "import_preview.result.error_logs": "錯誤記錄:", "log_panel.title": "SQL 執行記錄", + "log_panel.short_title": "日誌", "log_panel.description": "記錄執行狀態、耗時與錯誤資訊,方便快速回溯。", "log_panel.action.clear": "清空記錄", "log_panel.action.close": "關閉面板", @@ -1576,6 +1577,7 @@ "tab_manager.kind_badge.event": "事件", "tab_manager.kind_badge.routine": "函式", "tab_manager.kind_badge.table_export": "匯出", + "tab_manager.kind_badge.sql_analysis": "分析", "tab_manager.kind_badge.fallback": "標籤", "tab_manager.empty.action.open_ai": "開啟 AI", "tab_manager.empty.aria.start_workbench": "GoNavi 起始工作台", @@ -1610,6 +1612,7 @@ "tab_manager.hover.kind.routine": "函數 / 程序", "tab_manager.hover.kind.table": "表資料", "tab_manager.hover.kind.table_export": "匯出工作台", + "tab_manager.hover.kind.sql_analysis": "SQL 分析工作台", "tab_manager.hover.kind.table_overview": "表概覽", "tab_manager.hover.kind.trigger": "觸發器", "tab_manager.hover.kind.view": "視圖",