diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index f5601e6..3e58839 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -246,6 +246,7 @@ jobs: run: | set -euo pipefail DEV_VERSION="${{ steps.version.outputs.version }}" + ./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.platform }}" if [ -n "${{ matrix.wails_tags }}" ]; then wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}" else diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b146317..f97c3cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -237,6 +237,7 @@ jobs: shell: bash run: | set -euo pipefail + ./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.platform }}" if [ -n "${{ matrix.wails_tags }}" ]; then wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}" else diff --git a/build-driver-agents.sh b/build-driver-agents.sh index e3734d2..455f72d 100755 --- a/build-driver-agents.sh +++ b/build-driver-agents.sh @@ -152,6 +152,8 @@ echo "🚀 开始构建 optional-driver-agent" echo " 平台:$goos/$goarch" echo " 输出目录:$output_dir_abs" echo " 驱动列表:${drivers[*]}" +echo "🧭 生成 driver-agent revision 指纹" +"$SCRIPT_DIR/tools/generate-driver-agent-revisions.sh" --platform "$target_platform" for driver in "${drivers[@]}"; do if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then diff --git a/build-release.sh b/build-release.sh index edb9fe3..a022e7e 100755 --- a/build-release.sh +++ b/build-release.sh @@ -155,6 +155,7 @@ package_macos_release() { local archive_suffix="$2" echo -e "${GREEN}🍎 正在构建 macOS (${platform})...${NC}" + generate_driver_agent_revisions "darwin/${platform}" wails build -platform "darwin/${platform}" -clean -ldflags "$LDFLAGS" if [ $? -ne 0 ]; then echo -e "${RED} ❌ macOS ${platform} 构建失败。${NC}" @@ -185,6 +186,12 @@ package_macos_release() { echo " ✅ 已生成 $zip_name" } +generate_driver_agent_revisions() { + local platform="$1" + echo " 🧭 正在生成 driver-agent revision 指纹 (${platform})..." + ./tools/generate-driver-agent-revisions.sh --platform "$platform" +} + echo -e "${GREEN}🚀 开始构建 $APP_NAME $VERSION...${NC}" # 清理并创建输出目录 @@ -197,6 +204,7 @@ package_macos_release "amd64" "mac-amd64" # --- Windows AMD64 构建 --- echo -e "${GREEN}🪟 正在构建 Windows (amd64)...${NC}" if command -v x86_64-w64-mingw32-gcc &> /dev/null; then + generate_driver_agent_revisions "windows/amd64" wails build -platform windows/amd64 -clean -ldflags "$LDFLAGS" if [ $? -eq 0 ]; then TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe" @@ -213,6 +221,7 @@ fi # --- Windows ARM64 构建 --- echo -e "${GREEN}🪟 正在构建 Windows (arm64)...${NC}" if command -v aarch64-w64-mingw32-gcc &> /dev/null; then + generate_driver_agent_revisions "windows/arm64" wails build -platform windows/arm64 -clean -ldflags "$LDFLAGS" if [ $? -eq 0 ]; then TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-arm64.exe" @@ -235,6 +244,7 @@ CURRENT_ARCH=$(uname -m) if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then # 本机 Linux amd64,直接构建 + generate_driver_agent_revisions "linux/amd64" wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS" if [ $? -eq 0 ]; then TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64" @@ -255,6 +265,7 @@ elif command -v x86_64-linux-gnu-gcc &> /dev/null; then export CC=x86_64-linux-gnu-gcc export CXX=x86_64-linux-gnu-g++ export CGO_ENABLED=1 + generate_driver_agent_revisions "linux/amd64" wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS" if [ $? -eq 0 ]; then TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64" @@ -279,6 +290,7 @@ fi echo -e "${GREEN}🐧 正在构建 Linux (arm64)...${NC}" if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then # 本机 Linux arm64,直接构建 + generate_driver_agent_revisions "linux/arm64" wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS" if [ $? -eq 0 ]; then TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64" @@ -298,6 +310,7 @@ elif command -v aarch64-linux-gnu-gcc &> /dev/null; then export CC=aarch64-linux-gnu-gcc export CXX=aarch64-linux-gnu-g++ export CGO_ENABLED=1 + generate_driver_agent_revisions "linux/arm64" wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS" if [ $? -eq 0 ]; then TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64" diff --git a/cmd/optional-driver-agent/main.go b/cmd/optional-driver-agent/main.go index 4c0c5b9..bf24273 100644 --- a/cmd/optional-driver-agent/main.go +++ b/cmd/optional-driver-agent/main.go @@ -37,6 +37,7 @@ type agentResponse struct { const ( agentMethodConnect = "connect" agentMethodClose = "close" + agentMethodMetadata = "metadata" agentMethodPing = "ping" agentMethodQuery = "query" agentMethodExec = "exec" @@ -131,6 +132,13 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse { *inst = nil } return resp + case agentMethodMetadata: + resp.Data = map[string]string{ + "driverType": strings.TrimSpace(agentDriverType), + "agentRevision": db.OptionalDriverAgentRevision(agentDriverType), + "protocolSchema": "json-lines-v1", + } + return resp } if *inst == nil { diff --git a/cmd/optional-driver-agent/main_test.go b/cmd/optional-driver-agent/main_test.go index 016e520..7e082e1 100644 --- a/cmd/optional-driver-agent/main_test.go +++ b/cmd/optional-driver-agent/main_test.go @@ -10,6 +10,7 @@ import ( "time" "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" ) type duckMapLike map[any]any @@ -66,6 +67,33 @@ func TestNormalizeAgentResponseData_KeepByteSlice(t *testing.T) { } } +func TestHandleRequestMetadataReportsAgentRevision(t *testing.T) { + previousDriverType := agentDriverType + previousFactory := agentDatabaseFactory + t.Cleanup(func() { + agentDriverType = previousDriverType + agentDatabaseFactory = previousFactory + }) + agentDriverType = "clickhouse" + agentDatabaseFactory = func() db.Database { return nil } + + var inst db.Database + resp := handleRequest(&inst, agentRequest{ID: 7, Method: agentMethodMetadata}) + if !resp.Success { + t.Fatalf("metadata request failed: %s", resp.Error) + } + data, ok := resp.Data.(map[string]string) + if !ok { + t.Fatalf("metadata response data type = %T", resp.Data) + } + if data["driverType"] != "clickhouse" { + t.Fatalf("unexpected driver type: %q", data["driverType"]) + } + if data["agentRevision"] != db.OptionalDriverAgentRevision("clickhouse") { + t.Fatalf("unexpected agent revision: %q", data["agentRevision"]) + } +} + type fakeAgentTimeoutDB struct { queryCalled bool queryContextCalled bool diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 1784394..2ea26d2 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -95,6 +95,7 @@ type ChoiceCardOption = { label: string; description?: string; }; +type ClickHouseProtocolChoice = "auto" | "http" | "native"; const MAX_URI_LENGTH = 4096; const MAX_URI_HOSTS = 32; const MAX_TIMEOUT_SECONDS = 3600; @@ -102,6 +103,25 @@ const CONNECTION_MODAL_WIDTH = 960; const CONNECTION_MODAL_BODY_HEIGHT = 620; const STEP1_SIDEBAR_DIVIDER_DARK = "rgba(255, 255, 255, 0.16)"; const STEP1_SIDEBAR_DIVIDER_LIGHT = "rgba(0, 0, 0, 0.08)"; +const CLICKHOUSE_PROTOCOL_OPTIONS: Array<{ + value: ClickHouseProtocolChoice; + label: string; +}> = [ + { value: "auto", label: "自动" }, + { value: "http", label: "HTTP" }, + { value: "native", label: "Native" }, +]; + +const normalizeClickHouseProtocolValue = ( + value: unknown, +): ClickHouseProtocolChoice => { + const text = String(value || "") + .trim() + .toLowerCase(); + if (text === "http" || text === "https") return "http"; + if (text === "native" || text === "tcp") return "native"; + return "auto"; +}; type ConnectionSecretKey = | "primaryPassword" | "sshPassword" @@ -216,6 +236,10 @@ type DriverStatusSnapshot = { type: string; name: string; connectable: boolean; + expectedRevision?: string; + needsUpdate?: boolean; + updateReason?: string; + affectedConnections?: number; message?: string; }; @@ -228,6 +252,14 @@ const normalizeDriverType = (value: string): string => { return normalized; }; +const resolveConnectionDriverType = (type: string, driver?: string): string => { + const normalizedType = normalizeDriverType(type); + if (normalizedType !== "custom") { + return normalizedType; + } + return normalizeDriverType(driver || ""); +}; + const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; @@ -300,6 +332,7 @@ const ConnectionModal: React.FC<{ const redisTopology = Form.useWatch("redisTopology", form) || "single"; const sslMode = Form.useWatch("sslMode", form) || "preferred"; const proxyType = Form.useWatch("proxyType", form) || "socks5"; + const customDriver = Form.useWatch("driver", form) || ""; const mongoReadPreference = Form.useWatch("mongoReadPreference", form) || "primary"; const mongoAuthMechanism = Form.useWatch("mongoAuthMechanism", form) || ""; @@ -831,6 +864,12 @@ const ConnectionModal: React.FC<{ type, name: String(item.name || item.type || type).trim(), connectable: !!item.connectable, + expectedRevision: String(item.expectedRevision || "").trim() || undefined, + needsUpdate: !!item.needsUpdate, + updateReason: String(item.updateReason || "").trim() || undefined, + affectedConnections: Number.isFinite(Number(item.affectedConnections)) + ? Number(item.affectedConnections) + : undefined, message: String(item.message || "").trim() || undefined, }; }); @@ -850,8 +889,9 @@ const ConnectionModal: React.FC<{ const resolveDriverUnavailableReason = async ( type: string, + driver?: string, ): Promise => { - const normalized = normalizeDriverType(type); + const normalized = resolveConnectionDriverType(type, driver); if (!normalized || normalized === "custom") { return ""; } @@ -1000,6 +1040,13 @@ const ConnectionModal: React.FC<{ } }; + const normalizeUriBool = (raw: unknown) => { + const text = String(raw ?? "") + .trim() + .toLowerCase(); + return text === "1" || text === "true" || text === "yes" || text === "on"; + }; + const normalizeFileDbPath = (rawPath: string): string => { let pathText = String(rawPath || "").trim(); if (!pathText) { @@ -1117,6 +1164,44 @@ const ConnectionModal: React.FC<{ }; }; + const parseClickHouseHTTPUriToValues = ( + uriText: string, + fallbackPort?: number, + ): Record | null => { + const trimmed = String(uriText || "").trim(); + const lower = trimmed.toLowerCase(); + const isHttps = lower.startsWith("https://"); + const isHttp = lower.startsWith("http://"); + if (!isHttp && !isHttps) { + return null; + } + const defaultPort = + Number.isFinite(Number(fallbackPort)) && Number(fallbackPort) > 0 + ? Number(fallbackPort) + : isHttps + ? 8443 + : 8123; + const parsed = parseSingleHostUri( + trimmed, + [isHttps ? "https" : "http"], + defaultPort, + ); + if (!parsed) { + return null; + } + const skipVerify = normalizeUriBool(parsed.params.get("skip_verify")); + return { + host: parsed.host, + port: parsed.port, + user: parsed.username, + password: parsed.password, + database: parsed.database || "", + clickHouseProtocol: "http", + useSSL: isHttps, + sslMode: isHttps ? (skipVerify ? "skip-verify" : "required") : "disable", + }; + }; + const parseUriToValues = ( uriText: string, type: string, @@ -1337,6 +1422,13 @@ const ConnectionModal: React.FC<{ }; } + if (type === "clickhouse") { + const httpValues = parseClickHouseHTTPUriToValues(trimmedUri); + if (httpValues) { + return httpValues; + } + } + const singleHostSchemes = singleHostUriSchemesByType[type]; if (singleHostSchemes && singleHostSchemes.length > 0) { const parsed = parseSingleHostUri( @@ -1412,6 +1504,9 @@ const ConnectionModal: React.FC<{ parsedValues.sslMode = "disable"; } } else if (type === "clickhouse") { + parsedValues.clickHouseProtocol = normalizeClickHouseProtocolValue( + parsed.params.get("protocol"), + ); const secure = String( parsed.params.get("secure") || parsed.params.get("tls") || "", ) @@ -1707,7 +1802,18 @@ const ConnectionModal: React.FC<{ return `${scheme}://${encodedAuth}${hosts.join(",")}${dbPath}${query ? `?${query}` : ""}`; } - const scheme = type === "postgres" ? "postgresql" : type; + const clickHouseProtocol = + type === "clickhouse" + ? normalizeClickHouseProtocolValue(values.clickHouseProtocol) + : "auto"; + const scheme = + type === "postgres" + ? "postgresql" + : type === "clickhouse" && clickHouseProtocol === "http" + ? values.useSSL + ? "https" + : "http" + : type; const dbPath = database ? `/${encodeURIComponent(database)}` : ""; const params = new URLSearchParams(); if (supportsSSLForType(type) && values.useSSL) { @@ -1728,9 +1834,15 @@ const ConnectionModal: React.FC<{ mode === "skip-verify" || mode === "preferred" ? "true" : "false", ); } else if (type === "clickhouse") { - params.set("secure", "true"); - if (mode === "skip-verify" || mode === "preferred") { - params.set("skip_verify", "true"); + if (clickHouseProtocol === "http") { + if (mode === "skip-verify" || mode === "preferred") { + params.set("skip_verify", "true"); + } + } else { + params.set("secure", "true"); + if (mode === "skip-verify" || mode === "preferred") { + params.set("skip_verify", "true"); + } } } else if (type === "dameng") { const certPath = String(values.sslCertPath || "").trim(); @@ -1761,6 +1873,9 @@ const ConnectionModal: React.FC<{ params.set("protocol", "ws"); } } + if (type === "clickhouse" && clickHouseProtocol !== "auto") { + params.set("protocol", clickHouseProtocol); + } const query = params.toString(); return `${scheme}://${encodedAuth}${toAddress(host, port, defaultPort)}${dbPath}${query ? `?${query}` : ""}`; }; @@ -1967,6 +2082,10 @@ const ConnectionModal: React.FC<{ password: config.password, database: config.database, uri: config.uri || "", + clickHouseProtocol: + configType === "clickhouse" + ? normalizeClickHouseProtocolValue(config.clickHouseProtocol) + : "auto", includeDatabases: initialValues.includeDatabases, includeRedisDatabases: initialValues.includeRedisDatabases, useSSL: !!config.useSSL, @@ -2287,10 +2406,14 @@ const ConnectionModal: React.FC<{ const values = form.getFieldsValue(true); const unavailableReason = await resolveDriverUnavailableReason( values.type, + values.driver, ); if (unavailableReason) { message.warning(unavailableReason); - promptInstallDriver(values.type, unavailableReason); + promptInstallDriver( + resolveConnectionDriverType(values.type, values.driver) || values.type, + unavailableReason, + ); return; } setLoading(true); @@ -2445,6 +2568,7 @@ const ConnectionModal: React.FC<{ const values = form.getFieldsValue(true); const unavailableReason = await resolveDriverUnavailableReason( values.type, + values.driver, ); if (unavailableReason) { applyTestFailureFeedback( @@ -2454,7 +2578,10 @@ const ConnectionModal: React.FC<{ fallback: "驱动未安装启用", }), ); - promptInstallDriver(values.type, unavailableReason); + promptInstallDriver( + resolveConnectionDriverType(values.type, values.driver) || values.type, + unavailableReason, + ); return; } const blockingSecretClearMessage = getBlockingSecretClearMessage(values); @@ -2740,6 +2867,15 @@ const ConnectionModal: React.FC<{ (Array.isArray(value) && value.length === 0); if (parsedUriValues) { Object.entries(parsedUriValues).forEach(([key, value]) => { + if ( + key === "clickHouseProtocol" && + normalizeClickHouseProtocolValue((mergedValues as any)[key]) === + "auto" && + normalizeClickHouseProtocolValue(value) !== "auto" + ) { + (mergedValues as any)[key] = value; + return; + } if (isEmptyField((mergedValues as any)[key])) { (mergedValues as any)[key] = value; } @@ -2748,6 +2884,35 @@ const ConnectionModal: React.FC<{ const type = String(mergedValues.type || "").toLowerCase(); const defaultPort = getDefaultPortByType(type); + if (type === "clickhouse") { + const requestedProtocol = normalizeClickHouseProtocolValue( + mergedValues.clickHouseProtocol, + ); + const hostSchemeValues = parseClickHouseHTTPUriToValues( + mergedValues.host, + Number(mergedValues.port || defaultPort), + ); + if (hostSchemeValues) { + mergedValues.host = hostSchemeValues.host; + mergedValues.port = hostSchemeValues.port; + if (requestedProtocol !== "native") { + mergedValues.clickHouseProtocol = "http"; + mergedValues.useSSL = hostSchemeValues.useSSL; + mergedValues.sslMode = hostSchemeValues.sslMode; + } else { + mergedValues.clickHouseProtocol = "native"; + } + if (isEmptyField(mergedValues.user)) { + mergedValues.user = hostSchemeValues.user; + } + if (isEmptyField(mergedValues.password)) { + mergedValues.password = hostSchemeValues.password; + } + if (isEmptyField(mergedValues.database)) { + mergedValues.database = hostSchemeValues.database; + } + } + } const isFileDbType = isFileDatabaseType(type); const sslCapableType = supportsSSLForType(type); @@ -2990,6 +3155,10 @@ const ConnectionModal: React.FC<{ ? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB)))) : 0, uri: String(mergedValues.uri || "").trim(), + clickHouseProtocol: + type === "clickhouse" + ? normalizeClickHouseProtocolValue(mergedValues.clickHouseProtocol) + : undefined, hosts: hosts, topology: topology, mysqlReplicaUser: mysqlReplicaUser, @@ -3017,7 +3186,10 @@ const ConnectionModal: React.FC<{ } setTypeSelectWarning(null); setDbType(type); - form.setFieldsValue({ type: type }); + form.setFieldsValue({ + type: type, + clickHouseProtocol: type === "clickhouse" ? "auto" : undefined, + }); const defaultPort = getDefaultPortByType(type); if (type === "jvm") { @@ -3188,17 +3360,27 @@ const ConnectionModal: React.FC<{ isJVM && hasUnsupportedJvmModeSelection ? "当前连接包含未支持的 JVM 模式。此版本只支持 JMX / Endpoint / Agent,请先调整允许模式和首选模式后再继续。" : ""; - const currentDriverType = normalizeDriverType(dbType); + const currentDriverType = resolveConnectionDriverType(dbType, customDriver); + const hasCurrentDriverType = + currentDriverType !== "" && currentDriverType !== "custom"; const currentDriverSnapshot = driverStatusMap[currentDriverType]; const currentDriverUnavailableReason = - currentDriverType !== "custom" && + hasCurrentDriverType && currentDriverSnapshot && !currentDriverSnapshot.connectable ? currentDriverSnapshot.message || `${currentDriverSnapshot.name || dbType} 驱动未安装启用` : ""; + const currentDriverUpdateReason = + hasCurrentDriverType && + currentDriverSnapshot?.connectable && + currentDriverSnapshot.needsUpdate + ? currentDriverSnapshot.message || + currentDriverSnapshot.updateReason || + `${currentDriverSnapshot.name || dbType} 驱动代理需要重装后才能应用当前版本的驱动侧更新` + : ""; const driverStatusChecking = - currentDriverType !== "custom" && !driverStatusLoaded && step === 2; + hasCurrentDriverType && !driverStatusLoaded && step === 2; const dbTypeGroups = [ { @@ -4294,6 +4476,25 @@ const ConnectionModal: React.FC<{ ), })} + {dbType === "clickhouse" && + renderConfigSectionCard({ + sectionKey: "connectionMode", + icon: , + children: ( + +