From cb18bc3067204800bf2e896c0b0c08a460b42ca1 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Feb 2026 16:39:13 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat(driver-proxy):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9EClickHouse=E6=95=B0=E6=8D=AE=E6=BA=90=E5=B9=B6?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E5=85=A8=E5=B1=80=E4=BB=A3=E7=90=86=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ClickHouse 可选驱动实现与 optional-driver-agent provider,补齐驱动注册与清单配置 - 补齐 ClickHouse 连接与 SQL 适配:连接默认端口/用户、LIMIT、标识符引用、只读编辑限制 - 新增全局代理后端能力与前端持久化配置,更新检查和驱动网络请求统一走代理客户端 --- .../provider_clickhouse.go | 12 + docs/driver-manifest.json | 6 + frontend/src/App.tsx | 166 ++++- frontend/src/components/ConnectionModal.tsx | 32 + frontend/src/components/DataViewer.tsx | 2 +- frontend/src/components/QueryEditor.tsx | 4 +- frontend/src/store.ts | 44 +- frontend/src/utils/sql.ts | 2 +- frontend/wailsjs/go/app/App.d.ts | 4 + frontend/wailsjs/go/app/App.js | 8 + go.mod | 12 +- go.sum | 75 ++- internal/app/app.go | 30 +- internal/app/db_context.go | 2 +- internal/app/db_proxy.go | 2 + internal/app/global_proxy.go | 191 ++++++ internal/app/methods_db.go | 16 +- internal/app/methods_driver.go | 76 ++- internal/app/methods_file.go | 23 +- internal/app/methods_update.go | 6 +- internal/db/clickhouse_impl.go | 603 ++++++++++++++++++ .../db/database_optional_factories_full.go | 1 + .../db/database_optional_factories_lite.go | 1 + internal/db/driver_support.go | 27 +- internal/db/dsn_test.go | 27 + optional-driver-agent | Bin 0 -> 30807346 bytes 26 files changed, 1284 insertions(+), 88 deletions(-) create mode 100644 cmd/optional-driver-agent/provider_clickhouse.go create mode 100644 internal/app/global_proxy.go create mode 100644 internal/db/clickhouse_impl.go create mode 100755 optional-driver-agent 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/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/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..00e3a00 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -10,6 +10,8 @@ export function CheckDriverNetworkStatus():Promise; export function CheckForUpdates():Promise; +export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise; + export function ConfigureDriverRuntimeDirectory(arg1:string):Promise; export function CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):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..f872dea 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -14,6 +14,10 @@ export function CheckForUpdates() { return window['go']['app']['App']['CheckForUpdates'](); } +export function ConfigureGlobalProxy(arg1, arg2) { + return window['go']['app']['App']['ConfigureGlobalProxy'](arg1, arg2); +} + export function ConfigureDriverRuntimeDirectory(arg1) { return window['go']['app']['App']['ConfigureDriverRuntimeDirectory'](arg1); } @@ -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/optional-driver-agent b/optional-driver-agent new file mode 100755 index 0000000000000000000000000000000000000000..66f4431e549582921042191d6eb4cf12e6727822 GIT binary patch literal 30807346 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

), + 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/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/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 00e3a00..b2edb2f 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -10,10 +10,10 @@ export function CheckDriverNetworkStatus():Promise; export function CheckForUpdates():Promise; -export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):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; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index f872dea..6dba529 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -14,14 +14,14 @@ export function CheckForUpdates() { return window['go']['app']['App']['CheckForUpdates'](); } -export function ConfigureGlobalProxy(arg1, arg2) { - return window['go']['app']['App']['ConfigureGlobalProxy'](arg1, arg2); -} - 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); } 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 } }