mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-23 23:13:50 +08:00
✨ feat(query-editor): 收敛 SQL 分析工作台与结果区日志体验
- 新增 SQL 分析工作台,统一承载慢 SQL 和 SQL 诊断视图 - 将 SQL 执行日志收进结果区首个日志标签并在失败时展示错误摘要 - 调整侧边栏入口、标签展示、多语言文案与定向前端测试覆盖
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
{/* 慢 SQL 历史入口:浮动在 Sidebar 右下角,独立组件不依赖 Sidebar 内部 state */}
|
||||
<SlowQueryRailButton
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
bottom: isV2Ui ? 8 : 66,
|
||||
zIndex: 10,
|
||||
background: 'var(--gn-card-bg, rgba(255,255,255,0.9))',
|
||||
borderRadius: 6,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
/>
|
||||
{!connectionWorkbenchState.ready && (
|
||||
<div
|
||||
style={{
|
||||
@@ -3138,10 +3135,10 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isLogPanelOpen && (
|
||||
<LogPanel
|
||||
height={logPanelHeight}
|
||||
onClose={handleCloseLogPanel}
|
||||
{!isV2Ui && isLogPanelOpen && (
|
||||
<LogPanel
|
||||
height={logPanelHeight}
|
||||
onClose={handleCloseLogPanel}
|
||||
onResizeStart={handleLogResizeStart}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -82,15 +82,16 @@ vi.mock("@ant-design/icons", async () => {
|
||||
ClearOutlined: Icon,
|
||||
CloseOutlined: Icon,
|
||||
ClockCircleOutlined: Icon,
|
||||
RobotOutlined: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
const renderLogPanel = () => {
|
||||
const renderLogPanel = (props: Partial<React.ComponentProps<typeof LogPanel>> = {}) => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
act(() => {
|
||||
renderer = create(
|
||||
<I18nProvider preference="en-US" onPreferenceChange={() => undefined}>
|
||||
<LogPanel height={260} onClose={vi.fn()} onResizeStart={vi.fn()} />
|
||||
<LogPanel height={260} onClose={vi.fn()} onResizeStart={vi.fn()} {...props} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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<LogPanelProps> = ({ height, onClose, onResizeStart }) => {
|
||||
const LogPanel: React.FC<LogPanelProps> = ({
|
||||
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<LogPanelProps> = ({ 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<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
}
|
||||
];
|
||||
|
||||
const logTable = (
|
||||
<div
|
||||
className="log-panel-scroll"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: isEmbedded ? '0 12px 12px' : '8px 10px 10px',
|
||||
}}
|
||||
>
|
||||
{sqlLogs.length === 0 ? (
|
||||
<div style={{ height: '100%', minHeight: 160, display: 'grid', placeItems: 'center' }}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<span style={{ color: panelMutedTextColor }}>{t('log_panel.empty')}</span>}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
className="log-panel-table"
|
||||
dataSource={sqlLogs}
|
||||
columns={columns}
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
showHeader={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const sharedStyles = (
|
||||
<style>{`
|
||||
.log-panel-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${logScrollbarThumb} transparent;
|
||||
}
|
||||
.log-panel-scroll::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.log-panel-scroll::-webkit-scrollbar-track,
|
||||
.log-panel-scroll::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
.log-panel-scroll::-webkit-scrollbar-thumb {
|
||||
background: ${logScrollbarThumb};
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.log-panel-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: ${logScrollbarThumbHover};
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.log-panel-table .ant-table,
|
||||
.log-panel-table .ant-table-container,
|
||||
.log-panel-table .ant-table-tbody > tr > td {
|
||||
background: transparent !important;
|
||||
}
|
||||
.log-panel-table .ant-table-tbody > tr > td {
|
||||
padding: 8px 10px !important;
|
||||
border-bottom: 1px solid ${panelDividerColor} !important;
|
||||
}
|
||||
.log-panel-table .ant-table-tbody > tr:last-child > td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
.log-panel-table .ant-table-row:hover > td {
|
||||
background: ${darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.03)'} !important;
|
||||
}
|
||||
`}</style>
|
||||
);
|
||||
|
||||
if (isEmbedded) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: '0 0 auto',
|
||||
padding: '8px 12px',
|
||||
borderBottom: `1px solid ${panelDividerColor}`,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
minHeight: 40,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 8,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: darkMode
|
||||
? `rgba(255,214,102,${Math.max(0.08, Math.min(0.14, opacity * 0.14))})`
|
||||
: `rgba(24,144,255,${Math.max(0.06, Math.min(0.12, opacity * 0.12))})`,
|
||||
color: panelAccentColor,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<BugOutlined />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: darkMode ? '#f5f7ff' : '#162033' }}>
|
||||
{t('log_panel.description')}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: panelMutedTextColor }}>
|
||||
{logCountLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip title={t('log_panel.action.clear')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ClearOutlined />}
|
||||
onClick={clearSqlLogs}
|
||||
style={{ color: panelMutedTextColor }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{executionError && (
|
||||
<div style={{ padding: '12px 12px 0' }}>
|
||||
<div style={{
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${darkMode ? '#5c2020' : '#ffccc7'}`,
|
||||
background: darkMode ? '#2d1a1a' : '#fff2f0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}>
|
||||
<div style={{ color: '#ff7875', fontWeight: 700, fontSize: 13, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<CloseOutlined />
|
||||
<span>{t('query_editor.result.execution_failed')}</span>
|
||||
</div>
|
||||
<div
|
||||
className="log-panel-scroll"
|
||||
style={{
|
||||
paddingRight: 4,
|
||||
maxHeight: 220,
|
||||
overflow: 'auto',
|
||||
color: darkMode ? '#ffa39e' : '#cf1322',
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
fontSize: 'var(--gn-font-size-mono, 12px)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{executionError}
|
||||
</div>
|
||||
{onDiagnoseExecutionError && (
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<RobotOutlined />}
|
||||
onClick={onDiagnoseExecutionError}
|
||||
style={{ background: '#818cf8', borderColor: '#818cf8', boxShadow: '0 2px 0 rgba(129, 140, 248, 0.2)' }}
|
||||
>
|
||||
{t('query_editor.result.ai_diagnose')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{logTable}
|
||||
{sharedStyles}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height,
|
||||
@@ -103,18 +298,20 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
zIndex: 100
|
||||
}}>
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
onMouseDown={onResizeStart}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 8,
|
||||
cursor: 'row-resize',
|
||||
zIndex: 10
|
||||
}}
|
||||
/>
|
||||
{onResizeStart && (
|
||||
<div
|
||||
onMouseDown={onResizeStart}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 8,
|
||||
cursor: 'row-resize',
|
||||
zIndex: 10
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{
|
||||
@@ -139,72 +336,16 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
<Tooltip title={t('log_panel.action.clear')}>
|
||||
<Button type="text" size="small" icon={<ClearOutlined />} onClick={clearSqlLogs} style={{ color: panelMutedTextColor }} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('log_panel.action.close')}>
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: panelMutedTextColor }} />
|
||||
</Tooltip>
|
||||
{onClose && (
|
||||
<Tooltip title={t('log_panel.action.close')}>
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: panelMutedTextColor }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="log-panel-scroll" style={{ flex: 1, overflow: 'auto', padding: '8px 10px 10px' }}>
|
||||
{sqlLogs.length === 0 ? (
|
||||
<div style={{ height: '100%', minHeight: 160, display: 'grid', placeItems: 'center' }}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<span style={{ color: panelMutedTextColor }}>{t('log_panel.empty')}</span>}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
className="log-panel-table"
|
||||
dataSource={sqlLogs}
|
||||
columns={columns}
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
showHeader={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<style>{`
|
||||
.log-panel-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${logScrollbarThumb} transparent;
|
||||
}
|
||||
.log-panel-scroll::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.log-panel-scroll::-webkit-scrollbar-track,
|
||||
.log-panel-scroll::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
.log-panel-scroll::-webkit-scrollbar-thumb {
|
||||
background: ${logScrollbarThumb};
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.log-panel-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: ${logScrollbarThumbHover};
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.log-panel-table .ant-table,
|
||||
.log-panel-table .ant-table-container,
|
||||
.log-panel-table .ant-table-tbody > tr > td {
|
||||
background: transparent !important;
|
||||
}
|
||||
.log-panel-table .ant-table-tbody > tr > td {
|
||||
padding: 8px 10px !important;
|
||||
border-bottom: 1px solid ${panelDividerColor} !important;
|
||||
}
|
||||
.log-panel-table .ant-table-tbody > tr:last-child > td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
.log-panel-table .ant-table-row:hover > td {
|
||||
background: ${darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.03)'} !important;
|
||||
}
|
||||
`}</style>
|
||||
{logTable}
|
||||
{sharedStyles}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 }) => (
|
||||
<div data-log-panel={variant}>
|
||||
SQL 执行日志
|
||||
{executionError ? ` ${executionError}` : ''}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@ant-design/icons', () => {
|
||||
const Icon = () => <span />;
|
||||
return {
|
||||
BugOutlined: Icon,
|
||||
ClearOutlined: Icon,
|
||||
PlayCircleOutlined: Icon,
|
||||
SaveOutlined: Icon,
|
||||
FormatPainterOutlined: Icon,
|
||||
@@ -377,10 +397,30 @@ vi.mock('antd', () => {
|
||||
const Form: any = ({ children }: any) => <form>{children}</form>;
|
||||
Form.Item = ({ children }: any) => <>{children}</>;
|
||||
Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
|
||||
const Table = ({ dataSource, columns }: { dataSource: any[]; columns: any[] }) => (
|
||||
<div>
|
||||
{dataSource.map((record) => (
|
||||
<div key={record.id}>
|
||||
{columns.map((column) => (
|
||||
<div key={column.dataIndex || column.title}>
|
||||
{column.render
|
||||
? column.render(record[column.dataIndex], record)
|
||||
: record[column.dataIndex]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const Empty = ({ description }: { description?: React.ReactNode }) => <div>{description}</div>;
|
||||
(Empty as any).PRESENTED_IMAGE_SIMPLE = 'simple';
|
||||
|
||||
return {
|
||||
Button,
|
||||
Space,
|
||||
Table,
|
||||
Tag: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
|
||||
Empty,
|
||||
message: messageApi,
|
||||
Modal: ({ children, open, onOk, okText = '确认' }: any) => (open ? (
|
||||
<section>
|
||||
@@ -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(<QueryEditor tab={createTab()} />);
|
||||
renderer = create(
|
||||
<I18nProvider preference="zh-CN" onPreferenceChange={() => undefined}>
|
||||
<QueryEditor tab={createTab()} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).not.toContain('等待执行 SQL');
|
||||
@@ -678,7 +724,11 @@ describe('QueryEditor external SQL save', () => {
|
||||
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab()} />);
|
||||
renderer = create(
|
||||
<I18nProvider preference="zh-CN" onPreferenceChange={() => undefined}>
|
||||
<QueryEditor tab={createTab()} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
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<string, ((event?: any) => 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(<QueryEditor tab={createTab()} />);
|
||||
});
|
||||
|
||||
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(<QueryEditor tab={createTab({ query: 'select 1;' })} />);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 = () => <span />;
|
||||
return {
|
||||
BugOutlined: Icon,
|
||||
ClearOutlined: Icon,
|
||||
PlayCircleOutlined: Icon,
|
||||
SaveOutlined: Icon,
|
||||
FormatPainterOutlined: Icon,
|
||||
@@ -377,10 +387,30 @@ vi.mock('antd', () => {
|
||||
const Form: any = ({ children }: any) => <form>{children}</form>;
|
||||
Form.Item = ({ children }: any) => <>{children}</>;
|
||||
Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
|
||||
const Table = ({ dataSource, columns }: { dataSource: any[]; columns: any[] }) => (
|
||||
<div>
|
||||
{dataSource.map((record) => (
|
||||
<div key={record.id}>
|
||||
{columns.map((column) => (
|
||||
<div key={column.dataIndex || column.title}>
|
||||
{column.render
|
||||
? column.render(record[column.dataIndex], record)
|
||||
: record[column.dataIndex]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const Empty = ({ description }: { description?: React.ReactNode }) => <div>{description}</div>;
|
||||
(Empty as any).PRESENTED_IMAGE_SIMPLE = 'simple';
|
||||
|
||||
return {
|
||||
Button,
|
||||
Space,
|
||||
Table,
|
||||
Tag: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
|
||||
Empty,
|
||||
message: messageApi,
|
||||
Modal: ({ children, open, onOk, okText = '确认' }: any) => (open ? (
|
||||
<section>
|
||||
@@ -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('<LogPanel');
|
||||
expect(panelSource).toContain('variant="embedded"');
|
||||
expect(panelSource).toContain('executionError={executionError}');
|
||||
expect(panelSource).toContain("t('log_panel.short_title')");
|
||||
expect(panelSource).toContain('[logTabItem, ...resultTabItems]');
|
||||
expect(editorSource).toContain("window.addEventListener('gonavi:show-sql-execution-log'");
|
||||
expect(editorSource).toContain('setActiveResultKey(QUERY_EDITOR_SQL_LOG_TAB_KEY)');
|
||||
});
|
||||
|
||||
it('keeps the v2 query editor toolbar grouped and compact', () => {
|
||||
const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8');
|
||||
const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8');
|
||||
|
||||
@@ -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('<ExplainWorkbench')
|
||||
expect(source).not.toContain('<SlowQueryPanel')
|
||||
})
|
||||
|
||||
it('opens the same sql-analysis workbench from the sidebar slow-query button', () => {
|
||||
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('<Tabs')
|
||||
})
|
||||
|
||||
it('uses a compact segmented switcher inside the explain report view', () => {
|
||||
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('<Tabs')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback, lazy, Suspense } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import Editor, { type OnMount } from './MonacoEditor';
|
||||
import { message, Input, Form, MenuProps } from 'antd';
|
||||
import { format } from 'sql-formatter';
|
||||
@@ -31,11 +31,8 @@ import { splitSidebarQualifiedName } from '../utils/sidebarLocate';
|
||||
import { buildMySQLCompatibleViewMetadataSqls, isSidebarViewTableType, normalizeSidebarViewName } from '../utils/sidebarMetadata';
|
||||
import { SIDEBAR_SQL_EDITOR_DRAG_MIME, decodeSidebarSqlEditorDragPayload, hasSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag';
|
||||
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 { 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<string>(tab.connectionId);
|
||||
const [currentDb, setCurrentDb] = useState<string>(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
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
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
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
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
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* SQL 诊断工作台:Ctrl+Shift+D 触发,lazy 加载避免 reactflow 进入主 bundle */}
|
||||
<Suspense fallback={null}>
|
||||
{explainOpen && explainConfig && (
|
||||
<ExplainWorkbench
|
||||
open={explainOpen}
|
||||
onClose={() => setExplainOpen(false)}
|
||||
config={explainConfig}
|
||||
dbName={currentDb}
|
||||
sql={query}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<QueryEditorResultsPanelProps> = ({
|
||||
activeResultKey,
|
||||
loading,
|
||||
executionError,
|
||||
sqlLogCount,
|
||||
darkMode,
|
||||
isV2Ui,
|
||||
currentDb,
|
||||
@@ -76,44 +81,253 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
}) => {
|
||||
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 = (
|
||||
<Tooltip title={hideTooltipTitle}>
|
||||
<Button
|
||||
className={isV2Ui ? 'gn-v2-query-result-toolbar-hide' : undefined}
|
||||
icon={<EyeInvisibleOutlined />}
|
||||
onClick={onHide}
|
||||
>
|
||||
<span>{t('query_editor.results_panel.action.hide')}</span>
|
||||
{isV2Ui && toggleShortcutLabel && (
|
||||
<span className="gn-v2-toolbar-kbd">{toggleShortcutLabel}</span>
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
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: (
|
||||
<Dropdown
|
||||
menu={{ items: buildResultTabMenuItems(rs.key, idx) }}
|
||||
trigger={['contextMenu']}
|
||||
rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}
|
||||
>
|
||||
<div
|
||||
className="query-result-tab-label"
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Tooltip title={rs.sql}>
|
||||
<span className="query-result-tab-text">
|
||||
{rs.resultType === 'message'
|
||||
? t('query_editor.results_panel.tab.message', { index: idx + 1 })
|
||||
: t('query_editor.results_panel.tab.result', { index: idx + 1 })}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{(() => {
|
||||
if (rs.resultType === 'message') {
|
||||
return <span className="query-result-tab-count">i</span>;
|
||||
}
|
||||
if (isAffectedRowsResult(rs)) {
|
||||
return <span className="query-result-tab-count">✓</span>;
|
||||
}
|
||||
if (!Array.isArray(rs.rows)) {
|
||||
return null;
|
||||
}
|
||||
return <span className="query-result-tab-count">{rs.rows.length}</span>;
|
||||
})()}
|
||||
<Tooltip title={t('query_editor.result.close')}>
|
||||
<span
|
||||
className="query-result-tab-close"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCloseResult(rs.key);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Dropdown>
|
||||
),
|
||||
children: (() => {
|
||||
if (rs.resultType === 'message') {
|
||||
return (
|
||||
<div className={isV2Ui ? 'gn-v2-query-success' : undefined} style={{
|
||||
flex: 1, minHeight: 0, display: 'flex', justifyContent: 'center',
|
||||
flexDirection: 'column', gap: 12, padding: 24, color: '#666', userSelect: 'text',
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('query_editor.results_panel.message.title')}</span>
|
||||
<div style={{
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : '#fff',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
fontSize: 'var(--gn-font-size-mono, 13px)',
|
||||
}}>
|
||||
{(rs.messages || []).join('\n')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isAffectedRowsResult(rs)) {
|
||||
const affected = Number(rs.rows[0]?.affectedRows ?? 0);
|
||||
return (
|
||||
<div className={isV2Ui ? 'gn-v2-query-success' : undefined} style={{
|
||||
flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexDirection: 'column', gap: 8, color: '#666', userSelect: 'text',
|
||||
}}>
|
||||
<span style={{ fontSize: 36, color: '#52c41a' }}>✓</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 500 }}>{t('query_editor.result.execution_success')}</span>
|
||||
<span style={{ fontSize: 13, color: '#999' }}>{t('query_editor.result.affected_rows', { count: affected })}</span>
|
||||
{Array.isArray(rs.messages) && rs.messages.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 8,
|
||||
maxWidth: 720,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : '#fff',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
fontSize: 'var(--gn-font-size-mono, 12px)',
|
||||
}}>
|
||||
{rs.messages.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{Array.isArray(rs.messages) && rs.messages.length > 0 && (
|
||||
<div style={{
|
||||
flex: '0 0 auto',
|
||||
margin: '8px 8px 0',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : '#fff',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
fontSize: 'var(--gn-font-size-mono, 12px)',
|
||||
color: darkMode ? '#d4d4d4' : '#666',
|
||||
}}>
|
||||
{rs.messages.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
<DataGrid
|
||||
data={rs.rows}
|
||||
columnNames={rs.columns}
|
||||
loading={loading || rs.page?.loading === true}
|
||||
tableName={rs.tableName}
|
||||
exportScope="queryResult"
|
||||
resultSql={rs.exportSql || rs.sql}
|
||||
resultExportAllSql={rs.page?.exportAllSql}
|
||||
dbName={currentDb}
|
||||
connectionId={currentConnectionId}
|
||||
pkColumns={rs.pkColumns}
|
||||
editLocator={rs.editLocator}
|
||||
showRowNumberColumn={rs.showRowNumberColumn}
|
||||
onReload={() => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})(),
|
||||
}));
|
||||
|
||||
const resultTabItems = buildResultTabItems();
|
||||
const logTabItem = shouldShowSqlLogTab
|
||||
? {
|
||||
key: QUERY_EDITOR_SQL_LOG_TAB_KEY,
|
||||
label: (
|
||||
<Tooltip title={t('log_panel.title')}>
|
||||
<div className="query-result-tab-label">
|
||||
<BugOutlined style={{ fontSize: 12 }} />
|
||||
<span className="query-result-tab-text">{t('log_panel.short_title')}</span>
|
||||
<span className="query-result-tab-count">{logTabCountLabel}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
),
|
||||
children: (
|
||||
<LogPanel
|
||||
variant="embedded"
|
||||
executionError={executionError}
|
||||
onDiagnoseExecutionError={executionError ? onDiagnoseExecutionError : undefined}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: 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 = (
|
||||
<Tooltip title={hideTooltipTitle}>
|
||||
@@ -151,21 +365,6 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const toolbarHideButton = (
|
||||
<Tooltip title={hideTooltipTitle}>
|
||||
<Button
|
||||
className={isV2Ui ? 'gn-v2-query-result-toolbar-hide' : undefined}
|
||||
icon={<EyeInvisibleOutlined />}
|
||||
onClick={onHide}
|
||||
>
|
||||
<span>{t('query_editor.results_panel.action.hide')}</span>
|
||||
{isV2Ui && toggleShortcutLabel && (
|
||||
<span className="gn-v2-toolbar-kbd">{toggleShortcutLabel}</span>
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@@ -332,7 +531,7 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
className={isV2Ui ? 'gn-v2-query-results' : undefined}
|
||||
style={{ position: 'relative', flex: 1, minHeight: 0, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{resultSets.length > 0 ? (
|
||||
{tabItems.length > 0 ? (
|
||||
<Tabs
|
||||
className="query-result-tabs"
|
||||
activeKey={resolvedActiveResultKey}
|
||||
@@ -340,160 +539,7 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
animated={false}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
tabBarExtraContent={tabsExtraContent}
|
||||
items={resultSets.map((rs, idx) => ({
|
||||
key: rs.key,
|
||||
label: (
|
||||
<Dropdown
|
||||
menu={{ items: buildResultTabMenuItems(rs.key, idx) }}
|
||||
trigger={['contextMenu']}
|
||||
rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}
|
||||
>
|
||||
<div
|
||||
className="query-result-tab-label"
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Tooltip title={rs.sql}>
|
||||
<span className="query-result-tab-text">
|
||||
{rs.resultType === 'message'
|
||||
? t('query_editor.results_panel.tab.message', { index: idx + 1 })
|
||||
: t('query_editor.results_panel.tab.result', { index: idx + 1 })}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{(() => {
|
||||
if (rs.resultType === 'message') {
|
||||
return <span className="query-result-tab-count">i</span>;
|
||||
}
|
||||
if (isAffectedRowsResult(rs)) {
|
||||
return <span className="query-result-tab-count">✓</span>;
|
||||
}
|
||||
if (!Array.isArray(rs.rows)) {
|
||||
return null;
|
||||
}
|
||||
return <span className="query-result-tab-count">{rs.rows.length}</span>;
|
||||
})()}
|
||||
<Tooltip title={t('query_editor.result.close')}>
|
||||
<span
|
||||
className="query-result-tab-close"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCloseResult(rs.key);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Dropdown>
|
||||
),
|
||||
children: (() => {
|
||||
if (rs.resultType === 'message') {
|
||||
return (
|
||||
<div className={isV2Ui ? 'gn-v2-query-success' : undefined} style={{
|
||||
flex: 1, minHeight: 0, display: 'flex', justifyContent: 'center',
|
||||
flexDirection: 'column', gap: 12, padding: 24, color: '#666', userSelect: 'text',
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('query_editor.results_panel.message.title')}</span>
|
||||
<div style={{
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : '#fff',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
fontSize: 'var(--gn-font-size-mono, 13px)',
|
||||
}}>
|
||||
{(rs.messages || []).join('\n')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isAffectedRowsResult(rs)) {
|
||||
const affected = Number(rs.rows[0]?.affectedRows ?? 0);
|
||||
return (
|
||||
<div className={isV2Ui ? 'gn-v2-query-success' : undefined} style={{
|
||||
flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexDirection: 'column', gap: 8, color: '#666', userSelect: 'text',
|
||||
}}>
|
||||
<span style={{ fontSize: 36, color: '#52c41a' }}>✓</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 500 }}>{t('query_editor.result.execution_success')}</span>
|
||||
<span style={{ fontSize: 13, color: '#999' }}>{t('query_editor.result.affected_rows', { count: affected })}</span>
|
||||
{Array.isArray(rs.messages) && rs.messages.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 8,
|
||||
maxWidth: 720,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : '#fff',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
fontSize: 'var(--gn-font-size-mono, 12px)',
|
||||
}}>
|
||||
{rs.messages.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{Array.isArray(rs.messages) && rs.messages.length > 0 && (
|
||||
<div style={{
|
||||
flex: '0 0 auto',
|
||||
margin: '8px 8px 0',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : '#fff',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
fontSize: 'var(--gn-font-size-mono, 12px)',
|
||||
color: darkMode ? '#d4d4d4' : '#666',
|
||||
}}>
|
||||
{rs.messages.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
<DataGrid
|
||||
data={rs.rows}
|
||||
columnNames={rs.columns}
|
||||
loading={loading || rs.page?.loading === true}
|
||||
tableName={rs.tableName}
|
||||
exportScope="queryResult"
|
||||
resultSql={rs.exportSql || rs.sql}
|
||||
resultExportAllSql={rs.page?.exportAllSql}
|
||||
dbName={currentDb}
|
||||
connectionId={currentConnectionId}
|
||||
pkColumns={rs.pkColumns}
|
||||
editLocator={rs.editLocator}
|
||||
showRowNumberColumn={rs.showRowNumberColumn}
|
||||
onReload={() => {
|
||||
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={resolvedActiveResultKey === rs.key ? toolbarHideButton : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})(),
|
||||
}))}
|
||||
items={tabItems}
|
||||
/>
|
||||
) : executionError ? (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SidebarConnectionRail from './sidebar/SidebarConnectionRail';
|
||||
import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/SidebarSearchPanel';
|
||||
import SlowQueryRailButton from './sidebar/SlowQueryRailButton';
|
||||
import { buildSidebarLegacyNodeMenuItems } from './sidebar/sidebarLegacyNodeMenu';
|
||||
import {
|
||||
getMetadataDialect,
|
||||
@@ -2782,6 +2783,10 @@ const Sidebar: React.FC<{
|
||||
<span>SQL 执行日志</span>
|
||||
<small>{sqlLogCount.toLocaleString()}</small>
|
||||
</button>
|
||||
<SlowQueryRailButton
|
||||
className="gn-v2-sidebar-slow-query-button"
|
||||
tooltipPlacement="top"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -123,6 +123,7 @@ describe('TabManager hover info', () => {
|
||||
'view',
|
||||
'event',
|
||||
'routine',
|
||||
'sql_analysis',
|
||||
'fallback',
|
||||
].forEach((name) => {
|
||||
expect(getTabKindLabelSource).toContain(`t('tab_manager.kind_badge.${name}')`);
|
||||
|
||||
@@ -24,6 +24,7 @@ import JVMResourceBrowser from './JVMResourceBrowser';
|
||||
import JVMAuditViewer from './JVMAuditViewer';
|
||||
import JVMDiagnosticConsole from './JVMDiagnosticConsole';
|
||||
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
|
||||
import SqlAnalysisWorkbench from './explain/SqlAnalysisWorkbench';
|
||||
import type { TabData } from '../types';
|
||||
import { t } from '../i18n';
|
||||
import {
|
||||
@@ -49,6 +50,7 @@ const getTabKindLabel = (tab: TabData): string => {
|
||||
if (tab.type === 'design') return t('tab_manager.kind_badge.design');
|
||||
if (tab.type === 'table-overview') return t('tab_manager.kind_badge.table_overview');
|
||||
if (tab.type === 'table-export') return t('tab_manager.kind_badge.table_export');
|
||||
if (tab.type === 'sql-analysis') return t('tab_manager.kind_badge.sql_analysis');
|
||||
if (tab.type.startsWith('redis')) return t('tab_manager.kind_badge.redis');
|
||||
if (tab.type.startsWith('jvm')) return t('tab_manager.kind_badge.jvm');
|
||||
if (tab.type === 'trigger') return t('tab_manager.kind_badge.trigger');
|
||||
@@ -70,6 +72,7 @@ const getTabKindTooltipLabel = (tab: TabData): string => {
|
||||
if (tab.type === 'design') return t('tab_manager.hover.kind.design');
|
||||
if (tab.type === 'table-overview') return t('tab_manager.hover.kind.table_overview');
|
||||
if (tab.type === 'table-export') return t('tab_manager.hover.kind.table_export');
|
||||
if (tab.type === 'sql-analysis') return t('tab_manager.hover.kind.sql_analysis');
|
||||
if (tab.type === 'redis-keys') return t('tab_manager.hover.kind.redis_keys');
|
||||
if (tab.type === 'redis-command') return t('tab_manager.hover.kind.redis_command');
|
||||
if (tab.type === 'redis-monitor') return t('tab_manager.hover.kind.redis_monitor');
|
||||
@@ -97,6 +100,7 @@ const getTabObjectLabel = (tab: TabData): string => {
|
||||
if (tab.triggerName) return tab.triggerName;
|
||||
if (tab.resourcePath) return tab.resourcePath;
|
||||
if (tab.filePath) return tab.filePath;
|
||||
if (tab.type === 'sql-analysis') return tab.title;
|
||||
if (tab.type.startsWith('redis')) return `db${tab.redisDB ?? 0}`;
|
||||
return '';
|
||||
};
|
||||
@@ -414,6 +418,9 @@ const TabContent: React.FC<{ tab: TabData; isActive: boolean }> = React.memo(({
|
||||
if (tab.type === 'table-export') {
|
||||
return <TableExportWorkbench tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'sql-analysis') {
|
||||
return <SqlAnalysisWorkbench tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-overview') {
|
||||
return <JVMOverview tab={tab} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
255
frontend/src/components/explain/SqlAnalysisWorkbench.tsx
Normal file
255
frontend/src/components/explain/SqlAnalysisWorkbench.tsx
Normal 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;
|
||||
}
|
||||
`
|
||||
@@ -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<ConnectionConfig | null>(() => {
|
||||
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 (
|
||||
<>
|
||||
<Tooltip title={tooltipText} placement="right">
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => !buttonDisabled && setOpen(true)}
|
||||
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,
|
||||
}}
|
||||
aria-label="慢 SQL 历史"
|
||||
>
|
||||
<HistoryOutlined style={{ fontSize: 16 }} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{open && activeConfig && (
|
||||
<Suspense fallback={null}>
|
||||
<SlowQueryPanel
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
config={activeConfig}
|
||||
dbName={dbName}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
<Tooltip title={tooltipText} placement={tooltipPlacement}>
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => {
|
||||
if (buttonDisabled || !activeTab?.connectionId) {
|
||||
return
|
||||
}
|
||||
addTab(buildSqlAnalysisWorkbenchTab({
|
||||
connectionId: activeTab.connectionId,
|
||||
dbName: activeTab.dbName,
|
||||
view: 'slow-query',
|
||||
}))
|
||||
}}
|
||||
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,
|
||||
}}
|
||||
aria-label="慢 SQL 工作台"
|
||||
>
|
||||
<HistoryOutlined style={{ fontSize: 16 }} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Record<TableExportScope, string>>;
|
||||
tableExportRowCountByScope?: Partial<Record<TableExportScope, number>>;
|
||||
sqlAnalysisView?: "diagnose" | "slow-query";
|
||||
sqlAnalysisRequestKey?: string;
|
||||
formatRestoreSnapshot?: {
|
||||
query: string;
|
||||
createdAt: number;
|
||||
|
||||
42
frontend/src/utils/sqlAnalysisTab.test.ts
Normal file
42
frontend/src/utils/sqlAnalysisTab.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
42
frontend/src/utils/sqlAnalysisTab.ts
Normal file
42
frontend/src/utils/sqlAnalysisTab.ts
Normal file
@@ -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()}`,
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ビュー",
|
||||
|
||||
@@ -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": "Представление",
|
||||
|
||||
@@ -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": "视图",
|
||||
|
||||
@@ -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": "視圖",
|
||||
|
||||
Reference in New Issue
Block a user