diff --git a/cmd/optional-driver-agent/provider_clickhouse.go b/cmd/optional-driver-agent/provider_clickhouse.go new file mode 100644 index 0000000..0df04ba --- /dev/null +++ b/cmd/optional-driver-agent/provider_clickhouse.go @@ -0,0 +1,12 @@ +//go:build gonavi_clickhouse_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "clickhouse" + agentDatabaseFactory = func() db.Database { + return &db.ClickHouseDB{} + } +} diff --git a/docs/driver-manifest.json b/docs/driver-manifest.json index a1c0c7c..aaef2d8 100644 --- a/docs/driver-manifest.json +++ b/docs/driver-manifest.json @@ -73,6 +73,12 @@ "checksumPolicy": "off", "downloadUrl": "builtin://activate/tdengine" }, + "clickhouse": { + "engine": "go", + "version": "2.43.0", + "checksumPolicy": "off", + "downloadUrl": "builtin://activate/clickhouse" + }, "postgres": { "engine": "go", "version": "1.11.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d8db66..c189de5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; -import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch } from 'antd'; +import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd'; import zhCN from 'antd/locale/zh_CN'; -import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons'; +import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons'; import { Environment, EventsOn, WindowFullscreen, WindowIsFullscreen, WindowIsMaximised, WindowMaximise } from '../wailsjs/runtime/runtime'; import Sidebar from './components/Sidebar'; import TabManager from './components/TabManager'; @@ -12,7 +12,7 @@ import LogPanel from './components/LogPanel'; import { useStore } from './store'; import { SavedConnection } from './types'; import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform } from './utils/appearance'; -import { SetWindowTranslucency } from '../wailsjs/go/app/App'; +import { ConfigureGlobalProxy, SetWindowTranslucency } from '../wailsjs/go/app/App'; import './App.css'; const { Sider, Content } = Layout; @@ -28,12 +28,16 @@ function App() { const setAppearance = useStore(state => state.setAppearance); const startupFullscreen = useStore(state => state.startupFullscreen); const setStartupFullscreen = useStore(state => state.setStartupFullscreen); + const globalProxy = useStore(state => state.globalProxy); + const setGlobalProxy = useStore(state => state.setGlobalProxy); const darkMode = themeMode === 'dark'; const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity); const effectiveBlur = normalizeBlurForPlatform(appearance.blur); const blurFilter = blurToFilter(effectiveBlur); const windowCornerRadius = 14; const [isLinuxRuntime, setIsLinuxRuntime] = useState(false); + const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated()); + const globalProxyInvalidHintShownRef = React.useRef(false); // 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView, // 避免 GPU 持续计算窗口背后的模糊合成 @@ -58,6 +62,83 @@ function App() { }; }, []); + useEffect(() => { + if (isStoreHydrated) { + return; + } + const unsubscribe = useStore.persist.onFinishHydration(() => { + setIsStoreHydrated(true); + }); + return () => { + unsubscribe(); + }; + }, [isStoreHydrated]); + + useEffect(() => { + if (!isStoreHydrated) { + return; + } + + const host = String(globalProxy.host || '').trim(); + const port = Number(globalProxy.port); + const portValid = Number.isFinite(port) && port > 0 && port <= 65535; + const invalidWhenEnabled = globalProxy.enabled && (!host || !portValid); + + if (invalidWhenEnabled) { + if (!globalProxyInvalidHintShownRef.current) { + message.warning({ + content: '全局代理已开启,但地址或端口无效,当前按未启用处理', + key: 'global-proxy-invalid', + }); + globalProxyInvalidHintShownRef.current = true; + } + } else { + globalProxyInvalidHintShownRef.current = false; + message.destroy('global-proxy-invalid'); + } + + const enabledForBackend = globalProxy.enabled && !invalidWhenEnabled; + let cancelled = false; + ConfigureGlobalProxy(enabledForBackend, { + type: globalProxy.type, + host, + port: portValid ? port : (globalProxy.type === 'http' ? 8080 : 1080), + user: String(globalProxy.user || '').trim(), + password: globalProxy.password || '', + }) + .then((res) => { + if (cancelled || res?.success) { + return; + } + message.error({ + content: '全局代理配置失败: ' + (res?.message || '未知错误'), + key: 'global-proxy-sync-error', + }); + }) + .catch((err) => { + if (cancelled) { + return; + } + const errMsg = err instanceof Error ? err.message : String(err || '未知错误'); + message.error({ + content: '全局代理配置失败: ' + errMsg, + key: 'global-proxy-sync-error', + }); + }); + + return () => { + cancelled = true; + }; + }, [ + isStoreHydrated, + globalProxy.enabled, + globalProxy.type, + globalProxy.host, + globalProxy.port, + globalProxy.user, + globalProxy.password, + ]); + useEffect(() => { let cancelled = false; let startupWindowTimer: number | null = null; @@ -492,6 +573,7 @@ function App() { ]; const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false); + const [isProxyModalOpen, setIsProxyModalOpen] = useState(false); // Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制 @@ -814,6 +896,7 @@ function App() { + @@ -954,7 +1037,7 @@ function App() { open={isAppearanceModalOpen} onCancel={() => setIsAppearanceModalOpen(false)} footer={null} - width={400} + width={460} >
@@ -1008,6 +1091,81 @@ function App() {
+ setIsProxyModalOpen(false)} + footer={null} + width={460} + > +
+
+
全局代理
+
+ 启用全局代理 + setGlobalProxy({ enabled: checked })} /> +
+
+
+
代理类型
+ setGlobalProxy({ host: e.target.value })} + /> +
+
+
用户名(可选)
+ setGlobalProxy({ user: e.target.value })} + /> +
+
+
密码(可选)
+ setGlobalProxy({ password: e.target.value })} + /> +
+
+
+ * 作用于更新检查、驱动管理网络请求,以及未单独配置代理的数据库连接 +
+
+
+
+ { case 'mysql': return 3306; case 'diros': return 9030; case 'sphinx': return 9306; + case 'clickhouse': return 9000; case 'postgres': return 5432; case 'redis': return 6379; case 'tdengine': return 6041; @@ -407,6 +408,31 @@ const ConnectionModal: React.FC<{ }; } + if (type === 'clickhouse') { + const parsed = parseMultiHostUri(trimmedUri, 'clickhouse'); + if (!parsed) { + return null; + } + if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { + return null; + } + if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { + return null; + } + const hostList = normalizeAddressList(parsed.hosts, 9000); + if (!hostList.length) { + return null; + } + const primary = parseHostPort(hostList[0] || 'localhost:9000', 9000); + return { + host: primary?.host || 'localhost', + port: primary?.port || 9000, + user: parsed.username, + password: parsed.password, + database: parsed.database || '', + }; + } + return null; }; @@ -441,6 +467,9 @@ const ConnectionModal: React.FC<{ if (dbType === 'mongodb') { return 'mongodb+srv://user:pass@cluster0.example.com/db_name?authSource=admin&authMechanism=SCRAM-SHA-256'; } + if (dbType === 'clickhouse') { + return 'clickhouse://default:pass@127.0.0.1:9000/default'; + } return '例如: postgres://user:pass@127.0.0.1:5432/db_name'; }; @@ -1060,7 +1089,9 @@ const ConnectionModal: React.FC<{ mongoReplicaPassword: '', }); } else if (type !== 'custom') { + const defaultUser = type === 'clickhouse' ? 'default' : 'root'; form.setFieldsValue({ + user: defaultUser, database: '', port: defaultPort, mysqlTopology: 'single', @@ -1102,6 +1133,7 @@ const ConnectionModal: React.FC<{ { key: 'mariadb', name: 'MariaDB', icon: }, { key: 'diros', name: 'Diros', icon: }, { key: 'sphinx', name: 'Sphinx', icon: }, + { key: 'clickhouse', name: 'ClickHouse', icon: }, { key: 'postgres', name: 'PostgreSQL', icon: }, { key: 'sqlserver', name: 'SQL Server', icon: }, { key: 'sqlite', name: 'SQLite', icon: }, diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 6cfec4f..f1903f8 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -605,7 +605,6 @@ const DataGrid: React.FC = ({ dataIndex: '', title: '', }); - const [cellSetValueInput, setCellSetValueInput] = useState(''); const containerRef = useRef(null); const pendingScrollToBottomRef = useRef(false); @@ -671,7 +670,6 @@ const DataGrid: React.FC = ({ dataIndex, title: titleText, }); - setCellSetValueInput(toFormText(record[dataIndex])); }, []); // Helper to export specific data @@ -1409,6 +1407,18 @@ const DataGrid: React.FC = ({ const hasChanges = addedRows.length > 0 || Object.keys(modifiedRows).length > 0 || deletedRowKeys.size > 0; + const addedRowKeySet = useMemo(() => { + const next = new Set(); + addedRows.forEach((row) => { + const key = row?.[GONAVI_ROW_KEY]; + if (key === undefined || key === null) return; + next.add(rowKeyStr(key)); + }); + return next; + }, [addedRows, rowKeyStr]); + + const modifiedRowKeySet = useMemo(() => new Set(Object.keys(modifiedRows)), [modifiedRows]); + const handleTableChange = (pag: any, filtersArg: any, sorter: any) => { if (isResizingRef.current) return; // Block sort if resizing if (sorter.field) { @@ -1560,12 +1570,6 @@ const DataGrid: React.FC = ({ setCellContextMenu(prev => ({ ...prev, visible: false })); }, [cellContextMenu, handleCellSave]); - const handleCellSetValue = useCallback(() => { - if (!cellContextMenu.record) return; - handleCellSave({ ...cellContextMenu.record, [cellContextMenu.dataIndex]: cellSetValueInput }); - setCellContextMenu(prev => ({ ...prev, visible: false })); - }, [cellContextMenu, cellSetValueInput, handleCellSave]); - const handleCellEditorSave = useCallback(() => { if (!cellEditorMeta) return; const apply = cellEditorApplyRef.current; @@ -1888,6 +1892,11 @@ const DataGrid: React.FC = ({ {formatCellValue(text)}
), + shouldCellUpdate: (record: Item, prevRecord: Item) => { + const rowKeyChanged = record?.[GONAVI_ROW_KEY] !== prevRecord?.[GONAVI_ROW_KEY]; + if (rowKeyChanged) return true; + return !isCellValueEqualForDiff(record?.[key], prevRecord?.[key]); + }, onHeaderCell: (column: any) => ({ width: column.width, onResizeStart: handleResizeStart(key), // Only need start @@ -2380,6 +2389,31 @@ const DataGrid: React.FC = ({ header: { cell: ResizableTitle } }), []); + const dataContextValue = useMemo(() => ({ + selectedRowKeysRef, + displayDataRef, + handleCopyInsert, + handleCopyJson, + handleCopyCsv, + handleExportSelected, + copyToClipboard, + tableName, + enableRowContextMenu: !canModifyData, + }), [handleCopyCsv, handleCopyInsert, handleCopyJson, handleExportSelected, copyToClipboard, tableName, canModifyData]); + + const cellContextMenuValue = useMemo(() => ({ + showMenu: showCellContextMenu, + handleBatchFillToSelected, + }), [showCellContextMenu, handleBatchFillToSelected]); + + const rowSelectionConfig = useMemo(() => ({ + selectedRowKeys, + onChange: setSelectedRowKeys, + columnWidth: selectionColumnWidth, + }), [selectedRowKeys, selectionColumnWidth]); + + const rowPropsFactory = useCallback((record: any) => ({ record } as any), []); + const totalWidth = columns.reduce((sum, col) => sum + (Number(col.width) || 200), 0) + selectionColumnWidth; const enableVirtual = mergedDisplayData.length >= 200; const tableScrollX = useMemo(() => { @@ -2779,8 +2813,8 @@ const DataGrid: React.FC = ({ {viewMode === 'table' ? (
- - + + = ({ scroll={{ x: tableScrollX, y: tableHeight }} sticky={tableStickyConfig} virtual={enableVirtual} - loading={loading} + loading={loading} rowKey={GONAVI_ROW_KEY} pagination={false} onChange={handleTableChange} bordered - rowSelection={{ - selectedRowKeys, - onChange: setSelectedRowKeys, - columnWidth: selectionColumnWidth, - }} + rowSelection={rowSelectionConfig} rowClassName={(record) => { const k = record?.[GONAVI_ROW_KEY]; - if (k !== undefined && addedRows.some(r => r?.[GONAVI_ROW_KEY] === k)) return 'row-added'; - if (k !== undefined && (modifiedRows[rowKeyStr(k)] || deletedRowKeys.has(rowKeyStr(k)))) return 'row-modified'; // deleted won't show + if (k === undefined || k === null) return ''; + const keyStr = rowKeyStr(k); + if (addedRowKeySet.has(keyStr)) return 'row-added'; + if (modifiedRowKeySet.has(keyStr) || deletedRowKeys.has(keyStr)) return 'row-modified'; // deleted won't show return ''; }} - onRow={(record) => ({ record } as any)} + onRow={rowPropsFactory} /> diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index cedcf41..2fd8e83 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -31,7 +31,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const [showFilter, setShowFilter] = useState(false); const [filterConditions, setFilterConditions] = useState([]); const currentConnType = (connections.find(c => c.id === tab.connectionId)?.config?.type || '').toLowerCase(); - const forceReadOnly = currentConnType === 'tdengine'; + const forceReadOnly = currentConnType === 'tdengine' || currentConnType === 'clickhouse'; useEffect(() => { setPkColumns([]); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 69d4199..347f43e 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -922,7 +922,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const applyAutoLimit = (sql: string, dbType: string, maxRows: number): { sql: string; applied: boolean; maxRows: number } => { const normalizedType = (dbType || 'mysql').toLowerCase(); - const supportsLimit = normalizedType === 'mysql' || normalizedType === 'mariadb' || normalizedType === 'diros' || normalizedType === 'sphinx' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'duckdb' || normalizedType === 'tdengine' || normalizedType === ''; + const supportsLimit = normalizedType === 'mysql' || normalizedType === 'mariadb' || normalizedType === 'diros' || normalizedType === 'sphinx' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'duckdb' || normalizedType === 'tdengine' || normalizedType === 'clickhouse' || normalizedType === ''; if (!supportsLimit) return { sql, applied: false, maxRows }; if (!Number.isFinite(maxRows) || maxRows <= 0) return { sql, applied: false, maxRows }; @@ -1001,7 +1001,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const maxRows = Number(queryOptions?.maxRows) || 0; const dbType = String((config as any).type || 'mysql'); const normalizedDbType = dbType.toLowerCase(); - const forceReadOnlyResult = normalizedDbType === 'tdengine'; + const forceReadOnlyResult = normalizedDbType === 'tdengine' || normalizedDbType === 'clickhouse'; const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0; const probeLimit = wantsLimitProbe ? (maxRows + 1) : 0; let anyTruncated = false; diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index d998950..0aa3750 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -16,6 +16,10 @@ const REDIS_TREE_KEY_TTL_WIDTH = 92; const REDIS_TREE_HIDE_TTL_THRESHOLD = 460; const REDIS_KEY_INITIAL_LOAD_COUNT = 2000; const REDIS_KEY_LOAD_MORE_COUNT = 2000; +const REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT = 600; +const REDIS_KEY_SEARCH_LOAD_MORE_COUNT = 1000; +const REDIS_LARGE_KEYSPACE_THRESHOLD = 10000; +const REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS = 200; interface RedisViewerProps { connectionId: string; @@ -241,36 +245,62 @@ type RedisKeyTreeGroup = { path: string; children: Map; leaves: RedisKeyTreeLeaf[]; + leafCount: number; }; type RedisKeyTreeResult = { - treeData: DataNode[]; - rawKeyByNodeKey: Map; - leafNodeKeyByRawKey: Map; + treeData: RedisTreeDataNode[]; groupKeys: string[]; }; +type RedisTreeDataNode = DataNode & { + nodeType: 'group' | 'leaf'; + groupName?: string; + groupLeafCount?: number; + leafLabel?: string; + rawKey?: string; + keyType?: string; + ttl?: number; +}; + +const buildLeafNodeKey = (rawKey: string): string => `key:${rawKey}`; + +const parseRawKeyFromNodeKey = (nodeKey: React.Key): string | null => { + const keyText = String(nodeKey); + if (!keyText.startsWith('key:')) { + return null; + } + return keyText.slice(4); +}; + +const getRedisScanLoadCount = (pattern: string, append: boolean): number => { + const normalizedPattern = pattern.trim() || '*'; + if (normalizedPattern === '*') { + return append ? REDIS_KEY_LOAD_MORE_COUNT : REDIS_KEY_INITIAL_LOAD_COUNT; + } + return append ? REDIS_KEY_SEARCH_LOAD_MORE_COUNT : REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT; +}; + const normalizeKeySegment = (segment: string): string => { return segment === '' ? EMPTY_SEGMENT_LABEL : segment; }; const createTreeGroup = (name: string, path: string): RedisKeyTreeGroup => { - return { name, path, children: new Map(), leaves: [] }; + return { name, path, children: new Map(), leaves: [], leafCount: 0 }; }; -const countGroupLeafNodes = (group: RedisKeyTreeGroup): number => { +const calculateGroupLeafCount = (group: RedisKeyTreeGroup): number => { let count = group.leaves.length; group.children.forEach((child) => { - count += countGroupLeafNodes(child); + count += calculateGroupLeafCount(child); }); + group.leafCount = count; return count; }; const buildRedisKeyTree = ( keys: RedisKeyInfo[], - formatTTL: (ttl: number) => string, - getTypeColor: (type: string) => string, - showTTL: boolean + sortLeafNodes: boolean ): RedisKeyTreeResult => { const root = createTreeGroup('__root__', '__root__'); @@ -300,105 +330,41 @@ const buildRedisKeyTree = ( current.leaves.push({ keyInfo, label: leafLabel }); }); + calculateGroupLeafCount(root); - const rawKeyByNodeKey = new Map(); - const leafNodeKeyByRawKey = new Map(); const groupKeys: string[] = []; - const toTreeNodes = (group: RedisKeyTreeGroup): DataNode[] => { + const toTreeNodes = (group: RedisKeyTreeGroup): RedisTreeDataNode[] => { const childGroups = Array.from(group.children.values()).sort((a, b) => a.name.localeCompare(b.name)); - const childLeaves = [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key)); + const childLeaves = sortLeafNodes + ? [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key)) + : group.leaves; - const groupNodes: DataNode[] = childGroups.map((child) => { + const groupNodes: RedisTreeDataNode[] = childGroups.map((child) => { const groupNodeKey = `group:${child.path}`; groupKeys.push(groupNodeKey); return { key: groupNodeKey, - title: ( - - - {child.name} - ({countGroupLeafNodes(child)}) - - ), + title: child.name, + nodeType: 'group', + groupName: child.name, + groupLeafCount: child.leafCount, selectable: false, disableCheckbox: true, children: toTreeNodes(child), }; }); - const leafNodes: DataNode[] = childLeaves.map((leaf) => { - const nodeKey = `key:${leaf.keyInfo.key}`; - rawKeyByNodeKey.set(nodeKey, leaf.keyInfo.key); - leafNodeKeyByRawKey.set(leaf.keyInfo.key, nodeKey); + const leafNodes: RedisTreeDataNode[] = childLeaves.map((leaf) => { return { - key: nodeKey, + key: buildLeafNodeKey(leaf.keyInfo.key), isLeaf: true, - title: ( -
-
- - - - {leaf.label} - - -
- - {leaf.keyInfo.type} - - {showTTL && ( - - {formatTTL(leaf.keyInfo.ttl)} - - )} -
- ), + title: leaf.label, + nodeType: 'leaf', + leafLabel: leaf.label, + rawKey: leaf.keyInfo.key, + keyType: leaf.keyInfo.type, + ttl: leaf.keyInfo.ttl, }; }); @@ -407,8 +373,6 @@ const buildRedisKeyTree = ( return { treeData: toTreeNodes(root), - rawKeyByNodeKey, - leafNodeKeyByRawKey, groupKeys, }; }; @@ -445,11 +409,14 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { onSave: (newValue: string) => Promise; } | null>(null); const jsonEditValueRef = useRef(''); + const latestLoadRequestIdRef = useRef(0); // 面板宽度状态和 ref - 默认占据 50% 宽度 const [leftPanelWidth, setLeftPanelWidth] = useState('50%'); const leftPanelRef = useRef(null); + const treeContainerRef = useRef(null); const [showTreeKeyTTL, setShowTreeKeyTTL] = useState(true); + const [treeHeight, setTreeHeight] = useState(500); const [expandedGroupKeys, setExpandedGroupKeys] = useState([]); const getConfig = useCallback(() => { @@ -468,14 +435,22 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { pattern: string = '*', fromCursor: number = 0, append: boolean = false, - targetCount: number = REDIS_KEY_INITIAL_LOAD_COUNT + targetCount?: number ) => { const config = getConfig(); if (!config) return; + const normalizedPattern = pattern.trim() || '*'; + const effectiveTargetCount = targetCount ?? getRedisScanLoadCount(normalizedPattern, append); + const requestId = latestLoadRequestIdRef.current + 1; + latestLoadRequestIdRef.current = requestId; + setLoading(true); try { - const res = await (window as any).go.app.App.RedisScanKeys(config, pattern, fromCursor, targetCount); + const res = await (window as any).go.app.App.RedisScanKeys(config, normalizedPattern, fromCursor, effectiveTargetCount); + if (requestId !== latestLoadRequestIdRef.current) { + return; + } if (res.success) { const result = res.data; const scannedKeys = Array.isArray(result?.keys) ? result.keys : []; @@ -496,33 +471,38 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { message.error('加载 Key 失败: ' + res.message); } } catch (e: any) { + if (requestId !== latestLoadRequestIdRef.current) { + return; + } message.error('加载 Key 失败: ' + (e?.message || String(e))); } finally { - setLoading(false); + if (requestId === latestLoadRequestIdRef.current) { + setLoading(false); + } } }, [getConfig]); useEffect(() => { - loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT); + loadKeys(searchPattern, 0, false, getRedisScanLoadCount(searchPattern, false)); }, [redisDB]); const handleSearch = (value: string) => { const pattern = value.trim() || '*'; setSearchPattern(pattern); setCursor(0); - loadKeys(pattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT); + loadKeys(pattern, 0, false, getRedisScanLoadCount(pattern, false)); }; const handleLoadMore = () => { if (!hasMore || loading) { return; } - loadKeys(searchPattern, cursor, true, REDIS_KEY_LOAD_MORE_COUNT); + loadKeys(searchPattern, cursor, true, getRedisScanLoadCount(searchPattern, true)); }; const handleRefresh = () => { setCursor(0); - loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT); + loadKeys(searchPattern, 0, false, getRedisScanLoadCount(searchPattern, false)); }; const loadKeyValue = async (key: string) => { @@ -678,23 +658,51 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { return () => window.removeEventListener('resize', handleWindowResize); }, []); + useEffect(() => { + const target = treeContainerRef.current; + if (!target) return; + + const updateTreeHeight = (nextHeight: number) => { + if (nextHeight <= 0) return; + setTreeHeight((prev) => (prev === nextHeight ? prev : nextHeight)); + }; + + updateTreeHeight(Math.round(target.getBoundingClientRect().height)); + + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver((entries) => { + const nextHeight = Math.round(entries[0]?.contentRect.height || target.getBoundingClientRect().height); + updateTreeHeight(nextHeight); + }); + observer.observe(target); + return () => observer.disconnect(); + } + + const handleWindowResize = () => { + updateTreeHeight(Math.round(target.getBoundingClientRect().height)); + }; + window.addEventListener('resize', handleWindowResize); + return () => window.removeEventListener('resize', handleWindowResize); + }, []); + + const isLargeKeyspace = keys.length >= REDIS_LARGE_KEYSPACE_THRESHOLD; + const keyTree = useMemo(() => { - return buildRedisKeyTree(keys, formatTTL, getTypeColor, showTreeKeyTTL); - }, [keys, showTreeKeyTTL]); + return buildRedisKeyTree(keys, !isLargeKeyspace); + }, [isLargeKeyspace, keys]); + + const groupKeySet = useMemo(() => new Set(keyTree.groupKeys), [keyTree.groupKeys]); const selectedTreeNodeKeys = useMemo(() => { if (!selectedKey) { return [] as string[]; } - const nodeKey = keyTree.leafNodeKeyByRawKey.get(selectedKey); - return nodeKey ? [nodeKey] : []; - }, [selectedKey, keyTree]); + return [buildLeafNodeKey(selectedKey)]; + }, [selectedKey]); const checkedTreeNodeKeys = useMemo(() => { - return selectedKeys - .map(rawKey => keyTree.leafNodeKeyByRawKey.get(rawKey)) - .filter((nodeKey): nodeKey is string => Boolean(nodeKey)); - }, [selectedKeys, keyTree]); + return selectedKeys.map(rawKey => buildLeafNodeKey(rawKey)); + }, [selectedKeys]); useEffect(() => { const existingKeySet = new Set(keys.map(item => item.key)); @@ -703,16 +711,19 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { useEffect(() => { setExpandedGroupKeys((prev) => { - const validKeys = prev.filter(nodeKey => keyTree.groupKeys.includes(nodeKey)); - return validKeys; + const validKeys = prev.filter(nodeKey => groupKeySet.has(nodeKey)); + if (!isLargeKeyspace) { + return validKeys; + } + return validKeys.slice(0, REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS); }); - }, [keyTree]); + }, [groupKeySet, isLargeKeyspace]); const handleTreeSelect = (nodeKeys: React.Key[]) => { if (nodeKeys.length === 0) { return; } - const rawKey = keyTree.rawKeyByNodeKey.get(String(nodeKeys[0])); + const rawKey = parseRawKeyFromNodeKey(nodeKeys[0]); if (!rawKey) { return; } @@ -722,11 +733,119 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const handleTreeCheck = (checked: React.Key[] | { checked: React.Key[]; halfChecked: React.Key[] }) => { const checkedNodeKeys = Array.isArray(checked) ? checked : checked.checked; const rawKeys = checkedNodeKeys - .map(nodeKey => keyTree.rawKeyByNodeKey.get(String(nodeKey))) + .map(nodeKey => parseRawKeyFromNodeKey(nodeKey)) .filter((rawKey): rawKey is string => Boolean(rawKey)); setSelectedKeys(rawKeys); }; + const renderTreeNodeTitle = useCallback((nodeData: DataNode) => { + const treeNode = nodeData as RedisTreeDataNode; + + if (treeNode.nodeType === 'group') { + return ( + + + {treeNode.groupName} + ({treeNode.groupLeafCount ?? 0}) + + ); + } + + const leafLabel = treeNode.leafLabel ?? ''; + const rawKey = treeNode.rawKey ?? parseRawKeyFromNodeKey(treeNode.key ?? '') ?? ''; + const keyType = treeNode.keyType ?? 'unknown'; + const ttl = typeof treeNode.ttl === 'number' ? treeNode.ttl : -1; + + if (isLargeKeyspace) { + return ( +
+ {leafLabel} + [{keyType}] + {showTreeKeyTTL && ( + {formatTTL(ttl)} + )} +
+ ); + } + + return ( +
+
+ + + + {leafLabel} + + +
+ + {keyType} + + {showTreeKeyTTL && ( + + {formatTTL(ttl)} + + )} +
+ ); + }, [formatTTL, getTypeColor, isLargeKeyspace, showTreeKeyTTL]); + + const handleTreeExpand = (nextExpandedKeys: React.Key[]) => { + const validGroupKeys = nextExpandedKeys + .map(key => String(key)) + .filter(nodeKey => groupKeySet.has(nodeKey)); + if (isLargeKeyspace) { + setExpandedGroupKeys(validGroupKeys.slice(0, REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS)); + return; + } + setExpandedGroupKeys(validGroupKeys); + }; + const renderValueEditor = () => { if (!keyValue || !selectedKey) { return
选择一个 Key 查看详情
; @@ -1769,24 +1888,34 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { -
- - setExpandedGroupKeys(nextExpandedKeys as string[])} - onSelect={(nodeKeys) => handleTreeSelect(nodeKeys)} - onCheck={(checked) => handleTreeCheck(checked)} - style={{ padding: '8px 6px' }} - /> - +
+ {isLargeKeyspace && ( +
+ 已启用大数据量性能模式(简化节点渲染,最多保留 {REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS} 个展开分组) +
+ )} +
+ + handleTreeSelect(nodeKeys)} + onCheck={(checked) => handleTreeCheck(checked)} + style={{ padding: '8px 6px' }} + /> + +
{hasMore && (
diff --git a/frontend/src/store.ts b/frontend/src/store.ts index b1dddbd..76854c3 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { ConnectionConfig, SavedConnection, TabData, SavedQuery } from './types'; +import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery } from './types'; const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 }; const DEFAULT_STARTUP_FULLSCREEN = false; @@ -11,12 +11,22 @@ const MAX_HOST_ENTRY_LENGTH = 512; const MAX_HOST_ENTRIES = 64; const DEFAULT_TIMEOUT_SECONDS = 30; const MAX_TIMEOUT_SECONDS = 3600; +const PERSIST_VERSION = 4; const DEFAULT_CONNECTION_TYPE = 'mysql'; +const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = { + enabled: false, + type: 'socks5', + host: '', + port: 1080, + user: '', + password: '', +}; const SUPPORTED_CONNECTION_TYPES = new Set([ 'mysql', 'mariadb', 'diros', 'sphinx', + 'clickhouse', 'postgres', 'redis', 'tdengine', @@ -43,6 +53,8 @@ const getDefaultPortByType = (type: string): number => { return 0; case 'sphinx': return 9306; + case 'clickhouse': + return 9000; case 'postgres': case 'vastbase': return 5432; @@ -288,6 +300,10 @@ export interface QueryOptions { showColumnType: boolean; } +export interface GlobalProxyConfig extends ProxyConfig { + enabled: boolean; +} + interface AppState { connections: SavedConnection[]; tabs: TabData[]; @@ -297,6 +313,7 @@ interface AppState { theme: 'light' | 'dark'; appearance: { opacity: number; blur: number }; startupFullscreen: boolean; + globalProxy: GlobalProxyConfig; sqlFormatOptions: { keywordCase: 'upper' | 'lower' }; queryOptions: QueryOptions; sqlLogs: SqlLog[]; @@ -324,6 +341,7 @@ interface AppState { setTheme: (theme: 'light' | 'dark') => void; setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void; setStartupFullscreen: (enabled: boolean) => void; + setGlobalProxy: (proxy: Partial) => void; setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void; setQueryOptions: (options: Partial) => void; @@ -416,6 +434,21 @@ const sanitizeStartupFullscreen = (value: unknown): boolean => { return value === true; }; +const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => { + const raw = (value && typeof value === 'object') ? value as Record : {}; + const typeRaw = toTrimmedString(raw.type, DEFAULT_GLOBAL_PROXY.type).toLowerCase(); + const type: 'socks5' | 'http' = typeRaw === 'http' ? 'http' : 'socks5'; + const fallbackPort = type === 'http' ? 8080 : 1080; + return { + enabled: raw.enabled === true, + type, + host: toTrimmedString(raw.host), + port: normalizePort(raw.port, fallbackPort), + user: toTrimmedString(raw.user), + password: toTrimmedString(raw.password), + }; +}; + const unwrapPersistedAppState = (persistedState: unknown): Record => { if (!persistedState || typeof persistedState !== 'object') { return {}; @@ -438,6 +471,7 @@ export const useStore = create()( theme: 'light', appearance: { ...DEFAULT_APPEARANCE }, startupFullscreen: DEFAULT_STARTUP_FULLSCREEN, + globalProxy: { ...DEFAULT_GLOBAL_PROXY }, sqlFormatOptions: { keywordCase: 'upper' }, queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true }, sqlLogs: [], @@ -550,6 +584,7 @@ export const useStore = create()( setTheme: (theme) => set({ theme }), setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })), setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }), + setGlobalProxy: (proxy) => set((state) => ({ globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }) })), setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }), setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })), @@ -579,7 +614,7 @@ export const useStore = create()( }), { name: 'lite-db-storage', // name of the item in the storage (must be unique) - version: 3, + version: PERSIST_VERSION, migrate: (persistedState: unknown, version: number) => { const state = unwrapPersistedAppState(persistedState) as Partial; const nextState: Partial = { ...state }; @@ -588,6 +623,7 @@ export const useStore = create()( nextState.theme = sanitizeTheme(state.theme); nextState.appearance = sanitizeAppearance(state.appearance, version); nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen); + nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy); nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions); nextState.queryOptions = sanitizeQueryOptions(state.queryOptions); nextState.tableAccessCount = sanitizeTableAccessCount(state.tableAccessCount); @@ -602,8 +638,9 @@ export const useStore = create()( connections: sanitizeConnections(state.connections), savedQueries: sanitizeSavedQueries(state.savedQueries), theme: sanitizeTheme(state.theme), - appearance: sanitizeAppearance(state.appearance, 3), + appearance: sanitizeAppearance(state.appearance, PERSIST_VERSION), startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen), + globalProxy: sanitizeGlobalProxy(state.globalProxy), sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions), queryOptions: sanitizeQueryOptions(state.queryOptions), tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount), @@ -616,6 +653,7 @@ export const useStore = create()( theme: state.theme, appearance: state.appearance, startupFullscreen: state.startupFullscreen, + globalProxy: state.globalProxy, sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions, tableAccessCount: state.tableAccessCount, diff --git a/frontend/src/utils/sql.ts b/frontend/src/utils/sql.ts index 40f5577..d412ed7 100644 --- a/frontend/src/utils/sql.ts +++ b/frontend/src/utils/sql.ts @@ -36,7 +36,7 @@ export const quoteIdentPart = (dbType: string, ident: string) => { if (!raw) return raw; const dbTypeLower = (dbType || '').toLowerCase(); - if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine') { + if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine' || dbTypeLower === 'clickhouse') { return `\`${raw.replace(/`/g, '``')}\``; } diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 8c38771..b2edb2f 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -12,6 +12,8 @@ export function CheckForUpdates():Promise; export function ConfigureDriverRuntimeDirectory(arg1:string):Promise; +export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise; + export function CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise; export function DBConnect(arg1:connection.ConnectionConfig):Promise; @@ -72,6 +74,8 @@ export function GetDriverVersionList(arg1:string,arg2:string):Promise; +export function GetGlobalProxyConfig():Promise; + export function ImportConfigFile():Promise; export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 3b1a185..6dba529 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -18,6 +18,10 @@ export function ConfigureDriverRuntimeDirectory(arg1) { return window['go']['app']['App']['ConfigureDriverRuntimeDirectory'](arg1); } +export function ConfigureGlobalProxy(arg1, arg2) { + return window['go']['app']['App']['ConfigureGlobalProxy'](arg1, arg2); +} + export function CreateDatabase(arg1, arg2) { return window['go']['app']['App']['CreateDatabase'](arg1, arg2); } @@ -138,6 +142,10 @@ export function GetDriverVersionPackageSize(arg1, arg2) { return window['go']['app']['App']['GetDriverVersionPackageSize'](arg1, arg2); } +export function GetGlobalProxyConfig() { + return window['go']['app']['App']['GetGlobalProxyConfig'](); +} + export function ImportConfigFile() { return window['go']['app']['App']['ImportConfigFile'](); } diff --git a/go.mod b/go.mod index 9203b37..7070acd 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.3 require ( gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3 gitee.com/chunanyong/dm v1.8.22 + github.com/ClickHouse/clickhouse-go/v2 v2.43.0 github.com/duckdb/duckdb-go/v2 v2.5.5 github.com/go-sql-driver/mysql v1.9.3 github.com/highgo/pq-sm3 v0.0.0 @@ -25,6 +26,8 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/ClickHouse/ch-go v0.71.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect github.com/apache/arrow-go/v18 v18.5.1 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -36,6 +39,8 @@ require ( github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.3.3 // indirect github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.3.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.5 // indirect @@ -46,7 +51,7 @@ require ( github.com/google/flatbuffers v25.12.19+incompatible // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect - github.com/hashicorp/go-version v1.7.0 // indirect + github.com/hashicorp/go-version v1.8.0 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.3 // indirect @@ -62,6 +67,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/paulmach/orb v0.12.0 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect @@ -70,6 +76,7 @@ require ( github.com/richardlehane/msoleps v1.0.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/lo v1.49.1 // indirect + github.com/segmentio/asm v1.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/tiendc/go-deepcopy v1.7.1 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect @@ -84,6 +91,9 @@ require ( github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect diff --git a/go.sum b/go.sum index a74392f..b64a87a 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,10 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuo github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= +github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= +github.com/ClickHouse/clickhouse-go/v2 v2.43.0 h1:fUR05TrF1GyvLDa/mAQjkx7KbgwdLRffs2n9O3WobtE= +github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apache/arrow-go/v18 v18.5.1 h1:yaQ6zxMGgf9YCYw4/oaeOU3AULySDlAYDOcnr4LdHdI= @@ -52,6 +56,10 @@ github.com/duckdb/duckdb-go/v2 v2.5.5 h1:TlK8ipnzoKW2aNrjGqRkFWLCDpJDxR/VwH8ezEc github.com/duckdb/duckdb-go/v2 v2.5.5/go.mod h1:6uIbC3gz36NCEygECzboygOo/Z9TeVwox/puG+ohWV0= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= @@ -62,19 +70,23 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= @@ -83,20 +95,29 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= @@ -134,8 +155,14 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= +github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -159,6 +186,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg= @@ -169,6 +198,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -176,6 +206,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/taosdata/driver-go/v3 v3.7.8 h1:N2H6HLLZH2ve2ipcoFgG9BJS+yW0XksqNYwEdSmHaJk= github.com/taosdata/driver-go/v3 v3.7.8/go.mod h1:gSxBEPOueMg0rTmMO1Ug6aeD7AwGdDGvUtLrsDTTpYc= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4= github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= @@ -192,8 +223,10 @@ github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSB github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= @@ -202,38 +235,64 @@ github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstf github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -260,15 +319,25 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/app.go b/internal/app/app.go index 9d8f081..a46726e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -208,15 +208,17 @@ func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, erro } func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing bool) (db.Database, error) { - key := getCacheKey(config) + effectiveConfig := applyGlobalProxyToConnection(config) + + key := getCacheKey(effectiveConfig) shortKey := key if len(shortKey) > 12 { shortKey = shortKey[:12] } - if supported, reason := db.DriverRuntimeSupportStatus(config.Type); !supported { + if supported, reason := db.DriverRuntimeSupportStatus(effectiveConfig.Type); !supported { if strings.TrimSpace(reason) == "" { - reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(config.Type)) + reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(effectiveConfig.Type)) } // Best-effort cleanup: if cached instance exists for this exact config, close it. a.mu.Lock() @@ -254,7 +256,7 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing a.mu.Unlock() return entry.inst, nil } else { - logger.Error(err, "缓存连接不可用,准备重建:%s 缓存Key=%s", formatConnSummary(config), shortKey) + logger.Error(err, "缓存连接不可用,准备重建:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) } // Ping failed: remove cached instance (best effort) @@ -268,24 +270,24 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing a.mu.Unlock() } - logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(config), shortKey) - logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", config.Type, shortKey) - dbInst, err := db.NewDatabase(config.Type) + logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) + logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey) + dbInst, err := db.NewDatabase(effectiveConfig.Type) if err != nil { - logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", config.Type, shortKey) + logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey) return nil, err } - connectConfig, proxyErr := resolveDialConfigWithProxy(config) + connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig) if proxyErr != nil { - wrapped := wrapConnectError(config, proxyErr) - logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(config), shortKey) + wrapped := wrapConnectError(effectiveConfig, proxyErr) + logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) return nil, wrapped } if err := dbInst.Connect(connectConfig); err != nil { - wrapped := wrapConnectError(config, err) - logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(config), shortKey) + wrapped := wrapConnectError(effectiveConfig, err) + logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) return nil, wrapped } @@ -301,6 +303,6 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now} a.mu.Unlock() - logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(config), shortKey) + logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) return dbInst, nil } diff --git a/internal/app/db_context.go b/internal/app/db_context.go index 7f92849..842c8f6 100644 --- a/internal/app/db_context.go +++ b/internal/app/db_context.go @@ -14,7 +14,7 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne } switch strings.ToLower(strings.TrimSpace(config.Type)) { - case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine": + case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine", "clickhouse": // 这些类型的 dbName 表示"数据库",需要写入连接配置以选择目标库。 runConfig.Database = name case "dameng": diff --git a/internal/app/db_proxy.go b/internal/app/db_proxy.go index 6adf0c2..bdf2311 100644 --- a/internal/app/db_proxy.go +++ b/internal/app/db_proxy.go @@ -194,6 +194,8 @@ func defaultPortByType(driverType string) int { return 1433 case "mongodb": return 27017 + case "clickhouse": + return 9000 case "highgo": return 5866 default: diff --git a/internal/app/global_proxy.go b/internal/app/global_proxy.go new file mode 100644 index 0000000..57db384 --- /dev/null +++ b/internal/app/global_proxy.go @@ -0,0 +1,191 @@ +package app + +import ( + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/logger" + proxytunnel "GoNavi-Wails/internal/proxy" +) + +type globalProxySnapshot struct { + Enabled bool `json:"enabled"` + Proxy connection.ProxyConfig `json:"proxy"` +} + +var globalProxyRuntime = struct { + mu sync.RWMutex + enabled bool + proxy connection.ProxyConfig +}{} + +func currentGlobalProxyConfig() globalProxySnapshot { + globalProxyRuntime.mu.RLock() + defer globalProxyRuntime.mu.RUnlock() + if !globalProxyRuntime.enabled { + return globalProxySnapshot{ + Enabled: false, + Proxy: connection.ProxyConfig{}, + } + } + return globalProxySnapshot{ + Enabled: true, + Proxy: globalProxyRuntime.proxy, + } +} + +func setGlobalProxyConfig(enabled bool, proxyConfig connection.ProxyConfig) (globalProxySnapshot, error) { + if !enabled { + globalProxyRuntime.mu.Lock() + globalProxyRuntime.enabled = false + globalProxyRuntime.proxy = connection.ProxyConfig{} + globalProxyRuntime.mu.Unlock() + return currentGlobalProxyConfig(), nil + } + + normalizedProxy, err := proxytunnel.NormalizeConfig(proxyConfig) + if err != nil { + return globalProxySnapshot{}, err + } + + globalProxyRuntime.mu.Lock() + globalProxyRuntime.enabled = true + globalProxyRuntime.proxy = normalizedProxy + globalProxyRuntime.mu.Unlock() + return currentGlobalProxyConfig(), nil +} + +func (a *App) ConfigureGlobalProxy(enabled bool, proxyConfig connection.ProxyConfig) connection.QueryResult { + snapshot, err := setGlobalProxyConfig(enabled, proxyConfig) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + if snapshot.Enabled { + authState := "" + if strings.TrimSpace(snapshot.Proxy.User) != "" { + authState = "(认证:已配置)" + } + logger.Infof( + "全局代理已启用:%s://%s:%d%s", + strings.ToLower(strings.TrimSpace(snapshot.Proxy.Type)), + strings.TrimSpace(snapshot.Proxy.Host), + snapshot.Proxy.Port, + authState, + ) + } else { + logger.Infof("全局代理已关闭") + } + + return connection.QueryResult{ + Success: true, + Message: "全局代理配置已生效", + Data: snapshot, + } +} + +func (a *App) GetGlobalProxyConfig() connection.QueryResult { + return connection.QueryResult{ + Success: true, + Message: "OK", + Data: currentGlobalProxyConfig(), + } +} + +func applyGlobalProxyToConnection(config connection.ConnectionConfig) connection.ConnectionConfig { + effective := config + if effective.UseProxy { + return effective + } + if isFileDatabaseType(effective.Type) { + effective.Proxy = connection.ProxyConfig{} + return effective + } + + snapshot := currentGlobalProxyConfig() + if !snapshot.Enabled { + effective.Proxy = connection.ProxyConfig{} + return effective + } + + effective.UseProxy = true + effective.Proxy = snapshot.Proxy + return effective +} + +func isFileDatabaseType(driverType string) bool { + switch strings.ToLower(strings.TrimSpace(driverType)) { + case "sqlite", "duckdb": + return true + default: + return false + } +} + +func newHTTPClientWithGlobalProxy(timeout time.Duration) *http.Client { + client := &http.Client{ + Timeout: timeout, + } + if transport := buildHTTPTransportWithGlobalProxy(); transport != nil { + client.Transport = transport + } + return client +} + +func buildHTTPTransportWithGlobalProxy() *http.Transport { + baseTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok || baseTransport == nil { + return nil + } + + transport := baseTransport.Clone() + snapshot := currentGlobalProxyConfig() + if !snapshot.Enabled { + transport.Proxy = http.ProxyFromEnvironment + return transport + } + + proxyURL, err := buildProxyURLFromConfig(snapshot.Proxy) + if err != nil { + logger.Warnf("全局代理配置无效,回退系统代理:%v", err) + transport.Proxy = http.ProxyFromEnvironment + return transport + } + + transport.Proxy = http.ProxyURL(proxyURL) + return transport +} + +func buildProxyURLFromConfig(proxyConfig connection.ProxyConfig) (*url.URL, error) { + normalizedProxy, err := proxytunnel.NormalizeConfig(proxyConfig) + if err != nil { + return nil, err + } + + proxyType := strings.ToLower(strings.TrimSpace(normalizedProxy.Type)) + if proxyType != "http" && proxyType != "socks5" { + return nil, fmt.Errorf("不支持的代理类型:%s", normalizedProxy.Type) + } + if strings.TrimSpace(normalizedProxy.Host) == "" { + return nil, fmt.Errorf("代理地址不能为空") + } + if normalizedProxy.Port <= 0 || normalizedProxy.Port > 65535 { + return nil, fmt.Errorf("代理端口无效:%d", normalizedProxy.Port) + } + + proxyURL := &url.URL{ + Scheme: proxyType, + Host: net.JoinHostPort(strings.TrimSpace(normalizedProxy.Host), strconv.Itoa(normalizedProxy.Port)), + } + if strings.TrimSpace(normalizedProxy.User) != "" { + proxyURL.User = url.UserPassword(strings.TrimSpace(normalizedProxy.User), normalizedProxy.Password) + } + return proxyURL, nil +} diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index e31b9e8..4c4086b 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -88,6 +88,8 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) query = fmt.Sprintf("CREATE DATABASE \"%s\"", escapedDbName) } else if dbType == "tdengine" { query = fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdentByType(dbType, dbName)) + } else if dbType == "clickhouse" { + query = fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdentByType(dbType, dbName)) } else if dbType == "mariadb" || dbType == "diros" { // MariaDB uses same syntax as MySQL } else if dbType == "sphinx" { @@ -162,7 +164,7 @@ func buildRunConfigForDDL(config connection.ConnectionConfig, dbType string, dbN if strings.EqualFold(strings.TrimSpace(config.Type), "custom") { // custom 连接的 dbName 语义依赖 driver,尽量在常见驱动上对齐内置类型行为。 switch dbType { - case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "vastbase", "dameng": + case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "vastbase", "dameng", "clickhouse": if strings.TrimSpace(dbName) != "" { runConfig.Database = strings.TrimSpace(dbName) } @@ -216,7 +218,7 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co sql string ) switch dbType { - case "mysql", "mariadb", "diros", "tdengine": + case "mysql", "mariadb", "diros", "tdengine", "clickhouse": runConfig = config runConfig.Database = "" sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName)) @@ -255,7 +257,7 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old dbType := resolveDDLDBType(config) switch dbType { - case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver": + case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "clickhouse": default: return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持重命名表", dbType)} } @@ -269,7 +271,7 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old var sql string switch dbType { - case "mysql", "mariadb", "diros", "sphinx": + case "mysql", "mariadb", "diros", "sphinx", "clickhouse": newQualifiedTable := quoteTableIdentByType(dbType, schemaName, newTableName) sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualifiedTable, newQualifiedTable) case "sqlserver": @@ -301,7 +303,7 @@ func (a *App) DropTable(config connection.ConnectionConfig, dbName string, table dbType := resolveDDLDBType(config) switch dbType { - case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "tdengine": + case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "tdengine", "clickhouse": default: return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除表", dbType)} } @@ -663,7 +665,7 @@ func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewNa dbType := resolveDDLDBType(config) switch dbType { - case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver": + case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "clickhouse": default: return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除视图", dbType)} } @@ -752,7 +754,7 @@ func (a *App) RenameView(config connection.ConnectionConfig, dbName string, oldN var sql string switch dbType { - case "mysql", "mariadb", "diros", "sphinx": + case "mysql", "mariadb", "diros", "sphinx", "clickhouse": newQualified := quoteTableIdentByType(dbType, schemaName, newName) sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualified, newQualified) case "postgres", "kingbase", "highgo", "vastbase": diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go index 9fe2e56..0c3a3e9 100644 --- a/internal/app/methods_driver.go +++ b/internal/app/methods_driver.go @@ -222,7 +222,8 @@ const builtinDriverManifestJSON = `{ "highgo": { "engine": "go", "version": "0.0.0-local", "checksumPolicy": "off", "downloadUrl": "builtin://activate/highgo" }, "vastbase": { "engine": "go", "version": "1.11.1", "checksumPolicy": "off", "downloadUrl": "builtin://activate/vastbase" }, "mongodb": { "engine": "go", "version": "2.5.0", "checksumPolicy": "off", "downloadUrl": "builtin://activate/mongodb" }, - "tdengine": { "engine": "go", "version": "3.7.8", "checksumPolicy": "off", "downloadUrl": "builtin://activate/tdengine" } + "tdengine": { "engine": "go", "version": "3.7.8", "checksumPolicy": "off", "downloadUrl": "builtin://activate/tdengine" }, + "clickhouse": { "engine": "go", "version": "2.43.0", "checksumPolicy": "off", "downloadUrl": "builtin://activate/clickhouse" } } }` @@ -261,37 +262,39 @@ var pinnedDriverPackageMap = map[string]pinnedDriverPackage{ } var latestDriverVersionMap = map[string]string{ - "mysql": "1.9.3", - "mariadb": "1.9.3", - "diros": "1.9.3", - "sphinx": "1.9.3", - "sqlserver": "1.9.6", - "sqlite": "1.46.1", - "duckdb": "2.5.5", - "dameng": "1.8.22", - "kingbase": "0.0.0-20201021123113-29bd62a876c3", - "highgo": "0.0.0-local", - "vastbase": "1.11.2", - "mongodb": "2.5.0", - "tdengine": "3.7.8", - "oracle": "2.9.0", - "postgres": "1.11.2", - "redis": "9.17.3", + "mysql": "1.9.3", + "mariadb": "1.9.3", + "diros": "1.9.3", + "sphinx": "1.9.3", + "sqlserver": "1.9.6", + "sqlite": "1.46.1", + "duckdb": "2.5.5", + "dameng": "1.8.22", + "kingbase": "0.0.0-20201021123113-29bd62a876c3", + "highgo": "0.0.0-local", + "vastbase": "1.11.2", + "mongodb": "2.5.0", + "tdengine": "3.7.8", + "clickhouse": "2.43.0", + "oracle": "2.9.0", + "postgres": "1.11.2", + "redis": "9.17.3", } var driverGoModulePathMap = map[string]string{ - "mariadb": "github.com/go-sql-driver/mysql", - "diros": "github.com/go-sql-driver/mysql", - "sphinx": "github.com/go-sql-driver/mysql", - "sqlserver": "github.com/microsoft/go-mssqldb", - "sqlite": "modernc.org/sqlite", - "duckdb": "github.com/duckdb/duckdb-go/v2", - "dameng": "gitee.com/chunanyong/dm", - "kingbase": "gitea.com/kingbase/gokb", - "highgo": "github.com/highgo/pq-sm3", - "vastbase": "github.com/lib/pq", - "mongodb": "go.mongodb.org/mongo-driver/v2", - "tdengine": "github.com/taosdata/driver-go/v3", + "mariadb": "github.com/go-sql-driver/mysql", + "diros": "github.com/go-sql-driver/mysql", + "sphinx": "github.com/go-sql-driver/mysql", + "sqlserver": "github.com/microsoft/go-mssqldb", + "sqlite": "modernc.org/sqlite", + "duckdb": "github.com/duckdb/duckdb-go/v2", + "dameng": "gitee.com/chunanyong/dm", + "kingbase": "gitea.com/kingbase/gokb", + "highgo": "github.com/highgo/pq-sm3", + "vastbase": "github.com/lib/pq", + "mongodb": "go.mongodb.org/mongo-driver/v2", + "tdengine": "github.com/taosdata/driver-go/v3", + "clickhouse": "github.com/ClickHouse/clickhouse-go/v2", } var fallbackRecentDriverVersionsMap = map[string][]goModuleVersionMeta{ @@ -870,7 +873,7 @@ func probeDriverNetworkEndpoint(item driverNetworkProbeItem) driverNetworkProbeI return probed } - client := &http.Client{Timeout: driverNetworkProbeTimeout} + client := newHTTPClientWithGlobalProxy(driverNetworkProbeTimeout) start := time.Now() req, err := http.NewRequest(http.MethodHead, urlText, nil) if err != nil { @@ -1046,6 +1049,7 @@ func allDriverDefinitionsWithPackages(packages map[string]pinnedDriverPackage) [ buildOptionalGoDriverDefinition("vastbase", "Vastbase", packages), buildOptionalGoDriverDefinition("mongodb", "MongoDB", packages), buildOptionalGoDriverDefinition("tdengine", "TDengine", packages), + buildOptionalGoDriverDefinition("clickhouse", "ClickHouse", packages), } } @@ -1548,7 +1552,7 @@ func fetchGoModuleVersionMetas(modulePath string) ([]goModuleVersionMeta, error) } endpoint := fmt.Sprintf("https://proxy.golang.org/%s/@v/list", escapeGoModulePathForProxy(trimmed)) - client := &http.Client{Timeout: driverModuleLatestProbeTimeout} + client := newHTTPClientWithGlobalProxy(driverModuleLatestProbeTimeout) req, err := http.NewRequest(http.MethodGet, endpoint, nil) if err != nil { return nil, err @@ -1689,7 +1693,7 @@ func loadDriverReleaseListCached() ([]githubRelease, error) { func fetchDriverReleaseList() ([]githubRelease, error) { apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=30", updateRepo) - client := &http.Client{Timeout: driverReleaseListProbeTimeout} + client := newHTTPClientWithGlobalProxy(driverReleaseListProbeTimeout) req, err := http.NewRequest(http.MethodGet, apiURL, nil) if err != nil { return nil, err @@ -2019,7 +2023,7 @@ func loadManifestContent(resolvedURL string) ([]byte, error) { scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme)) switch scheme { case "http", "https": - client := &http.Client{Timeout: 12 * time.Second} + client := newHTTPClientWithGlobalProxy(12 * time.Second) req, reqErr := http.NewRequest(http.MethodGet, parsed.String(), nil) if reqErr != nil { return nil, reqErr @@ -2605,6 +2609,8 @@ func optionalDriverBuildTag(driverType string) (string, error) { return "gonavi_mongodb_driver", nil case "tdengine": return "gonavi_tdengine_driver", nil + case "clickhouse": + return "gonavi_clickhouse_driver", nil default: return "", fmt.Errorf("未配置驱动构建标签:%s", driverType) } @@ -3026,7 +3032,7 @@ func fetchDriverBundleAssetSizeIndex(release *githubRelease) (map[string]int64, return nil, fmt.Errorf("未找到驱动总包索引资产") } - client := &http.Client{Timeout: driverReleaseAssetSizeProbeTimeout} + client := newHTTPClientWithGlobalProxy(driverReleaseAssetSizeProbeTimeout) req, err := http.NewRequest(http.MethodGet, indexURL, nil) if err != nil { return nil, err @@ -3074,7 +3080,7 @@ func fetchDriverReleaseByURL(apiURL string) (*githubRelease, error) { return nil, fmt.Errorf("API 地址为空") } - client := &http.Client{Timeout: driverReleaseAssetSizeProbeTimeout} + client := newHTTPClientWithGlobalProxy(driverReleaseAssetSizeProbeTimeout) req, err := http.NewRequest(http.MethodGet, urlText, nil) if err != nil { return nil, err diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 67275dd..d80c251 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -701,7 +701,7 @@ func quoteIdentByType(dbType string, ident string) string { } switch dbType { - case "mysql", "mariadb", "diros", "sphinx", "tdengine": + case "mysql", "mariadb", "diros", "sphinx", "tdengine", "clickhouse": return "`" + strings.ReplaceAll(ident, "`", "``") + "`" case "sqlserver": escaped := strings.ReplaceAll(ident, "]", "]]") @@ -950,6 +950,15 @@ func buildListViewQueries(config connection.ConnectionConfig, dbName string) []s return []string{ `SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name`, } + case "clickhouse": + if strings.TrimSpace(dbName) == "" { + return []string{ + `SELECT database AS schema_name, name AS object_name FROM system.tables WHERE engine LIKE '%View%' ORDER BY database, name`, + } + } + return []string{ + fmt.Sprintf(`SELECT database AS schema_name, name AS object_name FROM system.tables WHERE engine LIKE '%%View%%' AND database='%s' ORDER BY name`, escapedDbName), + } default: if strings.TrimSpace(dbName) == "" { return []string{ @@ -1070,6 +1079,18 @@ WHERE s.name = '%s' AND v.name = '%s'`, fmt.Sprintf("SELECT sql AS ddl FROM duckdb_views() WHERE view_name = '%s' AND schema_name = '%s' LIMIT 1", escapedView, escapedSchema), fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedSchema), } + case "clickhouse": + if safeSchema == "" { + safeSchema = strings.TrimSpace(dbName) + } + if safeSchema != "" { + return []string{ + fmt.Sprintf("SHOW CREATE TABLE %s.%s", quoteIdentByType("clickhouse", safeSchema), quoteIdentByType("clickhouse", safeView)), + } + } + return []string{ + fmt.Sprintf("SHOW CREATE TABLE %s", quoteIdentByType("clickhouse", safeView)), + } default: if safeSchema != "" { return []string{ diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index 20f1fdc..5707b6e 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -374,7 +374,7 @@ func getCurrentAuthor() string { } func fetchLatestRelease() (*githubRelease, error) { - client := &http.Client{Timeout: 15 * time.Second} + client := newHTTPClientWithGlobalProxy(15 * time.Second) req, err := http.NewRequest(http.MethodGet, updateAPIURL, nil) if err != nil { return nil, err @@ -451,7 +451,7 @@ func fetchReleaseSHA256(assets []githubAsset) (map[string]string, error) { return nil, errors.New("Release 未提供 SHA256SUMS") } - client := &http.Client{Timeout: 15 * time.Second} + client := newHTTPClientWithGlobalProxy(15 * time.Second) req, err := http.NewRequest(http.MethodGet, checksumURL, nil) if err != nil { return nil, err @@ -522,7 +522,7 @@ func (w *downloadProgressWriter) Write(p []byte) (int, error) { } func downloadFileWithHash(url, filePath string, onProgress func(downloaded, total int64)) (string, error) { - client := &http.Client{Timeout: 10 * time.Minute} + client := newHTTPClientWithGlobalProxy(10 * time.Minute) req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return "", err diff --git a/internal/db/clickhouse_impl.go b/internal/db/clickhouse_impl.go new file mode 100644 index 0000000..4ba1c85 --- /dev/null +++ b/internal/db/clickhouse_impl.go @@ -0,0 +1,603 @@ +//go:build gonavi_full_drivers || gonavi_clickhouse_driver + +package db + +import ( + "context" + "database/sql" + "fmt" + "net" + "net/url" + "strconv" + "strings" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/logger" + "GoNavi-Wails/internal/ssh" + "GoNavi-Wails/internal/utils" + + _ "github.com/ClickHouse/clickhouse-go/v2" +) + +const ( + defaultClickHousePort = 9000 + defaultClickHouseUser = "default" + defaultClickHouseDatabase = "default" +) + +type ClickHouseDB struct { + conn *sql.DB + pingTimeout time.Duration + forwarder *ssh.LocalForwarder + database string +} + +func normalizeClickHouseConfig(config connection.ConnectionConfig) connection.ConnectionConfig { + normalized := applyClickHouseURI(config) + if strings.TrimSpace(normalized.Host) == "" { + normalized.Host = "localhost" + } + if normalized.Port <= 0 { + normalized.Port = defaultClickHousePort + } + if strings.TrimSpace(normalized.User) == "" { + normalized.User = defaultClickHouseUser + } + if strings.TrimSpace(normalized.Database) == "" { + normalized.Database = defaultClickHouseDatabase + } + return normalized +} + +func applyClickHouseURI(config connection.ConnectionConfig) connection.ConnectionConfig { + uriText := strings.TrimSpace(config.URI) + if uriText == "" { + return config + } + lowerURI := strings.ToLower(uriText) + if !strings.HasPrefix(lowerURI, "clickhouse://") { + return config + } + + parsed, err := url.Parse(uriText) + if err != nil { + return config + } + + if parsed.User != nil { + if strings.TrimSpace(config.User) == "" { + config.User = parsed.User.Username() + } + if pass, ok := parsed.User.Password(); ok && config.Password == "" { + config.Password = pass + } + } + + if dbName := strings.TrimPrefix(strings.TrimSpace(parsed.Path), "/"); dbName != "" && strings.TrimSpace(config.Database) == "" { + config.Database = dbName + } + if strings.TrimSpace(config.Database) == "" { + if dbName := strings.TrimSpace(parsed.Query().Get("database")); dbName != "" { + config.Database = dbName + } + } + + defaultPort := config.Port + if defaultPort <= 0 { + defaultPort = defaultClickHousePort + } + if strings.TrimSpace(config.Host) == "" { + host, port, ok := parseHostPortWithDefault(parsed.Host, defaultPort) + if ok { + config.Host = host + config.Port = port + } + } + if config.Port <= 0 { + config.Port = defaultPort + } + return config +} + +func (c *ClickHouseDB) getDSN(config connection.ConnectionConfig) string { + u := &url.URL{ + Scheme: "clickhouse", + Host: net.JoinHostPort(config.Host, strconv.Itoa(config.Port)), + Path: "/" + strings.TrimPrefix(strings.TrimSpace(config.Database), "/"), + } + if strings.TrimSpace(config.Password) != "" { + u.User = url.UserPassword(strings.TrimSpace(config.User), config.Password) + } else { + u.User = url.User(strings.TrimSpace(config.User)) + } + + timeoutSeconds := getConnectTimeoutSeconds(config) + query := u.Query() + query.Set("dial_timeout", fmt.Sprintf("%ds", timeoutSeconds)) + query.Set("read_timeout", fmt.Sprintf("%ds", timeoutSeconds)) + query.Set("write_timeout", fmt.Sprintf("%ds", timeoutSeconds)) + u.RawQuery = query.Encode() + return u.String() +} + +func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error { + if supported, reason := DriverRuntimeSupportStatus("clickhouse"); !supported { + if strings.TrimSpace(reason) == "" { + reason = "ClickHouse 纯 Go 驱动未启用,请先在驱动管理中安装启用" + } + return fmt.Errorf("%s", reason) + } + + if c.forwarder != nil { + _ = c.forwarder.Close() + c.forwarder = nil + } + if c.conn != nil { + _ = c.conn.Close() + c.conn = nil + } + + runConfig := normalizeClickHouseConfig(config) + c.pingTimeout = getConnectTimeout(runConfig) + c.database = runConfig.Database + + if runConfig.UseSSH { + logger.Infof("ClickHouse 使用 SSH 连接:地址=%s:%d 用户=%s", runConfig.Host, runConfig.Port, runConfig.User) + forwarder, err := ssh.GetOrCreateLocalForwarder(runConfig.SSH, runConfig.Host, runConfig.Port) + if err != nil { + return fmt.Errorf("创建 SSH 隧道失败:%w", err) + } + c.forwarder = forwarder + + host, portText, err := net.SplitHostPort(forwarder.LocalAddr) + if err != nil { + return fmt.Errorf("解析本地转发地址失败:%w", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + return fmt.Errorf("解析本地端口失败:%w", err) + } + + runConfig.Host = host + runConfig.Port = port + runConfig.UseSSH = false + logger.Infof("ClickHouse 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port) + } + + dbConn, err := sql.Open("clickhouse", c.getDSN(runConfig)) + if err != nil { + return fmt.Errorf("打开数据库连接失败:%w", err) + } + c.conn = dbConn + + if err := c.Ping(); err != nil { + _ = c.Close() + return fmt.Errorf("连接建立后验证失败:%w", err) + } + return nil +} + +func (c *ClickHouseDB) Close() error { + if c.forwarder != nil { + if err := c.forwarder.Close(); err != nil { + logger.Warnf("关闭 ClickHouse SSH 端口转发失败:%v", err) + } + c.forwarder = nil + } + if c.conn != nil { + return c.conn.Close() + } + return nil +} + +func (c *ClickHouseDB) Ping() error { + if c.conn == nil { + return fmt.Errorf("connection not open") + } + timeout := c.pingTimeout + if timeout <= 0 { + timeout = 5 * time.Second + } + ctx, cancel := utils.ContextWithTimeout(timeout) + defer cancel() + return c.conn.PingContext(ctx) +} + +func (c *ClickHouseDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) { + if c.conn == nil { + return nil, nil, fmt.Errorf("connection not open") + } + rows, err := c.conn.QueryContext(ctx, query) + if err != nil { + return nil, nil, err + } + defer rows.Close() + return scanRows(rows) +} + +func (c *ClickHouseDB) Query(query string) ([]map[string]interface{}, []string, error) { + if c.conn == nil { + return nil, nil, fmt.Errorf("connection not open") + } + rows, err := c.conn.Query(query) + if err != nil { + return nil, nil, err + } + defer rows.Close() + return scanRows(rows) +} + +func (c *ClickHouseDB) ExecContext(ctx context.Context, query string) (int64, error) { + if c.conn == nil { + return 0, fmt.Errorf("connection not open") + } + res, err := c.conn.ExecContext(ctx, query) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (c *ClickHouseDB) Exec(query string) (int64, error) { + if c.conn == nil { + return 0, fmt.Errorf("connection not open") + } + res, err := c.conn.Exec(query) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (c *ClickHouseDB) GetDatabases() ([]string, error) { + data, _, err := c.Query("SELECT name FROM system.databases ORDER BY name") + if err != nil { + return nil, err + } + + result := make([]string, 0, len(data)) + for _, row := range data { + if val, ok := getClickHouseValueFromRow(row, "name", "database"); ok { + result = append(result, fmt.Sprintf("%v", val)) + continue + } + for _, value := range row { + result = append(result, fmt.Sprintf("%v", value)) + break + } + } + return result, nil +} + +func (c *ClickHouseDB) GetTables(dbName string) ([]string, error) { + targetDB := strings.TrimSpace(dbName) + if targetDB == "" { + targetDB = strings.TrimSpace(c.database) + } + + var query string + if targetDB != "" { + query = fmt.Sprintf( + "SELECT name FROM system.tables WHERE database = '%s' ORDER BY name", + escapeClickHouseSQLLiteral(targetDB), + ) + } else { + query = "SELECT database, name FROM system.tables ORDER BY database, name" + } + + data, _, err := c.Query(query) + if err != nil { + return nil, err + } + + result := make([]string, 0, len(data)) + for _, row := range data { + if targetDB != "" { + if val, ok := getClickHouseValueFromRow(row, "name", "table", "table_name"); ok { + result = append(result, fmt.Sprintf("%v", val)) + continue + } + } else { + databaseValue, hasDB := getClickHouseValueFromRow(row, "database", "schema_name") + tableValue, hasTable := getClickHouseValueFromRow(row, "name", "table", "table_name") + if hasDB && hasTable { + result = append(result, fmt.Sprintf("%v.%v", databaseValue, tableValue)) + continue + } + } + for _, value := range row { + result = append(result, fmt.Sprintf("%v", value)) + break + } + } + return result, nil +} + +func (c *ClickHouseDB) GetCreateStatement(dbName, tableName string) (string, error) { + database, table, err := c.resolveDatabaseAndTable(dbName, tableName) + if err != nil { + return "", err + } + + query := fmt.Sprintf("SHOW CREATE TABLE %s.%s", quoteClickHouseIdentifier(database), quoteClickHouseIdentifier(table)) + data, _, err := c.Query(query) + if err != nil { + return "", err + } + if len(data) == 0 { + return "", fmt.Errorf("create statement not found") + } + row := data[0] + if val, ok := getClickHouseValueFromRow(row, "statement", "create_statement", "sql", "query"); ok { + text := strings.TrimSpace(fmt.Sprintf("%v", val)) + if text != "" { + return text, nil + } + } + + longest := "" + for _, value := range row { + text := strings.TrimSpace(fmt.Sprintf("%v", value)) + if text == "" { + continue + } + if strings.Contains(strings.ToUpper(text), "CREATE ") && len(text) > len(longest) { + longest = text + } + } + if longest != "" { + return longest, nil + } + return "", fmt.Errorf("create statement not found") +} + +func (c *ClickHouseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { + database, table, err := c.resolveDatabaseAndTable(dbName, tableName) + if err != nil { + return nil, err + } + + query := fmt.Sprintf(` +SELECT + name, + type, + default_kind, + default_expression, + is_in_primary_key, + is_in_sorting_key, + comment +FROM system.columns +WHERE database = '%s' AND table = '%s' +ORDER BY position`, + escapeClickHouseSQLLiteral(database), + escapeClickHouseSQLLiteral(table), + ) + data, _, err := c.Query(query) + if err != nil { + return nil, err + } + + columns := make([]connection.ColumnDefinition, 0, len(data)) + for _, row := range data { + nameValue, _ := getClickHouseValueFromRow(row, "name", "column_name") + typeValue, _ := getClickHouseValueFromRow(row, "type", "data_type") + defaultKind, _ := getClickHouseValueFromRow(row, "default_kind") + defaultExpr, hasDefault := getClickHouseValueFromRow(row, "default_expression", "column_default") + commentValue, _ := getClickHouseValueFromRow(row, "comment") + inPrimary, _ := getClickHouseValueFromRow(row, "is_in_primary_key") + inSorting, _ := getClickHouseValueFromRow(row, "is_in_sorting_key") + + colType := strings.TrimSpace(fmt.Sprintf("%v", typeValue)) + nullable := "NO" + if strings.HasPrefix(strings.ToLower(colType), "nullable(") { + nullable = "YES" + } + + key := "" + if isClickHouseTruthy(inPrimary) { + key = "PRI" + } else if isClickHouseTruthy(inSorting) { + key = "MUL" + } + + extra := "" + kindText := strings.ToUpper(strings.TrimSpace(fmt.Sprintf("%v", defaultKind))) + if kindText != "" && kindText != "DEFAULT" { + extra = kindText + } + + col := connection.ColumnDefinition{ + Name: strings.TrimSpace(fmt.Sprintf("%v", nameValue)), + Type: colType, + Nullable: nullable, + Key: key, + Extra: extra, + Comment: strings.TrimSpace(fmt.Sprintf("%v", commentValue)), + } + if hasDefault && defaultExpr != nil { + text := strings.TrimSpace(fmt.Sprintf("%v", defaultExpr)) + if text != "" { + col.Default = &text + } + } + columns = append(columns, col) + } + return columns, nil +} + +func (c *ClickHouseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { + targetDB := strings.TrimSpace(dbName) + if targetDB == "" { + targetDB = strings.TrimSpace(c.database) + } + + var query string + if targetDB != "" { + query = fmt.Sprintf(` +SELECT + database, + table, + name, + type +FROM system.columns +WHERE database = '%s' +ORDER BY table, position`, + escapeClickHouseSQLLiteral(targetDB), + ) + } else { + query = ` +SELECT + database, + table, + name, + type +FROM system.columns +WHERE database NOT IN ('system', 'information_schema', 'INFORMATION_SCHEMA') +ORDER BY database, table, position` + } + + data, _, err := c.Query(query) + if err != nil { + return nil, err + } + + result := make([]connection.ColumnDefinitionWithTable, 0, len(data)) + for _, row := range data { + databaseValue, _ := getClickHouseValueFromRow(row, "database") + tableValue, hasTable := getClickHouseValueFromRow(row, "table", "table_name") + nameValue, hasName := getClickHouseValueFromRow(row, "name", "column_name") + typeValue, _ := getClickHouseValueFromRow(row, "type", "data_type") + if !hasTable || !hasName { + continue + } + + tableName := strings.TrimSpace(fmt.Sprintf("%v", tableValue)) + if targetDB == "" { + dbText := strings.TrimSpace(fmt.Sprintf("%v", databaseValue)) + if dbText != "" { + tableName = dbText + "." + tableName + } + } + + result = append(result, connection.ColumnDefinitionWithTable{ + TableName: tableName, + Name: strings.TrimSpace(fmt.Sprintf("%v", nameValue)), + Type: strings.TrimSpace(fmt.Sprintf("%v", typeValue)), + }) + } + return result, nil +} + +func (c *ClickHouseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { + return []connection.IndexDefinition{}, nil +} + +func (c *ClickHouseDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { + return []connection.ForeignKeyDefinition{}, nil +} + +func (c *ClickHouseDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) { + return []connection.TriggerDefinition{}, nil +} + +func (c *ClickHouseDB) resolveDatabaseAndTable(dbName, tableName string) (string, string, error) { + rawTable := strings.TrimSpace(tableName) + if rawTable == "" { + return "", "", fmt.Errorf("table name required") + } + + resolvedDB := strings.TrimSpace(dbName) + resolvedTable := rawTable + if parts := strings.SplitN(rawTable, ".", 2); len(parts) == 2 { + if dbPart := normalizeClickHouseIdentifierPart(parts[0]); dbPart != "" { + resolvedDB = dbPart + } + resolvedTable = normalizeClickHouseIdentifierPart(parts[1]) + } else { + resolvedTable = normalizeClickHouseIdentifierPart(rawTable) + } + + if resolvedDB == "" { + resolvedDB = strings.TrimSpace(c.database) + } + if resolvedDB == "" { + resolvedDB = defaultClickHouseDatabase + } + if resolvedTable == "" { + return "", "", fmt.Errorf("table name required") + } + return resolvedDB, resolvedTable, nil +} + +func normalizeClickHouseIdentifierPart(raw string) string { + text := strings.TrimSpace(raw) + if len(text) >= 2 { + first := text[0] + last := text[len(text)-1] + if (first == '`' && last == '`') || (first == '"' && last == '"') { + text = text[1 : len(text)-1] + } + } + return strings.TrimSpace(text) +} + +func quoteClickHouseIdentifier(raw string) string { + return "`" + strings.ReplaceAll(strings.TrimSpace(raw), "`", "``") + "`" +} + +func escapeClickHouseSQLLiteral(raw string) string { + return strings.ReplaceAll(strings.TrimSpace(raw), "'", "''") +} + +func getClickHouseValueFromRow(row map[string]interface{}, keys ...string) (interface{}, bool) { + if len(row) == 0 { + return nil, false + } + for _, key := range keys { + if value, ok := row[key]; ok { + return value, true + } + } + for existingKey, value := range row { + for _, key := range keys { + if strings.EqualFold(existingKey, key) { + return value, true + } + } + } + return nil, false +} + +func isClickHouseTruthy(value interface{}) bool { + switch val := value.(type) { + case bool: + return val + case int: + return val != 0 + case int8: + return val != 0 + case int16: + return val != 0 + case int32: + return val != 0 + case int64: + return val != 0 + case uint: + return val != 0 + case uint8: + return val != 0 + case uint16: + return val != 0 + case uint32: + return val != 0 + case uint64: + return val != 0 + case string: + normalized := strings.ToLower(strings.TrimSpace(val)) + return normalized == "1" || normalized == "true" || normalized == "yes" || normalized == "y" + default: + normalized := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", value))) + return normalized == "1" || normalized == "true" || normalized == "yes" || normalized == "y" + } +} diff --git a/internal/db/database_optional_factories_full.go b/internal/db/database_optional_factories_full.go index 2a4545c..3de3d1b 100644 --- a/internal/db/database_optional_factories_full.go +++ b/internal/db/database_optional_factories_full.go @@ -15,4 +15,5 @@ func registerOptionalDatabaseFactories() { registerDatabaseFactory(newOptionalDriverAgentDatabase("vastbase"), "vastbase") registerDatabaseFactory(newOptionalDriverAgentDatabase("mongodb"), "mongodb") registerDatabaseFactory(newOptionalDriverAgentDatabase("tdengine"), "tdengine") + registerDatabaseFactory(newOptionalDriverAgentDatabase("clickhouse"), "clickhouse") } diff --git a/internal/db/database_optional_factories_lite.go b/internal/db/database_optional_factories_lite.go index df9e13c..3078709 100644 --- a/internal/db/database_optional_factories_lite.go +++ b/internal/db/database_optional_factories_lite.go @@ -15,4 +15,5 @@ func registerOptionalDatabaseFactories() { registerDatabaseFactory(newOptionalDriverAgentDatabase("vastbase"), "vastbase") registerDatabaseFactory(newOptionalDriverAgentDatabase("mongodb"), "mongodb") registerDatabaseFactory(newOptionalDriverAgentDatabase("tdengine"), "tdengine") + registerDatabaseFactory(newOptionalDriverAgentDatabase("clickhouse"), "clickhouse") } diff --git a/internal/db/driver_support.go b/internal/db/driver_support.go index 1c1089f..4ffe820 100644 --- a/internal/db/driver_support.go +++ b/internal/db/driver_support.go @@ -18,18 +18,19 @@ var coreBuiltinDrivers = map[string]struct{}{ // optionalGoDrivers 表示需要用户“安装启用”后才能使用的纯 Go 驱动。 // 注意:这是一种运行时门控(installed.json 标记),并不减少主二进制体积。 var optionalGoDrivers = map[string]struct{}{ - "mariadb": {}, - "diros": {}, - "sphinx": {}, - "sqlserver": {}, - "sqlite": {}, - "duckdb": {}, - "dameng": {}, - "kingbase": {}, - "highgo": {}, - "vastbase": {}, - "mongodb": {}, - "tdengine": {}, + "mariadb": {}, + "diros": {}, + "sphinx": {}, + "sqlserver": {}, + "sqlite": {}, + "duckdb": {}, + "dameng": {}, + "kingbase": {}, + "highgo": {}, + "vastbase": {}, + "mongodb": {}, + "tdengine": {}, + "clickhouse": {}, } var ( @@ -83,6 +84,8 @@ func driverDisplayName(driverType string) string { return "MongoDB" case "tdengine": return "TDengine" + case "clickhouse": + return "ClickHouse" default: return strings.ToUpper(strings.TrimSpace(driverType)) } diff --git a/internal/db/dsn_test.go b/internal/db/dsn_test.go index 8ae4edb..f3d9392 100644 --- a/internal/db/dsn_test.go +++ b/internal/db/dsn_test.go @@ -114,3 +114,30 @@ func TestTDengineDSN_UsesWebSocketFormat(t *testing.T) { t.Fatalf("tdengine dsn 格式不正确:%s", dsn) } } + +func TestClickHouseDSN_EscapesPasswordAndSetsTimeout(t *testing.T) { + c := &ClickHouseDB{} + cfg := normalizeClickHouseConfig(connection.ConnectionConfig{ + Type: "clickhouse", + Host: "127.0.0.1", + Port: 9000, + User: "default", + Password: "p@ss:wo/rd", + Database: "analytics", + Timeout: 15, + }) + + dsn := c.getDSN(cfg) + if strings.Contains(dsn, cfg.Password) { + t.Fatalf("dsn 包含原始密码:%s", dsn) + } + if !strings.Contains(dsn, "p%40ss%3Awo%2Frd") { + t.Fatalf("dsn 未正确转义密码:%s", dsn) + } + if !strings.Contains(dsn, "dial_timeout=15s") { + t.Fatalf("dsn 缺少 dial_timeout 参数:%s", dsn) + } + if !strings.Contains(dsn, "/analytics") { + t.Fatalf("dsn 缺少数据库路径:%s", dsn) + } +} diff --git a/internal/redis/redis_impl.go b/internal/redis/redis_impl.go index 03ee844..50df382 100644 --- a/internal/redis/redis_impl.go +++ b/internal/redis/redis_impl.go @@ -28,6 +28,11 @@ const ( redisScanMinStepCount int64 = 200 redisScanMaxStepCount int64 = 2000 redisScanMaxRounds = 64 + redisScanMaxDuration = 12 * time.Second + redisSearchMaxTargetCount int64 = 1000 + redisSearchMaxStepCount int64 = 1000 + redisSearchMaxRounds = 16 + redisSearchMaxDuration = 3 * time.Second ) // NewRedisClient creates a new Redis client instance @@ -110,21 +115,41 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( return nil, fmt.Errorf("Redis 客户端未连接") } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - if pattern == "" { pattern = "*" } + + isSearchPattern := pattern != "*" targetCount := normalizeRedisScanTargetCount(count) scanStepCount := normalizeRedisScanStepCount(targetCount) + maxRounds := redisScanMaxRounds + maxDuration := redisScanMaxDuration + if isSearchPattern { + if targetCount > redisSearchMaxTargetCount { + targetCount = redisSearchMaxTargetCount + } + if scanStepCount > redisSearchMaxStepCount { + scanStepCount = redisSearchMaxStepCount + } + maxRounds = redisSearchMaxRounds + maxDuration = redisSearchMaxDuration + } + + ctx, cancel := context.WithTimeout(context.Background(), maxDuration+5*time.Second) + defer cancel() + currentCursor := cursor round := 0 + scanStartedAt := time.Now() keys := make([]string, 0, int(targetCount)) seen := make(map[string]struct{}, int(targetCount)) for len(keys) < int(targetCount) { + if time.Since(scanStartedAt) >= maxDuration { + break + } + batch, nextCursor, err := r.client.Scan(ctx, currentCursor, pattern, scanStepCount).Result() if err != nil { return nil, err @@ -143,7 +168,7 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( currentCursor = nextCursor round++ - if currentCursor == 0 || round >= redisScanMaxRounds { + if currentCursor == 0 || round >= maxRounds { break } } diff --git a/optional-driver-agent b/optional-driver-agent new file mode 100755 index 0000000..66f4431 Binary files /dev/null and b/optional-driver-agent differ