Compare commits

..

12 Commits

Author SHA1 Message Date
Syngnat
ec59023736 📝 docs(i18n-readme): 建立README中英双文档结构并统一能力描述
- 英文主 README 覆盖项目定位、性能特性与安装说明
- 中文 README 覆盖同等信息密度,避免内容断层
- 中英文文档统一支持数据源表格与驱动模式说明
- 完成语言切换链接配置,便于读者快速切换
2026-02-28 15:54:46 +08:00
Syngnat
4a96cb93d2 🎨 style(connection-modal): 优化新建连接弹窗尺寸与分类栏视觉一致性
- 统一调整弹窗 step1/step2 尺寸参数,改善布局观感
- 增加 step1 内容区最小高度,减少拥挤感
- 分类栏分割线改为主题感知颜色,消除深色模式下突兀白线
2026-02-28 15:36:26 +08:00
Syngnat
4c322db9d0 💥 breaking(driver-manager): 统一 Doris 驱动命名并移除 diros 历史包兼容
- 前后端统一 Doris 展示与连接命名,修复 diros 拼写问题
- GitHub Actions 驱动产物改为 doris-driver-agent-* 命名
- 构建流程保持内部 gonavi_diros_driver 映射,避免构建链路中断
- 驱动安装/下载/解压链路仅识别 doris 资产名,不再兼容 diros 历史包
- 内置与文档 manifest 下载地址统一为 builtin://activate/doris
- close #132
2026-02-28 15:27:58 +08:00
Syngnat
ed18c8285f 🐛 fix(db-compat): 修复PG系建表语句兼容并优化DuckDB大表总数统计
- 统一 DBShowCreateTable 与导出链路的 DDL 兜底逻辑,修复 Kingbase/Postgres 占位语句问题
- 增强 custom driver 到 postgres/kingbase/highgo/vastbase 的映射并补充回归测试
- DuckDB 关闭自动后台 COUNT(*),避免大文件场景翻页与查询卡顿
- 新增近似总数展示、手动精确统计与取消统计交互
- 新增 DBQueryIsolated 独立连接查询能力并同步前端 wailsjs 接口
- refs #136
2026-02-28 15:00:13 +08:00
Syngnat
5f8cedabd8 🐛 fix(update-proxy): 修复本地代理下检查更新 TLS 证书未知颁发者失败
- 在全局代理 HTTP 传输层增加本地回环代理兼容回退能力
- 回退触发条件限制为 unknown authority 且仅 GET/HEAD 请求
- 保留默认 TLS 校验策略并输出告警日志便于审计定位
- refs #139
2026-02-28 13:55:42 +08:00
Syngnat
20923989b9 🐛 fix(connection-modal): 修复透明暗色模式下 SSH/代理配置区块白底问题
- 为连接弹窗接入主题与透明度状态,按模式动态计算区块背景
- 将 SSH 与代理配置容器统一替换为自适应样式并补齐边框层次
- 保持连接测试与保存逻辑不变,仅修复显示层
2026-02-28 13:37:19 +08:00
Syngnat
210106cde7 🐛 fix(driver-modal): 修复驱动日志弹窗在透明暗色主题下对比度异常
- 将日志内容容器改为 dark/light 双模式自适应样式
- 使用全局外观透明度参数参与日志背景渲染
- 保持驱动安装与日志采集逻辑不变,仅修复显示层
2026-02-28 13:27:49 +08:00
Syngnat
87aac277ec 🎨 style(redis-viewer): 对齐 Redis 拖拽分割条与侧边栏宽度调整样式
- 分割条宽度调整为与 host 侧边栏一致
- 分割条背景统一为 transparent,去除 hover 强对比效果
- 保持拖拽命中区与提示文案,提升整体样式一致性
2026-02-28 12:53:06 +08:00
Syngnat
4de3f408c5 🐛 fix(redis-scan): 修复大数据量下命名空间加载不完整问题
- 前后端 Redis SCAN 游标统一为字符串传递,避免 Number 精度丢失
- RedisScanKeys 增加 string/number 游标兼容解析,异常游标降级并告警
- 新增游标解析单测
- refs #135
2026-02-28 12:32:22 +08:00
Syngnat
439625a49c 🔧 fix(duckdb-pagination): 修复 DuckDB 总数异常导致分页不可用
- 修正 DataViewer 在 hasMore 与 totalKnown 冲突时的分页状态处理
- 增强 DuckDB COUNT(*) 结果解析,兼容字段名与数值类型差异
- 将分页兜底逻辑收敛为 DuckDB 专用,避免影响其他数据库
- 修复 total=0 时分页文案显示异常
- refs #136
2026-02-28 12:14:34 +08:00
Syngnat
884d72f3d3 ♻️ refactor(clickhouse): 使用结构化 Options 替代 DSN 连接构造
- 用 buildClickHouseOptions 收敛连接参数生成逻辑
- 将连接入口改为 clickhouse.OpenDB(Options)
- 清理 DSN 中的 write_timeout/read_timeout/dial_timeout 透传路径
- 同步重写 ClickHouse 相关测试断言
- refs #138
2026-02-28 11:56:59 +08:00
Syngnat
98c1600e13 feat(driver-manager): 增强驱动管理本地导入并统一滚动交互体验
- 新增驱动目录批量导入入口,支持覆盖已安装开关与去重处理
- 行内本地导入聚焦单文件场景,目录导入与单文件导入流程统一
- 已安装驱动版本选择锁定,避免安装后误改版本
- 补充驱动下载网络检测与日志可见性,提升问题定位效率
- 重构驱动管理横向滚动条实现,修复双滚动条/消失/位置异常问题
2026-02-28 11:33:21 +08:00
28 changed files with 2308 additions and 367 deletions

View File

@@ -135,11 +135,11 @@ jobs:
shell: bash
run: |
set -euo pipefail
TAG_ARGS=()
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
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
TAG_ARGS+=(-tags "${{ matrix.wails_tags }}")
fi
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} "${TAG_ARGS[@]}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
- name: Build Optional Driver Agents
if: ${{ matrix.build_optional_agents }}
@@ -149,12 +149,16 @@ jobs:
TARGET_PLATFORM="${{ matrix.platform }}"
GOOS="${TARGET_PLATFORM%%/*}"
GOARCH="${TARGET_PLATFORM##*/}"
DRIVERS=(mariadb diros sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
for DRIVER in "${DRIVERS[@]}"; do
TAG="gonavi_${DRIVER}_driver"
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
TAG="gonavi_${BUILD_DRIVER}_driver"
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT="${OUTPUT}.exe"

228
README.md
View File

@@ -1,4 +1,4 @@
# GoNavi - 现代化的轻量级数据库管理工具
# GoNavi - A Modern Lightweight Database Client
[![Go Version](https://img.shields.io/github/go-mod/go-version/Syngnat/GoNavi)](https://go.dev/)
[![Wails Version](https://img.shields.io/badge/Wails-v2-red)](https://wails.io)
@@ -6,11 +6,51 @@
[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
[![Build Status](https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?label=Build)](https://github.com/Syngnat/GoNavi/actions)
**GoNavi** 是一款基于 **Wails (Go)****React** 构建的现代化、高性能、跨平台数据库管理客户端。它旨在提供如原生应用般流畅的用户体验,同时保持极低的资源占用。
**Language**: English | [简体中文](README.zh-CN.md)
相比于 Electron 应用GoNavi 的体积更小(~10MB启动速度更快内存占用更低。
GoNavi is a modern, high-performance, cross-platform database client built with **Wails (Go)** and **React**.
It delivers native-like responsiveness with low resource usage.
<h2 align="center">📸 项目截图</h2>
Compared with many Electron-based clients, GoNavi is typically smaller in binary size (around 10MB class), starts faster, and uses less memory.
---
## Project Overview
GoNavi is designed for developers and DBAs who need a unified desktop experience across multiple databases.
- **Native-performance architecture**: Wails (Go + WebView) with lightweight runtime overhead.
- **Large dataset usability**: virtualized rendering and optimized DataGrid workflows for high-volume tables.
- **Unified connectivity**: URI build/parse, SSH tunnel, proxy support, and on-demand driver activation.
- **Production-oriented workflow**: SQL editor, object management, batch export/backup, sync tools, execution logs, and update checks.
## Supported Data Sources
> `Built-in`: available out of the box.
> `Optional driver agent`: install/enable via Driver Manager first.
| Category | Data Source | Driver Mode | Typical Capabilities |
|---|---|---|---|
| Relational | MySQL | Built-in | Schema browsing, SQL query, data editing, export/backup |
| Relational | PostgreSQL | Built-in | Schema browsing, SQL query, data editing, object management |
| Relational | Oracle | Built-in | Query execution, object browsing, data editing |
| Cache | Redis | Built-in | Key browsing, command execution, encoding/view switch |
| Relational | MariaDB | Optional driver agent | Querying, object management, data editing |
| Relational | Doris | Optional driver agent | Querying, object browsing, SQL execution |
| Search | Sphinx | Optional driver agent | SphinxQL querying and object browsing |
| Relational | SQL Server | Optional driver agent | Schema browsing, SQL query, object management |
| File-based | SQLite | Optional driver agent | Local DB browsing, editing, export |
| File-based | DuckDB | Optional driver agent | Large-table query, pagination, file-DB workflow |
| Domestic DB | Dameng | Optional driver agent | Querying, object browsing, data editing |
| Domestic DB | Kingbase | Optional driver agent | Querying, object browsing, data editing |
| Domestic DB | HighGo | Optional driver agent | Querying, object browsing, data editing |
| Domestic DB | Vastbase | Optional driver agent | Querying, object browsing, data editing |
| Document | MongoDB | Optional driver agent | Document query, collection browsing, connection management |
| Time-series | TDengine | Optional driver agent | Time-series schema browsing and querying |
| Columnar Analytics | ClickHouse | Optional driver agent | Analytical query, object browsing, SQL execution |
| Extensibility | Custom Driver/DSN | Custom | Extend to more data sources via Driver + DSN |
<h2 align="center">📸 Screenshots</h2>
<div align="center">
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
@@ -24,137 +64,123 @@
---
## ✨ 核心特性
## Key Features
### 🚀 极致性能
- **零卡顿交互**:采用独创的 "幽灵拖拽" (Ghost Resizing) 技术,在包含数万行数据的表格中调整列宽,依然保持 60fps+ 的丝滑体验。
- **虚拟滚动**:轻松处理海量数据展示,拒绝卡顿。
### Performance
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
- **Virtualized rendering**: keeps large result sets responsive.
### 🔌 多数据库支持
- **MySQL**:完整支持,涵盖数据编辑、结构管理与导入导出。
- **PostgreSQL**:数据查看与编辑支持,事务提交能力持续完善。
- **SQLite**:本地文件数据库支持。
- **Oracle**:基础数据访问与编辑支持。
- **Dameng达梦**:基础数据访问与编辑支持。
- **Kingbase人大金仓**:基础数据访问与编辑支持。
- **TDengine**:时序数据库连接、库表浏览与 SQL 查询支持。
- **Redis**Key/Value 浏览、命令执行、视图与编码切换。
- **自定义驱动**:支持配置 Driver/DSN 接入更多数据源。
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。
### Data Management (DataGrid)
- In-place cell editing.
- Batch insert/update/delete with transaction-oriented submit/rollback.
- Large-field popup editor.
- Context actions (set NULL, copy/export, etc.).
- Smart read/write mode switching based on query context.
- Export formats: CSV, Excel (XLSX), JSON, Markdown.
### 📊 强大的数据管理 (DataGrid)
- **所见即所得编辑**:直接在表格中双击单元格修改数据。
- **批量事务操作**:支持批量新增、修改、删除,一键提交或回滚事务。
- **大字段编辑**:双击大字段自动打开弹窗编辑器,避免卡顿。
- **右键上下文菜单**:快速设置 NULL、复制/导出等操作。
- **智能上下文**:自动识别单表查询,解锁编辑功能;复杂查询自动切换为只读模式。
- **批量导出/备份**:支持表与数据库的批量导出/备份。
- **数据导出**:支持 CSV、Excel (XLSX)、JSON、Markdown 等格式。
### SQL Editor
- Monaco Editor core.
- Context-aware completion for databases/tables/columns.
- Multi-tab query workflow.
### 🧰 批量导出/备份
- **数据库批量导出**:支持结构导出与结构+数据备份。
- **表批量导出**:支持多表一键导出/备份。
- **智能上下文检测**:自动判断目标范围,避免误操作。
### Batch Export / Backup
- Database-level and table-level batch export/backup.
- Scope-aware operation flow to reduce mistakes.
### 🧩 Redis 视图与编码
- **视图模式切换**:自动/原始文本/UTF-8/十六进制多模式显示。
- **智能解码**:针对二进制值进行 UTF-8 质量判定与中文字符识别。
- **命令执行**:内置命令面板快速操作。
### Connectivity
- URI generation/parsing.
- SSH tunnel support.
- Proxy support.
- Config import/export (JSON).
- Optional driver management and activation.
### 🔄 数据同步与导入导出
- **连接配置导入/导出**:支持配置 JSON 导入导出,便于团队共享。
- **数据同步**:内置数据同步面板,支持跨库同步任务配置。
### Redis Tools
- Multi-view value rendering (auto/raw text/UTF-8/hex).
- Built-in command execution panel.
### 🆙 在线更新
- **自动更新**:启动/定时/手动检查更新,自动下载并提示重启完成更新。
### Observability and Update
- SQL execution logs with timing information.
- Startup/scheduled/manual update checks.
### 🧾 可观测性
- **SQL 执行日志**:实时查看 SQL 与执行耗时,便于排障与优化。
### 📝 智能 SQL 编辑器
- **Monaco Editor 内核**:集成 VS Code 同款编辑器,体验极佳。
- **智能补全**:自动感知当前连接上下文,提供数据库、表名、字段名的实时补全。
- **多标签页**:支持多窗口并行操作,像浏览器一样管理你的查询会话。
### 🎨 现代化 UI
- **Ant Design 5**:企业级 UI 设计语言。
- **暗黑模式**:内置深色/浅色主题切换,适应不同光照环境。
- **响应式布局**:灵活的侧边栏与布局调整。
### UI/UX
- Ant Design 5 based interface.
- Light/Dark themes.
- Flexible sidebar and layout behavior.
---
## 🛠️ 技术栈
## Tech Stack
* **后端 (Backend)**: Go 1.24 + Wails v2
* **前端 (Frontend)**: React 18 + TypeScript + Vite
* **UI 框架**: Ant Design 5
* **状态管理**: Zustand
* **编辑器**: Monaco Editor
- **Backend**: Go 1.24 + Wails v2
- **Frontend**: React 18 + TypeScript + Vite
- **UI**: Ant Design 5
- **State Management**: Zustand
- **Editor**: Monaco Editor
---
## 📦 安装与运行
## Installation and Run
### 前置要求
* [Go](https://go.dev/dl/) 1.21+
* [Node.js](https://nodejs.org/) 18+
* [Wails CLI](https://wails.io/docs/gettingstarted/installation): `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
### Prerequisites
- [Go](https://go.dev/dl/) 1.21+
- [Node.js](https://nodejs.org/) 18+
- [Wails CLI](https://wails.io/docs/gettingstarted/installation):
`go install github.com/wailsapp/wails/v2/cmd/wails@latest`
### 开发模式
### Development Mode
```bash
# 克隆项目
# Clone
git clone https://github.com/Syngnat/GoNavi.git
cd GoNavi
# 启动开发服务器 (支持热重载)
# Start development with hot reload
wails dev
```
### 编译构建
### Build
```bash
# 构建当前平台的可执行文件
# Build for current platform
wails build
# 清理并构建 (推荐发布前使用)
# Clean build (recommended before release)
wails build -clean
```
构建产物将位于 `build/bin` 目录下。
Artifacts are generated in `build/bin`.
### 跨平台编译 (GitHub Actions)
### Cross-Platform Release (GitHub Actions)
本项目内置了 GitHub Actions 流水线Push `v*` 格式的 Tag 即可自动触发构建并发布 Release。
支持构建:
* macOS (AMD64 / ARM64)
* Windows (AMD64)
* Linux (AMD64提供 WebKitGTK 4.0 与 4.1 变体产物)
The repository includes a release workflow.
Push a `v*` tag to trigger automated build and release.
Target artifacts include:
- macOS (AMD64 / ARM64)
- Windows (AMD64)
- Linux (AMD64, WebKitGTK 4.0 and 4.1 variants)
---
## ❓ 常见问题 (Troubleshooting)
## Troubleshooting
### macOS 提示 "应用已损坏,无法打开"
### macOS: "App is damaged and cant be opened"
由于本项目尚未购买 Apple 开发者证书进行签名NotarizationmacOS 的 Gatekeeper 安全机制可能会拦截应用的运行。请按照以下步骤解决:
Without Apple notarization, Gatekeeper may block startup.
1. 将下载的 `GoNavi.app` 拖入 **应用程序** 文件夹。
2. 打开 **终端 (Terminal)**
3. 复制并执行以下命令(输入密码时不会显示):
```bash
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
```
4. 或者:在 Finder 中右键点击应用图标,按住 `Control` 键选择 **打开**,然后在弹出的窗口中再次点击 **打开**。
1. Move `GoNavi.app` to **Applications**.
2. Open **Terminal**.
3. Run:
### Linux 启动报错缺少 `libwebkit2gtk` / `libjavascriptcoregtk`
```bash
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
```
GoNavi 的 Linux 二进制依赖系统 WebKitGTK 运行库。不同发行版默认版本不同:
Or right-click the app in Finder and choose **Open** with Control key flow.
- Debian 13 / Ubuntu 24.04 及更新版本:通常为 WebKitGTK 4.1
- Ubuntu 22.04 / Debian 12 等:通常为 WebKitGTK 4.0
### Linux: missing `libwebkit2gtk` / `libjavascriptcoregtk`
如果启动时报错(如 `libwebkit2gtk-4.0.so.37: cannot open shared object file`),请按系统安装对应依赖后重试:
GoNavi depends on WebKitGTK runtime libraries.
```bash
# Debian 13 / Ubuntu 24.04+
@@ -166,20 +192,20 @@ sudo apt-get update
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
```
如果你使用的是 Release 中带 `-WebKit41` 后缀的 Linux 产物,请优先在 Debian 13 / Ubuntu 24.04+ 上使用;普通 Linux 产物更适合 WebKitGTK 4.0 运行环境。
If you use Linux artifacts with the `-WebKit41` suffix, prefer Debian 13 / Ubuntu 24.04+.
---
## 🤝 贡献指南
## Contributing
欢迎提交 Issue 和 Pull Request
Issues and pull requests are welcome.
1. Fork 本仓库
2. 创建你的特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交你的改动 (`git commit -m 'feat: Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启一个 Pull Request
1. Fork the repository.
2. Create a feature branch.
3. Commit your changes.
4. Push to your branch.
5. Open a pull request.
## 📄 开源协议
## License
本项目采用 [Apache-2.0 协议](LICENSE) 开源。
Licensed under [Apache-2.0](LICENSE).

194
README.zh-CN.md Normal file
View File

@@ -0,0 +1,194 @@
# GoNavi - 现代化轻量级数据库客户端
[![Go Version](https://img.shields.io/github/go-mod/go-version/Syngnat/GoNavi)](https://go.dev/)
[![Wails Version](https://img.shields.io/badge/Wails-v2-red)](https://wails.io)
[![React Version](https://img.shields.io/badge/React-v18-blue)](https://reactjs.org/)
[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
[![Build Status](https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?label=Build)](https://github.com/Syngnat/GoNavi/actions)
**语言**: [English](README.md) | 简体中文
GoNavi 是基于 **Wails (Go)****React** 构建的跨平台数据库管理工具,强调原生性能、低资源占用与多数据源统一工作流。
相比常见 Electron 客户端GoNavi 在体积、启动速度和内存占用上更轻量。
---
## 项目简介
GoNavi 面向开发者与 DBA核心目标是让数据库操作在桌面端做到“快、稳、统一”。
- **原生性能架构**WailsGo + WebView降低运行时开销。
- **大数据可用性**:虚拟滚动 + DataGrid 交互优化,提升大结果集可操作性。
- **统一连接能力**:支持 URI 生成/解析、SSH 隧道、代理、驱动按需安装。
- **工程化能力完整**:覆盖 SQL 编辑、对象管理、批量导出/备份、数据同步、执行日志、在线更新。
## 支持的数据源
> `内置`:主程序开箱即用。
> `可选驱动代理`:需在驱动管理中安装启用后可用。
| 类别 | 数据源 | 驱动模式 | 典型能力 |
|---|---|---|---|
| 关系型 | MySQL | 内置 | 库表浏览、SQL 查询、数据编辑、导出/备份 |
| 关系型 | PostgreSQL | 内置 | 库表浏览、SQL 查询、数据编辑、对象管理 |
| 关系型 | Oracle | 内置 | 连接查询、对象浏览、数据编辑 |
| 缓存 | Redis | 内置 | Key 浏览、命令执行、编码/视图切换 |
| 关系型 | MariaDB | 可选驱动代理 | 连接查询、对象管理、数据编辑 |
| 关系型 | Doris | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
| 搜索 | Sphinx | 可选驱动代理 | SphinxQL 查询与对象浏览 |
| 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 |
| 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 |
| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理 |
| 国产数据库 | Dameng | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | Kingbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | HighGo | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | Vastbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 文档型 | MongoDB | 可选驱动代理 | 文档查询、集合浏览、连接管理 |
| 时序 | TDengine | 可选驱动代理 | 时序库表浏览、查询分析 |
| 列式分析 | ClickHouse | 可选驱动代理 | 分析查询、对象浏览、SQL 执行 |
| 扩展接入 | Custom Driver/DSN | 自定义 | 通过 Driver + DSN 接入更多数据源 |
<h2 align="center">📸 项目截图</h2>
<div align="center">
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
<br />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/f0c57590-d987-4ecf-89b2-64efad60b6d7" />
</div>
---
## 核心特性
### 性能与交互
- 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。
- 虚拟滚动渲染,降低大结果集卡顿风险。
### 数据管理DataGrid
- 单元格所见即所得编辑。
- 批量新增/修改/删除,支持事务提交与回滚。
- 大字段弹窗编辑。
- 右键上下文操作NULL、复制、导出等
- 根据查询上下文智能切换读写模式。
- 支持 CSV / XLSX / JSON / Markdown 导出。
### SQL 编辑器
- 基于 Monaco Editor。
- 上下文补全(数据库/表/字段)。
- 多标签查询工作流。
### 连接与驱动
- URI 生成与解析。
- SSH 隧道、代理支持。
- 连接配置 JSON 导入/导出。
- 可选驱动安装与启用管理。
### Redis 工具
- 自动/原始文本/UTF-8/十六进制等视图模式。
- 内置命令执行面板。
### 可观测性与更新
- SQL 执行日志(含耗时)。
- 启动/定时/手动更新检查。
### UI 体验
- Ant Design 5 体系。
- 深色/浅色主题切换。
- 灵活布局与侧边栏行为。
---
## 技术栈
- **后端**: Go 1.24 + Wails v2
- **前端**: React 18 + TypeScript + Vite
- **UI 框架**: Ant Design 5
- **状态管理**: Zustand
- **编辑器**: Monaco Editor
---
## 安装与运行
### 前置要求
- [Go](https://go.dev/dl/) 1.21+
- [Node.js](https://nodejs.org/) 18+
- [Wails CLI](https://wails.io/docs/gettingstarted/installation):
`go install github.com/wailsapp/wails/v2/cmd/wails@latest`
### 开发模式
```bash
# 克隆项目
git clone https://github.com/Syngnat/GoNavi.git
cd GoNavi
# 启动开发(热重载)
wails dev
```
### 编译构建
```bash
# 构建当前平台
wails build
# 清理后构建(发布前推荐)
wails build -clean
```
构建产物位于 `build/bin`
### 跨平台发布GitHub Actions
仓库内置发布流水线,推送 `v*` Tag 可自动构建并发布 Release。
支持目标:
- macOS (AMD64 / ARM64)
- Windows (AMD64)
- Linux (AMD64含 WebKitGTK 4.0 / 4.1 变体)
---
## 常见问题
### macOS 提示“应用已损坏,无法打开”
在未进行 Apple Notarization 时Gatekeeper 可能拦截应用。
```bash
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
```
### Linux 缺少 `libwebkit2gtk` / `libjavascriptcoregtk`
```bash
# Debian 13 / Ubuntu 24.04+
sudo apt-get update
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0
# Ubuntu 22.04 / Debian 12
sudo apt-get update
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
```
---
## 贡献指南
欢迎提交 Issue 与 Pull Request。
1. Fork 本仓库。
2. 创建特性分支。
3. 提交改动。
4. 推送分支。
5. 发起 Pull Request。
## 开源协议
本项目采用 [Apache-2.0 协议](LICENSE)。

View File

@@ -7,11 +7,11 @@
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/mariadb"
},
"diros": {
"doris": {
"engine": "go",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/diros"
"downloadUrl": "builtin://activate/doris"
},
"sphinx": {
"engine": "go",

View File

@@ -92,3 +92,53 @@ body[data-theme='dark'] {
background-color: #ff4d4f !important;
color: #fff !important;
}
/* 驱动管理:统一关闭 antd sticky 横向条,仅保留自定义独立横向条 */
.driver-manager-table .ant-table-sticky-scroll {
display: none !important;
}
/* 仅在独立横向条激活时隐藏表格自身横向滚动条,避免出现双横向条 */
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content,
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body {
overflow-x: auto !important;
-ms-overflow-style: none;
scrollbar-width: none;
}
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content::-webkit-scrollbar:horizontal,
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body::-webkit-scrollbar:horizontal {
height: 0 !important;
}
.driver-manager-table-wrap {
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.driver-manager-footer {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.driver-manager-footer-actions {
width: 100%;
display: flex;
justify-content: flex-end;
}
.driver-manager-hscroll {
width: 100%;
height: 12px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-gutter: stable;
background: transparent;
}
.driver-manager-hscroll-inner {
height: 1px;
}

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd';
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
import { useStore } from '../store';
import { normalizeOpacityForPlatform } from '../utils/appearance';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
@@ -10,10 +11,16 @@ const { Text } = Typography;
const MAX_URI_LENGTH = 4096;
const MAX_URI_HOSTS = 32;
const MAX_TIMEOUT_SECONDS = 3600;
const STEP1_MODAL_WIDTH = 760;
const STEP2_MODAL_WIDTH = 680;
const STEP1_MODAL_MIN_BODY_HEIGHT = 460;
const STEP1_SIDEBAR_DIVIDER_DARK = 'rgba(255, 255, 255, 0.16)';
const STEP1_SIDEBAR_DIVIDER_LIGHT = 'rgba(0, 0, 0, 0.08)';
const getDefaultPortByType = (type: string) => {
switch (type) {
case 'mysql': return 3306;
case 'doris':
case 'diros': return 9030;
case 'sphinx': return 9306;
case 'clickhouse': return 9000;
@@ -78,10 +85,35 @@ const ConnectionModal: React.FC<{
const testTimerRef = useRef<number | null>(null);
const addConnection = useStore((state) => state.addConnection);
const updateConnection = useStore((state) => state.updateConnection);
const theme = useStore((state) => state.theme);
const appearance = useStore((state) => state.appearance);
const darkMode = theme === 'dark';
const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity);
const mysqlTopology = Form.useWatch('mysqlTopology', form) || 'single';
const mongoTopology = Form.useWatch('mongoTopology', form) || 'single';
const mongoSrv = Form.useWatch('mongoSrv', form) || false;
const getSectionBg = (darkHex: string) => {
if (!darkMode) {
return `rgba(245, 245, 245, ${Math.max(effectiveOpacity, 0.92)})`;
}
const hex = darkHex.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${Math.max(effectiveOpacity, 0.82)})`;
};
const step1SidebarDividerColor = darkMode ? STEP1_SIDEBAR_DIVIDER_DARK : STEP1_SIDEBAR_DIVIDER_LIGHT;
const tunnelSectionStyle: React.CSSProperties = {
padding: '12px',
background: getSectionBg('#2a2a2a'),
borderRadius: 6,
marginTop: 12,
border: darkMode ? '1px solid rgba(255, 255, 255, 0.16)' : '1px solid rgba(0, 0, 0, 0.06)',
};
const fetchDriverStatusMap = async (): Promise<Record<string, DriverStatusSnapshot>> => {
const result: Record<string, DriverStatusSnapshot> = {};
const res = await GetDriverStatusList('', '');
@@ -456,7 +488,7 @@ const ConnectionModal: React.FC<{
const getUriPlaceholder = () => {
if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') {
const defaultPort = getDefaultPortByType(dbType);
const scheme = dbType === 'diros' ? 'diros' : 'mysql';
const scheme = dbType === 'diros' ? 'doris' : 'mysql';
return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`;
}
if (isFileDatabaseType(dbType)) {
@@ -501,7 +533,7 @@ const ConnectionModal: React.FC<{
}
const dbPath = database ? `/${encodeURIComponent(database)}` : '/';
const query = params.toString();
const scheme = type === 'diros' ? 'diros' : 'mysql';
const scheme = type === 'diros' ? 'doris' : 'mysql';
return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`;
}
@@ -1131,7 +1163,7 @@ const ConnectionModal: React.FC<{
{ label: '关系型数据库', items: [
{ key: 'mysql', name: 'MySQL', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#00758F' }} /> },
{ key: 'mariadb', name: 'MariaDB', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#003545' }} /> },
{ key: 'diros', name: 'Diros', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#0050b3' }} /> },
{ key: 'diros', name: 'Doris', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#0050b3' }} /> },
{ key: 'sphinx', name: 'Sphinx', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#2F5D62' }} /> },
{ key: 'clickhouse', name: 'ClickHouse', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#FFCC01' }} /> },
{ key: 'postgres', name: 'PostgreSQL', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#336791' }} /> },
@@ -1181,7 +1213,7 @@ const ConnectionModal: React.FC<{
)}
<div style={{ display: 'flex', height: 360 }}>
{/* 左侧分类导航 */}
<div style={{ width: 120, borderRight: '1px solid #f0f0f0', paddingRight: 8, flexShrink: 0 }}>
<div style={{ width: 120, borderRight: `1px solid ${step1SidebarDividerColor}`, paddingRight: 8, flexShrink: 0 }}>
{dbTypeGroups.map((group, idx) => (
<div
key={group.label}
@@ -1598,7 +1630,7 @@ const ConnectionModal: React.FC<{
</Form.Item>
{useSSH && (
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: 6, marginTop: 12 }}>
<div style={tunnelSectionStyle}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="sshHost" label="SSH 主机 (域名或IP)" rules={[{ required: useSSH, message: '请输入SSH主机' }]} style={{ flex: 1 }}>
<Input placeholder="例如: ssh.example.com 或 192.168.1.100" />
@@ -1634,7 +1666,7 @@ const ConnectionModal: React.FC<{
</Form.Item>
{useProxy && (
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: 6, marginTop: 12 }}>
<div style={tunnelSectionStyle}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="proxyType" label="代理类型" rules={[{ required: useProxy, message: '请选择代理类型' }]} style={{ width: 180 }}>
<Select options={[
@@ -1756,7 +1788,7 @@ const ConnectionModal: React.FC<{
};
const modalBodyStyle = step === 1
? { padding: '16px 24px', overflow: 'hidden' as const }
? { padding: '16px 24px', overflow: 'hidden' as const, minHeight: STEP1_MODAL_MIN_BODY_HEIGHT }
: {
padding: '16px 24px',
overflowY: 'auto' as const,
@@ -1772,7 +1804,7 @@ const ConnectionModal: React.FC<{
footer={getFooter()}
centered
wrapClassName="connection-modal-wrap"
width={step === 1 ? 650 : 600}
width={step === 1 ? STEP1_MODAL_WIDTH : STEP2_MODAL_WIDTH}
zIndex={10001}
destroyOnHidden
maskClosable={false}

View File

@@ -509,7 +509,17 @@ interface DataGridProps {
onReload?: () => void;
onSort?: (field: string, order: string) => void;
onPageChange?: (page: number, size: number) => void;
pagination?: { current: number, pageSize: number, total: number, totalKnown?: boolean };
pagination?: {
current: number,
pageSize: number,
total: number,
totalKnown?: boolean,
totalApprox?: boolean,
totalCountLoading?: boolean,
totalCountCancelled?: boolean,
};
onRequestTotalCount?: () => void;
onCancelTotalCount?: () => void;
sortInfoExternal?: { columnKey: string, order: string } | null;
// Filtering
showFilter?: boolean;
@@ -534,7 +544,7 @@ type ColumnMeta = {
const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
onReload, onSort, onPageChange, pagination, sortInfoExternal, showFilter, onToggleFilter, onApplyFilter
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, onApplyFilter
}) => {
const connections = useStore(state => state.connections);
const addSqlLog = useStore(state => state.addSqlLog);
@@ -549,6 +559,8 @@ const DataGrid: React.FC<DataGridProps> = ({
const showColumnComment = queryOptions?.showColumnComment !== false;
const showColumnType = queryOptions?.showColumnType !== false;
const selectionColumnWidth = 46;
const connTypeLower = String(connections.find(c => c.id === connectionId)?.config?.type || '').trim().toLowerCase();
const isDuckDBConnection = connTypeLower === 'duckdb';
// Background Helper
const getBg = (darkHex: string) => {
@@ -2525,6 +2537,26 @@ const DataGrid: React.FC<DataGridProps> = ({
</>
)}
{isDuckDBConnection && onRequestTotalCount && (
<>
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
<Tooltip title={pagination?.totalCountLoading ? '取消本次精确总数统计(不会影响当前浏览)' : '按当前筛选统计精确总数'}>
<Button
icon={pagination?.totalCountLoading ? <CloseOutlined /> : <VerticalAlignBottomOutlined />}
onClick={() => {
if (pagination?.totalCountLoading) {
if (onCancelTotalCount) onCancelTotalCount();
return;
}
onRequestTotalCount();
}}
>
{pagination?.totalCountLoading ? '取消统计' : '统计总数'}
</Button>
</Tooltip>
</>
)}
<div style={{ marginLeft: 'auto' }} />
<div style={{ flexShrink: 0 }}>
<Popover
@@ -3089,8 +3121,20 @@ const DataGrid: React.FC<DataGridProps> = ({
pageSize={pagination.pageSize}
total={pagination.total}
showTotal={(total, range) => {
const currentCount = Math.max(0, range[1] - range[0] + 1);
if (pagination.totalKnown === false) return `当前 ${currentCount} 条 / 正在统计总数...`;
const hasValidRange = Array.isArray(range) && range[0] > 0 && range[1] >= range[0];
const currentCount = hasValidRange ? Math.max(0, range[1] - range[0] + 1) : 0;
if (pagination.totalKnown === false) {
if (isDuckDBConnection) {
if (pagination.totalCountLoading) return `当前 ${currentCount} 条 / 正在统计精确总数...`;
if (pagination.totalApprox && Number.isFinite(total) && total > 0) return `当前 ${currentCount} 条 / 约 ${total}`;
if (pagination.totalCountCancelled) return `当前 ${currentCount} 条 / 已取消统计`;
return `当前 ${currentCount} 条 / 总数未统计`;
}
return `当前 ${currentCount} 条 / 正在统计总数...`;
}
if (isDuckDBConnection && (!Number.isFinite(total) || total <= 0)) {
return '当前 0 条 / 共 0 条';
}
return `当前 ${currentCount} 条 / 共 ${total}`;
}}
showSizeChanger

View File

@@ -2,10 +2,112 @@ import React, { useEffect, useState, useCallback, useRef } from 'react';
import { message } from 'antd';
import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
import { DBQuery, DBGetColumns, DBQueryIsolated } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { buildOrderBySQL, buildWhereSQL, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
type ViewerPaginationState = {
current: number;
pageSize: number;
total: number;
totalKnown: boolean;
totalApprox: boolean;
totalCountLoading: boolean;
totalCountCancelled: boolean;
};
const toNonNegativeFiniteNumber = (value: unknown): number | null => {
if (typeof value === 'number') {
return Number.isFinite(value) && value >= 0 ? value : null;
}
if (typeof value === 'bigint') {
return value >= 0n ? Number(value) : null;
}
if (typeof value === 'string') {
const text = value.trim();
if (!text) return null;
const parsed = Number(text);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
return null;
};
const parseTotalFromCountRow = (row: any): number | null => {
if (!row || typeof row !== 'object') return null;
const entries = Object.entries(row as Record<string, unknown>);
if (entries.length === 0) return null;
for (const [key, raw] of entries) {
const normalized = String(key || '').trim().toLowerCase();
if (normalized === 'total' || normalized === 'count' || normalized.includes('count')) {
const parsed = toNonNegativeFiniteNumber(raw);
if (parsed !== null) return parsed;
}
}
for (const [, raw] of entries) {
const parsed = toNonNegativeFiniteNumber(raw);
if (parsed !== null) return parsed;
}
return null;
};
const parseDuckDBApproxTotalRow = (row: any): number | null => {
if (!row || typeof row !== 'object') return null;
const entries = Object.entries(row as Record<string, unknown>);
if (entries.length === 0) return null;
const preferredKeys = ['approx_total', 'estimated_size', 'estimated_rows', 'row_count', 'count', 'total'];
for (const preferred of preferredKeys) {
for (const [key, raw] of entries) {
if (String(key || '').trim().toLowerCase() !== preferred) continue;
const parsed = toNonNegativeFiniteNumber(raw);
if (parsed !== null) return parsed;
}
}
for (const [key, raw] of entries) {
const normalized = String(key || '').trim().toLowerCase();
if (normalized.includes('estimate') || normalized.includes('row') || normalized.includes('count') || normalized.includes('total')) {
const parsed = toNonNegativeFiniteNumber(raw);
if (parsed !== null) return parsed;
}
}
return null;
};
const normalizeDuckDBIdentifier = (raw: string): string => {
const text = String(raw || '').trim();
if (text.length >= 2) {
const first = text[0];
const last = text[text.length - 1];
if ((first === '"' && last === '"') || (first === '`' && last === '`')) {
return text.slice(1, -1).trim();
}
}
return text;
};
const resolveDuckDBSchemaAndTable = (dbName: string, tableName: string) => {
const rawTable = String(tableName || '').trim();
if (!rawTable) return { schemaName: 'main', pureTableName: '' };
const parts = rawTable.split('.');
if (parts.length >= 2) {
const pureTableName = normalizeDuckDBIdentifier(parts[parts.length - 1]);
const schemaName = normalizeDuckDBIdentifier(parts[parts.length - 2]);
if (schemaName && pureTableName) {
return { schemaName, pureTableName };
}
}
const fallbackSchema = normalizeDuckDBIdentifier(String(dbName || '').trim()) || 'main';
return { schemaName: fallbackSchema, pureTableName: normalizeDuckDBIdentifier(rawTable) };
};
const escapeSQLLiteral = (value: string): string => String(value || '').replace(/'/g, "''");
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const [data, setData] = useState<any[]>([]);
const [columnNames, setColumnNames] = useState<string[]>([]);
@@ -16,14 +118,26 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const fetchSeqRef = useRef(0);
const countSeqRef = useRef(0);
const countKeyRef = useRef<string>('');
const duckdbApproxSeqRef = useRef(0);
const duckdbApproxKeyRef = useRef<string>('');
const manualCountSeqRef = useRef(0);
const manualCountKeyRef = useRef<string>('');
const pkSeqRef = useRef(0);
const pkKeyRef = useRef<string>('');
const latestConfigRef = useRef<any>(null);
const latestDbTypeRef = useRef<string>('');
const latestDbNameRef = useRef<string>('');
const latestCountSqlRef = useRef<string>('');
const latestCountKeyRef = useRef<string>('');
const [pagination, setPagination] = useState({
const [pagination, setPagination] = useState<ViewerPaginationState>({
current: 1,
pageSize: 100,
total: 0,
totalKnown: false
totalKnown: false,
totalApprox: false,
totalCountLoading: false,
totalCountCancelled: false,
});
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
@@ -33,13 +147,106 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const currentConnType = (connections.find(c => c.id === tab.connectionId)?.config?.type || '').toLowerCase();
const forceReadOnly = currentConnType === 'tdengine' || currentConnType === 'clickhouse';
const runIsolatedQuery = useCallback(async (queryConfig: any, dbName: string, sql: string) => {
return DBQueryIsolated(queryConfig as any, dbName, sql);
}, []);
useEffect(() => {
setPkColumns([]);
pkKeyRef.current = '';
countKeyRef.current = '';
setPagination(prev => ({ ...prev, current: 1, total: 0, totalKnown: false }));
duckdbApproxKeyRef.current = '';
manualCountKeyRef.current = '';
latestConfigRef.current = null;
latestDbTypeRef.current = '';
latestDbNameRef.current = '';
latestCountSqlRef.current = '';
latestCountKeyRef.current = '';
setPagination(prev => ({
...prev,
current: 1,
total: 0,
totalKnown: false,
totalApprox: false,
totalCountLoading: false,
totalCountCancelled: false,
}));
}, [tab.connectionId, tab.dbName, tab.tableName]);
const handleDuckDBManualCount = useCallback(async () => {
if (latestDbTypeRef.current !== 'duckdb') {
return;
}
const config = latestConfigRef.current;
const dbName = latestDbNameRef.current;
const countSql = latestCountSqlRef.current;
const countKey = latestCountKeyRef.current;
if (!config || !countSql || !countKey) {
message.warning('当前结果集尚未就绪,请先执行一次加载');
return;
}
manualCountKeyRef.current = countKey;
const countSeq = ++manualCountSeqRef.current;
const countStart = Date.now();
setPagination(prev => ({ ...prev, totalCountLoading: true, totalCountCancelled: false }));
const countConfig: any = { ...(config as any), timeout: 120 };
try {
const resCount = await runIsolatedQuery(countConfig, dbName, countSql);
const countDuration = Date.now() - countStart;
addSqlLog({
id: `log-${Date.now()}-duckdb-manual-count`,
timestamp: Date.now(),
sql: countSql,
status: resCount?.success ? 'success' : 'error',
duration: countDuration,
message: resCount?.success ? '' : String(resCount?.message || '统计失败'),
dbName
});
if (manualCountSeqRef.current !== countSeq) return;
if (manualCountKeyRef.current !== countKey) return;
if (!resCount?.success) {
setPagination(prev => ({ ...prev, totalCountLoading: false }));
message.error(String(resCount?.message || '统计总数失败'));
return;
}
if (!Array.isArray(resCount.data) || resCount.data.length === 0) {
setPagination(prev => ({ ...prev, totalCountLoading: false }));
return;
}
const total = parseTotalFromCountRow(resCount.data[0]);
if (total === null) {
setPagination(prev => ({ ...prev, totalCountLoading: false }));
message.error('统计结果解析失败');
return;
}
setPagination(prev => ({
...prev,
total,
totalKnown: true,
totalApprox: false,
totalCountLoading: false,
totalCountCancelled: false,
}));
} catch (e: any) {
if (manualCountSeqRef.current !== countSeq) return;
if (manualCountKeyRef.current !== countKey) return;
setPagination(prev => ({ ...prev, totalCountLoading: false }));
message.error(`统计总数失败: ${String(e?.message || e)}`);
}
}, [addSqlLog, runIsolatedQuery]);
const handleDuckDBCancelManualCount = useCallback(() => {
manualCountSeqRef.current++;
setPagination(prev => ({ ...prev, totalCountLoading: false, totalCountCancelled: true }));
}, []);
const fetchData = useCallback(async (page = pagination.current, size = pagination.pageSize) => {
const seq = ++fetchSeqRef.current;
setLoading(true);
@@ -157,25 +364,70 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const countKey = `${tab.connectionId}|${dbName}|${tableName}|${whereSQL}`;
const derivedTotalKnown = !hasMore;
const derivedTotal = derivedTotalKnown ? offset + resultData.length : page * size + 1;
const isDuckDB = dbTypeLower === 'duckdb';
const minExpectedTotal = hasMore ? offset + resultData.length + 1 : offset + resultData.length;
if (derivedTotalKnown) countKeyRef.current = countKey;
latestConfigRef.current = config;
latestDbTypeRef.current = dbTypeLower;
latestDbNameRef.current = dbName;
latestCountSqlRef.current = countSql;
latestCountKeyRef.current = countKey;
setPagination(prev => {
if (derivedTotalKnown) {
return { ...prev, current: page, pageSize: size, total: derivedTotal, totalKnown: true };
return {
...prev,
current: page,
pageSize: size,
total: derivedTotal,
totalKnown: true,
totalApprox: false,
totalCountLoading: false,
totalCountCancelled: false,
};
}
if (prev.totalKnown && countKeyRef.current === countKey) {
return { ...prev, current: page, pageSize: size };
if (!isDuckDB) {
return { ...prev, current: page, pageSize: size };
}
// 当当前页存在“下一页”信号时,已知总数至少应大于当前页末尾。
// 若旧总数不满足该条件(例如历史统计值为 0降级为未知总数并回退到 derivedTotal。
if (Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
return { ...prev, current: page, pageSize: size };
}
}
return { ...prev, current: page, pageSize: size, total: derivedTotal, totalKnown: false };
const keepManualCounting = prev.totalCountLoading && manualCountKeyRef.current === countKey;
if (isDuckDB && prev.totalApprox && duckdbApproxKeyRef.current === countKey && Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
return {
...prev,
current: page,
pageSize: size,
totalKnown: false,
totalApprox: true,
totalCountLoading: keepManualCounting,
totalCountCancelled: false,
};
}
return {
...prev,
current: page,
pageSize: size,
total: derivedTotal,
totalKnown: false,
totalApprox: false,
totalCountLoading: keepManualCounting,
totalCountCancelled: keepManualCounting ? false : prev.totalCountCancelled,
};
});
if (!derivedTotalKnown) {
const shouldRunAsyncCount = !derivedTotalKnown && !isDuckDB;
if (shouldRunAsyncCount) {
if (countKeyRef.current !== countKey) {
countKeyRef.current = countKey;
const countSeq = ++countSeqRef.current;
const countStart = Date.now();
// 大表 COUNT(*) 可能非常慢,且在部分运行时环境下会影响后续操作响应;
// 这里为统计请求设置更短的超时,避免“后台统计”长期占用资源
// DuckDB 大文件场景下该统计会显著拖慢翻页,已禁用后台 COUNT
const countConfig: any = { ...(config as any), timeout: 5 };
DBQuery(countConfig, dbName, countSql)
@@ -198,10 +450,21 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
if (!resCount.success) return;
if (!Array.isArray(resCount.data) || resCount.data.length === 0) return;
const total = Number(resCount.data[0]?.['total']);
if (!Number.isFinite(total) || total < 0) return;
let total: number | null = null;
const parsed = Number(resCount.data[0]?.['total']);
if (Number.isFinite(parsed) && parsed >= 0) {
total = parsed;
}
if (total === null) return;
setPagination(prev => ({ ...prev, total, totalKnown: true }));
setPagination(prev => ({
...prev,
total,
totalKnown: true,
totalApprox: false,
totalCountLoading: false,
totalCountCancelled: false,
}));
})
.catch(() => {
if (countSeqRef.current !== countSeq) return;
@@ -210,6 +473,50 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
});
}
}
if (isDuckDB && !derivedTotalKnown && whereSQL.trim() === '' && duckdbApproxKeyRef.current !== countKey) {
duckdbApproxKeyRef.current = countKey;
const approxSeq = ++duckdbApproxSeqRef.current;
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
const escapedSchema = escapeSQLLiteral(schemaName);
const escapedTable = escapeSQLLiteral(pureTableName);
const approxConfig: any = { ...(config as any), timeout: 3 };
const approxSqlCandidates = [
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`,
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`,
];
(async () => {
for (const approxSql of approxSqlCandidates) {
try {
const approxRes = await runIsolatedQuery(approxConfig, dbName, approxSql);
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (countKeyRef.current !== countKey) return;
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
const approxTotal = parseDuckDBApproxTotalRow(approxRes.data[0]);
if (approxTotal === null) continue;
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue;
setPagination(prev => {
if (countKeyRef.current !== countKey) return prev;
if (prev.totalKnown) return prev;
return {
...prev,
total: approxTotal,
totalKnown: false,
totalApprox: true,
totalCountCancelled: false,
};
});
return;
} catch {
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (countKeyRef.current !== countKey) return;
}
}
})();
}
} else {
message.error(String(resData.message || '查询失败'));
}
@@ -227,7 +534,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
});
}
if (fetchSeqRef.current === seq) setLoading(false);
}, [connections, tab, sortInfo, filterConditions, pkColumns]);
}, [connections, tab, sortInfo, filterConditions, pkColumns, runIsolatedQuery]);
// 依赖 pkColumns在无手动排序时可回退到主键稳定排序。
// 主键信息只会在首次加载后更新一次,避免循环查询。
@@ -266,6 +573,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
onSort={handleSort}
onPageChange={handlePageChange}
pagination={pagination}
onRequestTotalCount={currentConnType === 'duckdb' ? handleDuckDBManualCount : undefined}
onCancelTotalCount={currentConnType === 'duckdb' ? handleDuckDBCancelManualCount : undefined}
showFilter={showFilter}
onToggleFilter={handleToggleFilter}
onApplyFilter={handleApplyFilter}

View File

@@ -1,7 +1,9 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Collapse, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd';
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, ReloadOutlined } from '@ant-design/icons';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Collapse, Modal, Progress, Select, Space, Switch, Table, Tag, Typography, message } from 'antd';
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, ReloadOutlined } from '@ant-design/icons';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { useStore } from '../store';
import { normalizeOpacityForPlatform } from '../utils/appearance';
import {
CheckDriverNetworkStatus,
DownloadDriverPackage,
@@ -10,6 +12,7 @@ import {
GetDriverStatusList,
InstallLocalDriverPackage,
RemoveDriverPackage,
SelectDriverPackageDirectory,
SelectDriverPackageFile,
} from '../../wailsjs/go/app/App';
@@ -46,6 +49,8 @@ type ProgressState = {
percent: number;
};
type DriverActionKind = '' | 'install' | 'remove' | 'local';
type DriverLogEntry = {
time: string;
text: string;
@@ -84,6 +89,7 @@ type DriverVersionOption = {
const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`;
const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`;
const DRIVER_TABLE_SCROLL_X = 1450;
const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
type SelectOption = { value: string; label: string };
@@ -132,20 +138,32 @@ const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
};
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const theme = useStore((state) => state.theme);
const appearance = useStore((state) => state.appearance);
const darkMode = theme === 'dark';
const opacity = normalizeOpacityForPlatform(appearance.opacity);
const modalContentRef = useRef<HTMLDivElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
const tableScrollTargetsRef = useRef<HTMLElement[]>([]);
const externalHScrollRef = useRef<HTMLDivElement | null>(null);
const horizontalSyncSourceRef = useRef<'table' | 'external' | ''>('');
const [loading, setLoading] = useState(false);
const [downloadDir, setDownloadDir] = useState('');
const [networkChecking, setNetworkChecking] = useState(false);
const [networkStatus, setNetworkStatus] = useState<DriverNetworkStatus | null>(null);
const [rows, setRows] = useState<DriverStatusRow[]>([]);
const [actionDriver, setActionDriver] = useState('');
const [actionState, setActionState] = useState<{ driverType: string; kind: DriverActionKind }>({ driverType: '', kind: '' });
const [progressMap, setProgressMap] = useState<Record<string, ProgressState>>({});
const [operationLogMap, setOperationLogMap] = useState<Record<string, DriverLogEntry[]>>({});
const [logDriverType, setLogDriverType] = useState('');
const [logModalOpen, setLogModalOpen] = useState(false);
const [batchDirectoryImporting, setBatchDirectoryImporting] = useState(false);
const [forceOverwriteInstalled, setForceOverwriteInstalled] = useState(false);
const [versionMap, setVersionMap] = useState<Record<string, DriverVersionOption[]>>({});
const [selectedVersionMap, setSelectedVersionMap] = useState<Record<string, string>>({});
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
const [horizontalScrollWidth, setHorizontalScrollWidth] = useState(DRIVER_TABLE_SCROLL_X);
const appendOperationLog = useCallback((
driverType: string,
@@ -193,6 +211,76 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
});
}, []);
const refreshHorizontalScrollState = useCallback(() => {
const tableContainer = tableContainerRef.current;
const targets = tableContainer
? [
...new Set(
[
...Array.from(tableContainer.querySelectorAll('.ant-table-content')),
...Array.from(tableContainer.querySelectorAll('.ant-table-body')),
].filter((node): node is HTMLElement => node instanceof HTMLElement),
),
]
: tableScrollTargetsRef.current;
if (!targets || targets.length === 0) {
setHorizontalScrollWidth(DRIVER_TABLE_SCROLL_X);
return;
}
const nextWidth = Math.max(
DRIVER_TABLE_SCROLL_X,
...targets.map((target) => Math.max(0, target.scrollWidth)),
);
setHorizontalScrollWidth((prev) => (prev === nextWidth ? prev : nextWidth));
const externalScroll = externalHScrollRef.current;
if (!externalScroll || horizontalSyncSourceRef.current === 'external') {
return;
}
const preferredTarget =
targets.find((target) => target.scrollWidth > target.clientWidth + 1) ||
targets[0];
const targetScrollLeft = preferredTarget?.scrollLeft || 0;
if (Math.abs(externalScroll.scrollLeft - targetScrollLeft) > 1) {
externalScroll.scrollLeft = targetScrollLeft;
}
}, []);
const applyExternalScrollToTableTargets = useCallback(() => {
const tableContainer = tableContainerRef.current;
const externalScroll = externalHScrollRef.current;
if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) {
return;
}
if (horizontalSyncSourceRef.current === 'table') {
return;
}
const liveTargets = [
...new Set(
[
...Array.from(tableContainer.querySelectorAll('.ant-table-content')),
...Array.from(tableContainer.querySelectorAll('.ant-table-body')),
].filter((node): node is HTMLElement => node instanceof HTMLElement),
),
];
if (liveTargets.length === 0) {
return;
}
horizontalSyncSourceRef.current = 'external';
liveTargets.forEach((target) => {
if (target.scrollWidth <= target.clientWidth + 1) {
return;
}
if (Math.abs(target.scrollLeft - externalScroll.scrollLeft) > 1) {
target.scrollLeft = externalScroll.scrollLeft;
}
});
horizontalSyncSourceRef.current = '';
}, []);
const refreshStatus = useCallback(async (toastOnError = true) => {
setLoading(true);
try {
@@ -429,12 +517,125 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
useEffect(() => {
if (!open) {
setHorizontalScrollWidth(DRIVER_TABLE_SCROLL_X);
tableScrollTargetsRef.current = [];
return;
}
refreshStatus(false);
checkNetworkStatus(false);
}, [checkNetworkStatus, open, refreshStatus]);
useEffect(() => {
if (!open) {
return;
}
const tableContainer = tableContainerRef.current;
const externalScroll = externalHScrollRef.current;
if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) {
return;
}
let currentTargets: HTMLElement[] = [];
let rafId: number | null = null;
let bodyResizeObserver: ResizeObserver | null = null;
let containerResizeObserver: ResizeObserver | null = null;
const pickSyncTarget = () => {
if (currentTargets.length === 0) {
return null;
}
return currentTargets.find((target) => target.scrollWidth > target.clientWidth + 1) || currentTargets[0];
};
const syncFromTableTarget = (event?: Event) => {
const source = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
const activeTarget = source || pickSyncTarget();
if (!activeTarget) {
return;
}
if (horizontalSyncSourceRef.current === 'external') {
return;
}
horizontalSyncSourceRef.current = 'table';
if (Math.abs(externalScroll.scrollLeft - activeTarget.scrollLeft) > 1) {
externalScroll.scrollLeft = activeTarget.scrollLeft;
}
horizontalSyncSourceRef.current = '';
};
const bindCurrentTableTargets = () => {
const nextTargets = [
...new Set(
[
...Array.from(tableContainer.querySelectorAll('.ant-table-content')),
...Array.from(tableContainer.querySelectorAll('.ant-table-body')),
].filter((node): node is HTMLElement => node instanceof HTMLElement),
),
];
const sameTargets =
nextTargets.length === currentTargets.length &&
nextTargets.every((target, index) => target === currentTargets[index]);
if (sameTargets) {
return;
}
currentTargets.forEach((target) => {
target.removeEventListener('scroll', syncFromTableTarget);
bodyResizeObserver?.unobserve(target);
});
currentTargets = nextTargets;
tableScrollTargetsRef.current = nextTargets;
currentTargets.forEach((target) => {
target.addEventListener('scroll', syncFromTableTarget, { passive: true });
bodyResizeObserver?.observe(target);
});
refreshHorizontalScrollState();
syncFromTableTarget();
};
const scheduleRefresh = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
bindCurrentTableTargets();
refreshHorizontalScrollState();
});
};
const mutationObserver = new MutationObserver(scheduleRefresh);
mutationObserver.observe(tableContainer, { childList: true, subtree: true });
bodyResizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleRefresh) : null;
containerResizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleRefresh) : null;
containerResizeObserver?.observe(tableContainer);
if (typeof ResizeObserver !== 'undefined') {
modalContentRef.current && containerResizeObserver?.observe(modalContentRef.current);
}
window.addEventListener('resize', scheduleRefresh);
scheduleRefresh();
return () => {
mutationObserver.disconnect();
window.removeEventListener('resize', scheduleRefresh);
currentTargets.forEach((target) => {
target.removeEventListener('scroll', syncFromTableTarget);
});
if (bodyResizeObserver) {
bodyResizeObserver.disconnect();
}
if (containerResizeObserver) {
containerResizeObserver.disconnect();
}
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
};
}, [open, refreshHorizontalScrollState]);
useEffect(() => {
if (!open) {
return;
@@ -470,7 +671,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
}, [appendOperationLog, open]);
const installDriver = useCallback(async (row: DriverStatusRow) => {
setActionDriver(row.type);
setActionState({ driverType: row.type, kind: 'install' });
setProgressMap((prev) => ({
...prev,
[row.type]: {
@@ -505,25 +706,25 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
message.success(`${row.name}${versionTip} 已安装启用`);
refreshStatus(false);
} finally {
setActionDriver('');
setActionState({ driverType: '', kind: '' });
}
}, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]);
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
const fileRes = await SelectDriverPackageFile(downloadDir);
if (!fileRes?.success) {
if (String(fileRes?.message || '') !== 'Cancelled') {
message.error(fileRes?.message || '选择本地驱动包失败');
const installDriverFromLocalPath = useCallback(async (
row: DriverStatusRow,
sourcePath: string,
sourceLabel: '文件' | '目录',
options?: { silentToast?: boolean; skipRefresh?: boolean },
) => {
const pathText = String(sourcePath || '').trim();
if (!pathText) {
if (!options?.silentToast) {
message.error(`未选择有效的本地导入${sourceLabel}`);
}
return;
}
const filePath = String((fileRes?.data as any)?.path || '').trim();
if (!filePath) {
message.error('未选择有效的驱动包文件');
return;
return false;
}
setActionDriver(row.type);
setActionState({ driverType: row.type, kind: 'local' });
setProgressMap((prev) => ({
...prev,
[row.type]: {
@@ -532,23 +733,122 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
percent: 0,
},
}));
appendOperationLog(row.type, `[START] 开始本地导入${filePath}`);
appendOperationLog(row.type, `[START] 开始本地导入${sourceLabel}${pathText}`);
try {
const result = await InstallLocalDriverPackage(row.type, filePath, downloadDir);
const result = await InstallLocalDriverPackage(row.type, pathText, downloadDir);
if (!result?.success) {
const errText = result?.message || `导入 ${row.name} 本地驱动包失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
message.error(errText);
return;
if (!options?.silentToast) {
message.error(errText);
}
return false;
}
appendOperationLog(row.type, '[DONE] 本地导入安装完成');
message.success(`${row.name} 本地驱动包已安装启用`);
refreshStatus(false);
if (!options?.silentToast) {
message.success(`${row.name} 本地驱动包已安装启用`);
}
if (!options?.skipRefresh) {
await refreshStatus(false);
}
return true;
} finally {
setActionDriver('');
setActionState({ driverType: '', kind: '' });
}
}, [appendOperationLog, downloadDir, refreshStatus]);
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
const fileRes = await SelectDriverPackageFile(downloadDir);
if (!fileRes?.success) {
if (String(fileRes?.message || '') !== 'Cancelled') {
message.error(fileRes?.message || '选择本地驱动包文件失败');
}
return;
}
const filePath = String((fileRes?.data as any)?.path || '').trim();
if (!filePath) {
message.error('未选择有效的驱动包文件');
return;
}
await installDriverFromLocalPath(row, filePath, '文件');
}, [downloadDir, installDriverFromLocalPath]);
const installDriversFromDirectory = useCallback(async () => {
const directoryRes = await SelectDriverPackageDirectory(downloadDir);
if (!directoryRes?.success) {
if (String(directoryRes?.message || '') !== 'Cancelled') {
message.error(directoryRes?.message || '选择本地驱动包目录失败');
}
return;
}
const directoryPath = String((directoryRes?.data as any)?.path || '').trim();
if (!directoryPath) {
message.error('未选择有效的驱动包目录');
return;
}
const optionalRows = rows.filter((item) => !item.builtIn);
if (optionalRows.length === 0) {
message.info('当前没有可导入的外置驱动');
return;
}
let successCount = 0;
let failCount = 0;
let dedupeSkipCount = 0;
let slimSkipCount = 0;
setBatchDirectoryImporting(true);
try {
for (const row of optionalRows) {
const alreadyInstalled = row.packageInstalled || row.connectable;
if (alreadyInstalled && !forceOverwriteInstalled) {
dedupeSkipCount += 1;
appendOperationLog(row.type, '[SKIP] 已检测到驱动已安装,目录导入去重跳过');
continue;
}
if (alreadyInstalled && forceOverwriteInstalled) {
appendOperationLog(row.type, '[INFO] 已启用覆盖已安装模式,执行重装导入');
}
const isSlimBuildUnavailable = (row.message || '').includes('精简构建') && !row.packageInstalled;
if (isSlimBuildUnavailable) {
slimSkipCount += 1;
appendOperationLog(row.type, '[WARN] 当前发行包为精简构建,已跳过目录导入');
continue;
}
const ok = await installDriverFromLocalPath(row, directoryPath, '目录', { silentToast: true, skipRefresh: true });
if (ok) {
successCount += 1;
} else {
failCount += 1;
}
}
await refreshStatus(false);
} finally {
setBatchDirectoryImporting(false);
}
const skipParts: string[] = [];
if (dedupeSkipCount > 0) {
skipParts.push(`去重跳过 ${dedupeSkipCount}`);
}
if (slimSkipCount > 0) {
skipParts.push(`精简版跳过 ${slimSkipCount}`);
}
const skipTip = skipParts.length > 0 ? `${skipParts.join('')}` : '';
const forceTip = forceOverwriteInstalled ? '(覆盖已安装)' : '';
if (failCount === 0) {
message.success(`目录导入完成${forceTip}:成功 ${successCount}${skipTip}`);
return;
}
if (successCount > 0) {
message.warning(`目录导入完成${forceTip}:成功 ${successCount},失败 ${failCount}${skipTip}`);
return;
}
message.error(`目录导入失败${forceTip}:失败 ${failCount}${skipTip}`);
}, [appendOperationLog, downloadDir, forceOverwriteInstalled, installDriverFromLocalPath, refreshStatus, rows]);
const openDriverLog = useCallback((driverType: string) => {
const normalized = String(driverType || '').trim().toLowerCase();
if (!normalized) {
@@ -559,7 +859,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
}, []);
const removeDriver = useCallback(async (row: DriverStatusRow) => {
setActionDriver(row.type);
setActionState({ driverType: row.type, kind: 'remove' });
appendOperationLog(row.type, '[START] 开始移除驱动');
try {
const result = await RemoveDriverPackage(row.type, downloadDir);
@@ -578,7 +878,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
});
refreshStatus(false);
} finally {
setActionDriver('');
setActionState({ driverType: '', kind: '' });
}
}, [appendOperationLog, downloadDir, refreshStatus]);
@@ -590,25 +890,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
key: 'name',
width: 150,
},
{
title: '安装位置',
key: 'installPath',
width: 260,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary"></Text>;
}
const installPath = row.executablePath || row.installDir || '-';
if (installPath === '-') {
return <Text type="secondary">-</Text>;
}
return (
<Text copyable={{ text: installPath }} style={{ fontSize: 12 }}>
{installPath}
</Text>
);
},
},
{
title: '安装包大小',
dataIndex: 'packageSizeText',
@@ -688,6 +969,14 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
if (row.builtIn) {
return <Text type="secondary">-</Text>;
}
const versionLocked = row.packageInstalled || row.connectable;
if (versionLocked) {
const installedVersion = String(row.installedVersion || '').trim();
if (installedVersion) {
return <Text type="secondary">{installedVersion}</Text>;
}
return <Text type="secondary"></Text>;
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const selectOptions = buildVersionSelectOptions(options);
@@ -696,7 +985,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
size="small"
style={{ width: '100%' }}
loading={!!versionLoadingMap[row.type]}
disabled={actionDriver === row.type}
disabled={actionState.driverType === row.type}
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
value={selectedKey}
options={selectOptions as any}
@@ -726,7 +1015,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
return <Text type="secondary">-</Text>;
}
const isSlimBuildUnavailable = (row.message || '').includes('精简构建');
const loadingAction = actionDriver === row.type;
const loadingInstallOrRemove =
actionState.driverType === row.type && (actionState.kind === 'install' || actionState.kind === 'remove');
const loadingLocal = actionState.driverType === row.type && actionState.kind === 'local';
if (isSlimBuildUnavailable && !row.packageInstalled) {
return <Text type="secondary"> Full </Text>;
}
@@ -738,7 +1029,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
<Button
danger
icon={<DeleteOutlined />}
loading={loadingAction}
loading={loadingInstallOrRemove}
onClick={() => removeDriver(row)}
>
@@ -747,7 +1038,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
<Button
type="primary"
icon={<DownloadOutlined />}
loading={loadingAction}
loading={loadingInstallOrRemove}
onClick={() => installDriver(row)}
>
@@ -759,7 +1050,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
{mainAction}
<Button
icon={<FileSearchOutlined />}
loading={loadingAction}
loading={loadingLocal}
onClick={() => installDriverFromLocalFile(row)}
>
@@ -776,7 +1067,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
},
},
];
}, [actionDriver, installDriver, installDriverFromLocalFile, loadVersionOptions, loadVersionPackageSize, openDriverLog, operationLogMap, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]);
}, [actionState, installDriver, installDriverFromLocalFile, loadVersionOptions, loadVersionPackageSize, openDriverLog, operationLogMap, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]);
const activeLogRow = useMemo(() => {
if (!logDriverType) {
@@ -788,6 +1079,11 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
const activeDriverLogs = operationLogMap[logDriverType] || [];
const activeDriverLogLines = activeDriverLogs.map((item) => `[${item.time}] ${item.text}`);
const proxyEnvEntries = Object.entries(networkStatus?.proxyEnv || {});
const logBlockBackground = darkMode
? `rgba(28, 28, 28, ${Math.max(opacity, 0.82)})`
: `rgba(255, 255, 255, ${Math.max(opacity, 0.92)})`;
const logBlockBorderColor = darkMode ? 'rgba(255, 255, 255, 0.16)' : 'rgba(0, 0, 0, 0.12)';
const logBlockTextColor = darkMode ? 'rgba(255, 255, 255, 0.88)' : 'rgba(0, 0, 0, 0.88)';
return (
<Modal
@@ -805,18 +1101,31 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
},
}}
destroyOnClose
footer={[
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
</Button>,
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
</Button>,
<Button key="close" type="primary" onClick={onClose}>
</Button>,
]}
footer={(
<div className="driver-manager-footer">
<div
ref={externalHScrollRef}
className="driver-manager-hscroll"
aria-hidden={false}
onScroll={applyExternalScrollToTableTargets}
>
<div className="driver-manager-hscroll-inner" style={{ width: `${Math.max(horizontalScrollWidth, 1)}px` }} />
</div>
<Space className="driver-manager-footer-actions" size={8}>
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
</Button>
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
</Button>
<Button key="close" type="primary" onClick={onClose}>
</Button>
</Space>
</div>
)}
>
<div ref={modalContentRef}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Text type="secondary"> MySQL / Redis / Oracle / PostgreSQL </Text>
{networkStatus ? (
@@ -868,7 +1177,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
description={(
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary"></Text>
<Text type="secondary"> `mariadb-driver-agent` / `mariadb-driver-agent.exe` `GoNavi-DriverAgents.zip`</Text>
<Text type="secondary">/ `mariadb-driver-agent``mariadb-driver-agent.exe``GoNavi-DriverAgents.zip`使</Text>
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
{downloadDir || '-'}
</Paragraph>
@@ -881,16 +1190,42 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
)}
/>
<Table
rowKey="type"
loading={loading}
columns={columns as any}
dataSource={rows}
pagination={false}
size="middle"
scroll={{ x: 1450 }}
/>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Space size={8}>
<Text type="secondary"></Text>
<Switch
checked={forceOverwriteInstalled}
onChange={(checked) => setForceOverwriteInstalled(checked)}
disabled={batchDirectoryImporting}
/>
</Space>
<Button
icon={<FolderOpenOutlined />}
loading={batchDirectoryImporting}
onClick={() => void installDriversFromDirectory()}
>
</Button>
</Space>
<div
ref={tableContainerRef}
className="driver-manager-table-wrap driver-manager-table-wrap-external-active"
>
<Table
className="driver-manager-table"
rowKey="type"
loading={loading}
columns={columns as any}
dataSource={rows}
pagination={false}
size="middle"
sticky={false}
scroll={{ x: DRIVER_TABLE_SCROLL_X }}
/>
</div>
</Space>
</div>
<Modal
title={`驱动日志 - ${activeLogRow?.name || logDriverType}`}
open={logModalOpen}
@@ -914,7 +1249,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
</Paragraph>
) : null}
{activeDriverLogLines.length > 0 ? (
<pre style={{ margin: 0, maxHeight: 360, overflow: 'auto', padding: 12, background: '#fafafa', borderRadius: 8, border: '1px solid #f0f0f0', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
<pre style={{ margin: 0, maxHeight: 360, overflow: 'auto', padding: 12, background: logBlockBackground, color: logBlockTextColor, borderRadius: 8, border: `1px solid ${logBlockBorderColor}`, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{activeDriverLogLines.join('\n')}
</pre>
) : (

View File

@@ -218,18 +218,17 @@ const ResizableDivider: React.FC<{
<div
onMouseDown={handleMouseDown}
style={{
width: 6,
width: 5,
cursor: 'col-resize',
background: '#f0f0f0',
background: 'transparent',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
zIndex: 10,
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#d9d9d9')}
onMouseLeave={(e) => (e.currentTarget.style.background = '#f0f0f0')}
title="拖动调整宽度"
>
<div style={{ width: 2, height: 30, background: '#bfbfbf', borderRadius: 1 }} />
</div>
);
};
@@ -281,6 +280,23 @@ const getRedisScanLoadCount = (pattern: string, append: boolean): number => {
return append ? REDIS_KEY_SEARCH_LOAD_MORE_COUNT : REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT;
};
const normalizeRedisCursor = (value: unknown): string => {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed === '' ? '0' : trimmed;
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
return '0';
}
return Math.trunc(value).toString();
}
if (typeof value === 'bigint') {
return value.toString();
}
return '0';
};
const normalizeKeySegment = (segment: string): string => {
return segment === '' ? EMPTY_SEGMENT_LABEL : segment;
};
@@ -384,7 +400,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const [keys, setKeys] = useState<RedisKeyInfo[]>([]);
const [loading, setLoading] = useState(false);
const [searchPattern, setSearchPattern] = useState('*');
const [cursor, setCursor] = useState<number>(0);
const [cursor, setCursor] = useState<string>('0');
const [hasMore, setHasMore] = useState(false);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [keyValue, setKeyValue] = useState<RedisValue | null>(null);
@@ -433,7 +449,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const loadKeys = useCallback(async (
pattern: string = '*',
fromCursor: number = 0,
fromCursor: string = '0',
append: boolean = false,
targetCount?: number
) => {
@@ -454,7 +470,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
if (res.success) {
const result = res.data;
const scannedKeys = Array.isArray(result?.keys) ? result.keys : [];
const nextCursor = Number(result?.cursor || 0);
const nextCursor = normalizeRedisCursor(result?.cursor);
if (append) {
setKeys(prev => {
const keyMap = new Map<string, RedisKeyInfo>();
@@ -466,7 +482,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
setKeys(scannedKeys);
}
setCursor(nextCursor);
setHasMore(nextCursor !== 0);
setHasMore(nextCursor !== '0');
} else {
message.error('加载 Key 失败: ' + res.message);
}
@@ -483,14 +499,14 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
}, [getConfig]);
useEffect(() => {
loadKeys(searchPattern, 0, false, getRedisScanLoadCount(searchPattern, false));
loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false));
}, [redisDB]);
const handleSearch = (value: string) => {
const pattern = value.trim() || '*';
setSearchPattern(pattern);
setCursor(0);
loadKeys(pattern, 0, false, getRedisScanLoadCount(pattern, false));
setCursor('0');
loadKeys(pattern, '0', false, getRedisScanLoadCount(pattern, false));
};
const handleLoadMore = () => {
@@ -501,8 +517,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
const handleRefresh = () => {
setCursor(0);
loadKeys(searchPattern, 0, false, getRedisScanLoadCount(searchPattern, false));
setCursor('0');
loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false));
};
const loadKeyValue = async (key: string) => {

View File

@@ -24,6 +24,7 @@ const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
const SUPPORTED_CONNECTION_TYPES = new Set([
'mysql',
'mariadb',
'doris',
'diros',
'sphinx',
'clickhouse',
@@ -47,6 +48,7 @@ const getDefaultPortByType = (type: string): number => {
case 'mysql':
case 'mariadb':
return 3306;
case 'doris':
case 'diros':
return 9030;
case 'duckdb':
@@ -150,6 +152,9 @@ const sanitizeAddressList = (value: unknown): string[] => {
const normalizeConnectionType = (value: unknown): string => {
const type = toTrimmedString(value).toLowerCase();
if (type === 'doris') {
return 'diros';
}
return SUPPORTED_CONNECTION_TYPES.has(type) ? type : DEFAULT_CONNECTION_TYPE;
};
@@ -241,7 +246,8 @@ const sanitizeSavedConnection = (value: unknown, index: number): SavedConnection
const raw = value as Record<string, unknown>;
const config = sanitizeConnectionConfig(resolveConnectionConfigPayload(raw));
const id = toTrimmedString(raw.id, `conn-${index + 1}`) || `conn-${index + 1}`;
const fallbackName = config.host ? `${config.type}-${config.host}` : `连接-${index + 1}`;
const displayType = config.type === 'diros' ? 'doris' : config.type;
const fallbackName = config.host ? `${displayType}-${config.host}` : `连接-${index + 1}`;
const name = toTrimmedString(raw.name, fallbackName) || fallbackName;
const includeDatabases = sanitizeStringArray(raw.includeDatabases, 256);
const includeRedisDatabases = sanitizeNumberArray(raw.includeRedisDatabases, 0, 15);

View File

@@ -137,7 +137,7 @@ export interface RedisKeyInfo {
export interface RedisScanResult {
keys: RedisKeyInfo[];
cursor: number;
cursor: string;
}
export interface RedisValue {

View File

@@ -34,6 +34,8 @@ export function DBGetTriggers(arg1:connection.ConnectionConfig,arg2:string,arg3:
export function DBQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DBQueryIsolated(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DataSync(arg1:sync.SyncConfig):Promise<sync.SyncResult>;
@@ -124,7 +126,7 @@ export function RedisListSet(arg1:connection.ConnectionConfig,arg2:string,arg3:n
export function RedisRenameKey(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:number):Promise<connection.QueryResult>;
export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:any,arg4:number):Promise<connection.QueryResult>;
export function RedisSelectDB(arg1:connection.ConnectionConfig,arg2:number):Promise<connection.QueryResult>;
@@ -164,6 +166,8 @@ export function ResolveDriverRepositoryURL(arg1:string):Promise<connection.Query
export function SelectDriverDownloadDirectory(arg1:string):Promise<connection.QueryResult>;
export function SelectDriverPackageDirectory(arg1:string):Promise<connection.QueryResult>;
export function SelectDriverPackageFile(arg1:string):Promise<connection.QueryResult>;
export function SelectSSHKeyFile(arg1:string):Promise<connection.QueryResult>;

View File

@@ -62,6 +62,10 @@ export function DBQuery(arg1, arg2, arg3) {
return window['go']['app']['App']['DBQuery'](arg1, arg2, arg3);
}
export function DBQueryIsolated(arg1, arg2, arg3) {
return window['go']['app']['App']['DBQueryIsolated'](arg1, arg2, arg3);
}
export function DBShowCreateTable(arg1, arg2, arg3) {
return window['go']['app']['App']['DBShowCreateTable'](arg1, arg2, arg3);
}
@@ -322,6 +326,10 @@ export function SelectDriverDownloadDirectory(arg1) {
return window['go']['app']['App']['SelectDriverDownloadDirectory'](arg1);
}
export function SelectDriverPackageDirectory(arg1) {
return window['go']['app']['App']['SelectDriverPackageDirectory'](arg1);
}
export function SelectDriverPackageFile(arg1) {
return window['go']['app']['App']['SelectDriverPackageFile'](arg1);
}

View File

@@ -207,6 +207,32 @@ func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, erro
return a.getDatabaseWithPing(config, false)
}
func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Database, error) {
effectiveConfig := applyGlobalProxyToConnection(config)
if supported, reason := db.DriverRuntimeSupportStatus(effectiveConfig.Type); !supported {
if strings.TrimSpace(reason) == "" {
reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(effectiveConfig.Type))
}
return nil, withLogHint{err: fmt.Errorf("%s", reason), logPath: logger.Path()}
}
dbInst, err := db.NewDatabase(effectiveConfig.Type)
if err != nil {
return nil, err
}
connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig)
if proxyErr != nil {
_ = dbInst.Close()
return nil, wrapConnectError(effectiveConfig, proxyErr)
}
if err := dbInst.Connect(connectConfig); err != nil {
_ = dbInst.Close()
return nil, wrapConnectError(effectiveConfig, err)
}
return dbInst, nil
}
func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing bool) (db.Database, error) {
effectiveConfig := applyGlobalProxyToConnection(config)

View File

@@ -1,6 +1,9 @@
package app
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"net/http"
@@ -26,6 +29,12 @@ var globalProxyRuntime = struct {
proxy connection.ProxyConfig
}{}
type localProxyTLSFallbackTransport struct {
primary *http.Transport
fallback *http.Transport
proxyEndpoint string
}
func currentGlobalProxyConfig() globalProxySnapshot {
globalProxyRuntime.mu.RLock()
defer globalProxyRuntime.mu.RUnlock()
@@ -139,7 +148,7 @@ func newHTTPClientWithGlobalProxy(timeout time.Duration) *http.Client {
return client
}
func buildHTTPTransportWithGlobalProxy() *http.Transport {
func buildHTTPTransportWithGlobalProxy() http.RoundTripper {
baseTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok || baseTransport == nil {
return nil
@@ -160,7 +169,98 @@ func buildHTTPTransportWithGlobalProxy() *http.Transport {
}
transport.Proxy = http.ProxyURL(proxyURL)
return transport
if !isLoopbackProxyHost(snapshot.Proxy.Host) {
return transport
}
fallbackTransport := transport.Clone()
fallbackTransport.TLSClientConfig = cloneTLSConfigWithInsecureSkipVerify(fallbackTransport.TLSClientConfig)
return &localProxyTLSFallbackTransport{
primary: transport,
fallback: fallbackTransport,
proxyEndpoint: proxyURL.Redacted(),
}
}
func (t *localProxyTLSFallbackTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.primary.RoundTrip(req)
if err == nil {
return resp, nil
}
if !isTLSFallbackCandidate(req.Method, err) {
return nil, err
}
retryReq, cloneErr := cloneRequestForRetry(req)
if cloneErr != nil {
return nil, err
}
logger.Warnf("检测到本地代理 TLS 证书不受信任,启用兼容回退:代理=%s 目标=%s 错误=%v", t.proxyEndpoint, req.URL.String(), err)
return t.fallback.RoundTrip(retryReq)
}
func isTLSFallbackCandidate(method string, err error) bool {
if !isIdempotentRequestMethod(method) {
return false
}
return isUnknownAuthorityError(err)
}
func isIdempotentRequestMethod(method string) bool {
switch strings.ToUpper(strings.TrimSpace(method)) {
case http.MethodGet, http.MethodHead:
return true
default:
return false
}
}
func cloneRequestForRetry(req *http.Request) (*http.Request, error) {
cloned := req.Clone(req.Context())
if req.Body == nil || req.Body == http.NoBody {
return cloned, nil
}
if req.GetBody == nil {
return nil, fmt.Errorf("request body not replayable")
}
body, err := req.GetBody()
if err != nil {
return nil, err
}
cloned.Body = body
return cloned, nil
}
func isUnknownAuthorityError(err error) bool {
var unknownErr x509.UnknownAuthorityError
if errors.As(err, &unknownErr) {
return true
}
return strings.Contains(strings.ToLower(err.Error()), "x509: certificate signed by unknown authority")
}
func cloneTLSConfigWithInsecureSkipVerify(base *tls.Config) *tls.Config {
if base == nil {
return &tls.Config{InsecureSkipVerify: true}
}
cloned := base.Clone()
cloned.InsecureSkipVerify = true
return cloned
}
func isLoopbackProxyHost(host string) bool {
trimmed := strings.TrimSpace(host)
if trimmed == "" {
return false
}
if strings.EqualFold(trimmed, "localhost") {
return true
}
ip := net.ParseIP(trimmed)
if ip == nil {
return false
}
return ip.IsLoopback()
}
func buildProxyURLFromConfig(proxyConfig connection.ProxyConfig) (*url.URL, error) {

View File

@@ -7,6 +7,7 @@ import (
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/utils"
)
@@ -112,16 +113,39 @@ func resolveDDLDBType(config connection.ConnectionConfig) string {
driver := strings.ToLower(strings.TrimSpace(config.Driver))
switch driver {
case "postgresql":
case "postgresql", "postgres", "pg", "pq", "pgx":
return "postgres"
case "dm":
case "dm", "dameng", "dm8":
return "dameng"
case "sqlite3":
case "sqlite3", "sqlite":
return "sqlite"
case "sphinxql":
return "sphinx"
case "diros", "doris":
return "diros"
case "kingbase", "kingbase8", "kingbasees", "kingbasev8":
return "kingbase"
case "highgo":
return "highgo"
case "vastbase":
return "vastbase"
}
switch {
case strings.Contains(driver, "postgres"):
return "postgres"
case strings.Contains(driver, "kingbase"):
return "kingbase"
case strings.Contains(driver, "highgo"):
return "highgo"
case strings.Contains(driver, "vastbase"):
return "vastbase"
case strings.Contains(driver, "sqlite"):
return "sqlite"
case strings.Contains(driver, "sphinx"):
return "sphinx"
case strings.Contains(driver, "diros"), strings.Contains(driver, "doris"):
return "diros"
default:
return driver
}
@@ -186,7 +210,7 @@ func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string,
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb", "diros", "sphinx":
return connection.QueryResult{Success: false, Message: "MySQL/MariaDB/Diros/Sphinx 不支持直接重命名数据库,请新建库后迁移数据"}
return connection.QueryResult{Success: false, Message: "MySQL/MariaDB/Doris/Sphinx 不支持直接重命名数据库,请新建库后迁移数据"}
case "postgres", "kingbase", "highgo", "vastbase":
if strings.EqualFold(strings.TrimSpace(config.Database), oldName) {
return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再重命名"}
@@ -406,6 +430,66 @@ func (a *App) DBQuery(config connection.ConnectionConfig, dbName string, query s
}
}
func (a *App) DBQueryIsolated(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult {
runConfig := normalizeRunConfig(config, dbName)
dbInst, err := a.openDatabaseIsolated(runConfig)
if err != nil {
logger.Error(err, "DBQueryIsolated 获取连接失败:%s", formatConnSummary(runConfig))
return connection.QueryResult{Success: false, Message: err.Error()}
}
defer func() {
if closeErr := dbInst.Close(); closeErr != nil {
logger.Error(closeErr, "DBQueryIsolated 关闭临时连接失败:%s", formatConnSummary(runConfig))
}
}()
query = sanitizeSQLForPgLike(runConfig.Type, query)
timeoutSeconds := runConfig.Timeout
if timeoutSeconds <= 0 {
timeoutSeconds = 30
}
ctx, cancel := utils.ContextWithTimeout(time.Duration(timeoutSeconds) * time.Second)
defer cancel()
lowerQuery := strings.TrimSpace(strings.ToLower(query))
isReadQuery := strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain")
if !isReadQuery && strings.ToLower(strings.TrimSpace(runConfig.Type)) == "mongodb" && strings.HasPrefix(strings.TrimSpace(query), "{") {
isReadQuery = true
}
if isReadQuery {
var data []map[string]interface{}
var columns []string
if q, ok := dbInst.(interface {
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
}); ok {
data, columns, err = q.QueryContext(ctx, query)
} else {
data, columns, err = dbInst.Query(query)
}
if err != nil {
logger.Error(err, "DBQueryIsolated 查询失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: data, Fields: columns}
}
var affected int64
if e, ok := dbInst.(interface {
ExecContext(context.Context, string) (int64, error)
}); ok {
affected, err = e.ExecContext(ctx, query)
} else {
affected, err = dbInst.Exec(query)
}
if err != nil {
logger.Error(err, "DBQueryIsolated 执行失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: map[string]int64{"affectedRows": affected}}
}
func sqlSnippet(query string) string {
q := strings.TrimSpace(query)
const max = 200
@@ -460,8 +544,8 @@ func (a *App) DBGetTables(config connection.ConnectionConfig, dbName string) con
}
func (a *App) DBShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
runConfig := normalizeRunConfig(config, dbName)
dbType := resolveDDLDBType(config)
runConfig := buildRunConfigForDDL(config, dbType, dbName)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
@@ -469,35 +553,65 @@ func (a *App) DBShowCreateTable(config connection.ConnectionConfig, dbName strin
return connection.QueryResult{Success: false, Message: err.Error()}
}
schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName)
sqlStr, err := dbInst.GetCreateStatement(schemaName, pureTableName)
sqlStr, err := resolveCreateStatementWithFallback(dbInst, config, dbName, tableName)
if err != nil {
logger.Error(err, "DBShowCreateTable 获取建表语句失败:%s 表=%s", formatConnSummary(runConfig), tableName)
return connection.QueryResult{Success: false, Message: err.Error()}
}
if shouldFallbackCreateStatement(dbType, sqlStr) {
columns, colErr := dbInst.GetColumns(schemaName, pureTableName)
if colErr != nil {
logger.Error(colErr, "DBShowCreateTable 兜底加载字段失败:%s 表=%s", formatConnSummary(runConfig), tableName)
return connection.QueryResult{Success: false, Message: colErr.Error()}
}
fallbackDDL, buildErr := buildFallbackCreateStatement(dbType, schemaName, pureTableName, columns)
if buildErr != nil {
logger.Error(buildErr, "DBShowCreateTable 兜底生成 DDL 失败:%s 表=%s", formatConnSummary(runConfig), tableName)
return connection.QueryResult{Success: false, Message: buildErr.Error()}
}
sqlStr = fallbackDDL
}
return connection.QueryResult{Success: true, Data: sqlStr}
}
func shouldFallbackCreateStatement(dbType string, ddl string) bool {
func resolveCreateStatementWithFallback(dbInst db.Database, config connection.ConnectionConfig, dbName string, tableName string) (string, error) {
dbType := resolveDDLDBType(config)
schemaName, pureTableName := normalizeSchemaAndTableByType(dbType, dbName, tableName)
if pureTableName == "" {
return "", fmt.Errorf("表名不能为空")
}
sqlStr, sourceErr := dbInst.GetCreateStatement(schemaName, pureTableName)
if sourceErr == nil && !shouldFallbackCreateStatement(dbType, sqlStr) {
return sqlStr, nil
}
if !supportsCreateStatementFallback(dbType) {
if sourceErr != nil {
return "", sourceErr
}
return sqlStr, nil
}
columns, colErr := dbInst.GetColumns(schemaName, pureTableName)
if colErr != nil {
if sourceErr != nil {
return "", sourceErr
}
return "", colErr
}
fallbackDDL, buildErr := buildFallbackCreateStatement(dbType, schemaName, pureTableName, columns)
if buildErr != nil {
if sourceErr != nil {
return "", sourceErr
}
return "", buildErr
}
return fallbackDDL, nil
}
func supportsCreateStatementFallback(dbType string) bool {
switch dbType {
case "postgres", "kingbase", "highgo", "vastbase":
return true
default:
return false
}
}
func shouldFallbackCreateStatement(dbType string, ddl string) bool {
if !supportsCreateStatementFallback(dbType) {
return false
}
trimmed := strings.TrimSpace(ddl)
if trimmed == "" {

View File

@@ -0,0 +1,174 @@
package app
import (
"errors"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
type fakeCreateStatementDB struct {
createSQL string
createErr error
columns []connection.ColumnDefinition
columnsErr error
createSchema string
createTable string
colsSchema string
colsTable string
}
func (f *fakeCreateStatementDB) Connect(config connection.ConnectionConfig) error { return nil }
func (f *fakeCreateStatementDB) Close() error { return nil }
func (f *fakeCreateStatementDB) Ping() error { return nil }
func (f *fakeCreateStatementDB) Query(query string) ([]map[string]interface{}, []string, error) {
return nil, nil, nil
}
func (f *fakeCreateStatementDB) Exec(query string) (int64, error) { return 0, nil }
func (f *fakeCreateStatementDB) GetDatabases() ([]string, error) { return nil, nil }
func (f *fakeCreateStatementDB) GetTables(dbName string) ([]string, error) { return nil, nil }
func (f *fakeCreateStatementDB) GetCreateStatement(dbName, tableName string) (string, error) {
f.createSchema = dbName
f.createTable = tableName
return f.createSQL, f.createErr
}
func (f *fakeCreateStatementDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
f.colsSchema = dbName
f.colsTable = tableName
return f.columns, f.columnsErr
}
func (f *fakeCreateStatementDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
return nil, nil
}
func (f *fakeCreateStatementDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return nil, nil
}
func (f *fakeCreateStatementDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return nil, nil
}
func (f *fakeCreateStatementDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return nil, nil
}
func TestResolveDDLDBType_CustomDriverAlias(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
driver string
want string
}{
{name: "postgresql alias", driver: "postgresql", want: "postgres"},
{name: "pgx alias", driver: "pgx", want: "postgres"},
{name: "kingbase8 alias", driver: "kingbase8", want: "kingbase"},
{name: "kingbase contains alias", driver: "kingbasees", want: "kingbase"},
{name: "dm alias", driver: "dm8", want: "dameng"},
{name: "sqlite alias", driver: "sqlite3", want: "sqlite"},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cfg := connection.ConnectionConfig{Type: "custom", Driver: tc.driver}
if got := resolveDDLDBType(cfg); got != tc.want {
t.Fatalf("resolveDDLDBType() mismatch, want=%q got=%q", tc.want, got)
}
})
}
}
func TestResolveCreateStatementWithFallback_CustomKingbaseUsesPublicSchema(t *testing.T) {
t.Parallel()
dbInst := &fakeCreateStatementDB{
createSQL: "SHOW CREATE TABLE not directly supported in Kingbase/Postgres via SQL",
columns: []connection.ColumnDefinition{
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"},
},
}
ddl, err := resolveCreateStatementWithFallback(dbInst, connection.ConnectionConfig{
Type: "custom",
Driver: "kingbase8",
}, "demo_db", "orders")
if err != nil {
t.Fatalf("resolveCreateStatementWithFallback() unexpected error: %v", err)
}
if dbInst.createSchema != "public" || dbInst.colsSchema != "public" {
t.Fatalf("expected fallback schema public, got create=%q columns=%q", dbInst.createSchema, dbInst.colsSchema)
}
if !strings.Contains(ddl, `CREATE TABLE "public"."orders"`) {
t.Fatalf("expected fallback DDL with public schema, got: %s", ddl)
}
}
func TestResolveCreateStatementWithFallback_KeepQualifiedSchema(t *testing.T) {
t.Parallel()
dbInst := &fakeCreateStatementDB{
createSQL: "-- SHOW CREATE TABLE not fully supported for PostgreSQL in this MVP.",
columns: []connection.ColumnDefinition{
{Name: "id", Type: "integer", Nullable: "NO", Key: "PRI"},
},
}
ddl, err := resolveCreateStatementWithFallback(dbInst, connection.ConnectionConfig{
Type: "custom",
Driver: "postgresql",
}, "demo_db", "sales.orders")
if err != nil {
t.Fatalf("resolveCreateStatementWithFallback() unexpected error: %v", err)
}
if dbInst.createSchema != "sales" || dbInst.colsSchema != "sales" {
t.Fatalf("expected schema sales, got create=%q columns=%q", dbInst.createSchema, dbInst.colsSchema)
}
if !strings.Contains(ddl, `CREATE TABLE "sales"."orders"`) {
t.Fatalf("expected fallback DDL with sales schema, got: %s", ddl)
}
}
func TestResolveCreateStatementWithFallback_NoFallbackForMySQL(t *testing.T) {
t.Parallel()
dbInst := &fakeCreateStatementDB{
createSQL: "SHOW CREATE TABLE not directly supported in Kingbase/Postgres via SQL",
columnsErr: errors.New("should not be called"),
}
ddl, err := resolveCreateStatementWithFallback(dbInst, connection.ConnectionConfig{
Type: "mysql",
}, "demo_db", "orders")
if err != nil {
t.Fatalf("resolveCreateStatementWithFallback() unexpected error: %v", err)
}
if ddl != dbInst.createSQL {
t.Fatalf("expected original ddl for mysql, got: %s", ddl)
}
if dbInst.colsTable != "" {
t.Fatalf("mysql path should not call GetColumns, got table=%q", dbInst.colsTable)
}
}
func TestResolveCreateStatementWithFallback_FallbackWhenCreateStatementError(t *testing.T) {
t.Parallel()
dbInst := &fakeCreateStatementDB{
createErr: errors.New("statement unsupported"),
columns: []connection.ColumnDefinition{
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"},
},
}
ddl, err := resolveCreateStatementWithFallback(dbInst, connection.ConnectionConfig{
Type: "postgres",
}, "demo_db", "orders")
if err != nil {
t.Fatalf("resolveCreateStatementWithFallback() unexpected error: %v", err)
}
if !strings.Contains(ddl, `CREATE TABLE "public"."orders"`) {
t.Fatalf("expected fallback DDL for postgres error path, got: %s", ddl)
}
}

View File

@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"net/url"
@@ -200,6 +201,7 @@ const (
driverBundleIndexMaxSize = 1 << 20
driverManifestMaxSize = 2 << 20
driverNetworkProbeTimeout = 4 * time.Second
localDriverDirectoryScanMaxEntries = 20000
driverChecksumPolicyStrict = "strict"
driverChecksumPolicyWarn = "warn"
driverChecksumPolicyOff = "off"
@@ -212,7 +214,7 @@ const builtinDriverManifestJSON = `{
"drivers": {
"mysql": { "engine": "go", "version": "1.9.3", "checksumPolicy": "off" },
"mariadb": { "engine": "go", "version": "1.9.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/mariadb" },
"diros": { "engine": "go", "version": "1.9.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/diros" },
"doris": { "engine": "go", "version": "1.9.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/doris" },
"sphinx": { "engine": "go", "version": "1.9.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sphinx" },
"sqlserver": { "engine": "go", "version": "1.9.6", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sqlserver" },
"sqlite": { "engine": "go", "version": "1.44.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sqlite" },
@@ -228,18 +230,19 @@ const builtinDriverManifestJSON = `{
}`
var (
driverManifestCacheMu sync.RWMutex
driverManifestCache = make(map[string]driverManifestCacheEntry)
driverReleaseSizeMu sync.RWMutex
driverReleaseSizeMap = make(map[string]driverReleaseAssetSizeCacheEntry)
driverReleaseListMu sync.RWMutex
driverReleaseList = driverManifestReleaseListCache{}
driverModuleLatestMu sync.RWMutex
driverModuleLatestMap = make(map[string]goModuleLatestVersionCacheEntry)
driverModuleVersionMu sync.RWMutex
driverModuleVersionMap = make(map[string]goModuleVersionListCacheEntry)
driverVersionWarmupMu sync.Mutex
driverVersionWarmup = driverVersionWarmupState{}
driverManifestCacheMu sync.RWMutex
driverManifestCache = make(map[string]driverManifestCacheEntry)
driverReleaseSizeMu sync.RWMutex
driverReleaseSizeMap = make(map[string]driverReleaseAssetSizeCacheEntry)
driverReleaseListMu sync.RWMutex
driverReleaseList = driverManifestReleaseListCache{}
driverModuleLatestMu sync.RWMutex
driverModuleLatestMap = make(map[string]goModuleLatestVersionCacheEntry)
driverModuleVersionMu sync.RWMutex
driverModuleVersionMap = make(map[string]goModuleVersionListCacheEntry)
driverVersionWarmupMu sync.Mutex
driverVersionWarmup = driverVersionWarmupState{}
errLocalDriverDirScanLimit = errors.New("local_driver_directory_scan_limit_exceeded")
)
type driverVersionWarmupState struct {
@@ -360,9 +363,6 @@ func (a *App) SelectDriverPackageFile(currentPath string) connection.QueryResult
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "选择驱动包文件",
DefaultDirectory: defaultDir,
Filters: []runtime.FileFilter{
{DisplayName: "所有文件", Pattern: "*"},
},
})
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
@@ -377,6 +377,36 @@ func (a *App) SelectDriverPackageFile(currentPath string) connection.QueryResult
return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}}
}
func (a *App) SelectDriverPackageDirectory(currentPath string) connection.QueryResult {
defaultDir := strings.TrimSpace(currentPath)
if defaultDir == "" {
defaultDir = defaultDriverDownloadDirectory()
}
if filepath.Ext(defaultDir) != "" {
defaultDir = filepath.Dir(defaultDir)
}
if !filepath.IsAbs(defaultDir) {
if abs, err := filepath.Abs(defaultDir); err == nil {
defaultDir = abs
}
}
selection, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
Title: "选择驱动包目录",
DefaultDirectory: defaultDir,
})
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if strings.TrimSpace(selection) == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
}
if abs, err := filepath.Abs(selection); err == nil {
selection = abs
}
return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}}
}
func (a *App) ResolveDriverDownloadDirectory(directory string) connection.QueryResult {
resolved, err := resolveDriverDownloadDirectory(directory)
if err != nil {
@@ -426,7 +456,7 @@ func (a *App) ResolveDriverPackageDownloadURL(driverType string, repositoryURL s
if engine == driverEngineGo && !definition.BuiltIn {
urlText := strings.TrimSpace(definition.DefaultDownloadURL)
if urlText == "" {
urlText = fmt.Sprintf("builtin://activate/%s", definition.Type)
urlText = fmt.Sprintf("builtin://activate/%s", optionalDriverPublicTypeName(definition.Type))
}
data := map[string]interface{}{
"url": urlText,
@@ -500,14 +530,14 @@ func (a *App) GetDriverVersionPackageSize(driverType string, version string) con
if sizeByAsset, err := loadReleaseAssetSizesCached("tag:"+tag, func() (*githubRelease, error) {
return fetchReleaseByTag(tag)
}); err == nil {
sizeBytes = sizeByAsset[assetName]
sizeBytes = resolveOptionalDriverAssetSize(sizeByAsset, normalizedType)
if sizeBytes > 0 {
sizeSource = "tag"
}
}
if sizeBytes <= 0 {
if sizeByAsset, err := loadReleaseAssetSizesCached("latest", fetchLatestReleaseForDriverAssets); err == nil {
sizeBytes = sizeByAsset[assetName]
sizeBytes = resolveOptionalDriverAssetSize(sizeByAsset, normalizedType)
if sizeBytes > 0 {
sizeSource = "latest"
}
@@ -684,7 +714,7 @@ func (a *App) InstallLocalDriverPackage(driverType string, filePath string, down
a.emitDriverDownloadProgress(definition.Type, "start", 0, 100, "开始安装本地驱动包")
selectedVersion := resolveDriverInstallVersion(definition.PinnedVersion, "local://manual", definition)
meta, installErr := installOptionalDriverAgentFromLocalFile(definition, filePath, resolvedDir, selectedVersion)
meta, installErr := installOptionalDriverAgentFromLocalPath(definition, filePath, resolvedDir, selectedVersion)
if installErr != nil {
errText := normalizeErrorMessage(installErr)
a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText)
@@ -732,7 +762,7 @@ func (a *App) DownloadDriverPackage(driverType string, version string, downloadU
urlText = strings.TrimSpace(definition.DefaultDownloadURL)
}
if urlText == "" {
urlText = fmt.Sprintf("builtin://activate/%s", definition.Type)
urlText = fmt.Sprintf("builtin://activate/%s", optionalDriverPublicTypeName(definition.Type))
}
selectedVersion := resolveDriverInstallVersion(version, urlText, definition)
@@ -1038,7 +1068,7 @@ func allDriverDefinitionsWithPackages(packages map[string]pinnedDriverPackage) [
// 其他数据源需要先在驱动管理中“安装启用”。
buildOptionalGoDriverDefinition("mariadb", "MariaDB", packages),
buildOptionalGoDriverDefinition("diros", "Diros", packages),
buildOptionalGoDriverDefinition("diros", "Doris", packages),
buildOptionalGoDriverDefinition("sphinx", "Sphinx", packages),
buildOptionalGoDriverDefinition("sqlserver", "SQL Server", packages),
buildOptionalGoDriverDefinition("sqlite", "SQLite", packages),
@@ -1216,7 +1246,7 @@ func resolveDriverVersionOptions(definition driverDefinition, repositoryURL stri
urlText = strings.TrimSpace(definition.DefaultDownloadURL)
}
if urlText == "" && effectiveDriverEngine(definition) == driverEngineGo {
urlText = fmt.Sprintf("builtin://activate/%s", driverType)
urlText = fmt.Sprintf("builtin://activate/%s", optionalDriverPublicTypeName(driverType))
}
if versionText == "" {
versionText = resolveDriverInstallVersion("", urlText, definition)
@@ -1353,7 +1383,7 @@ func resolveVersionedDriverOption(definition driverDefinition, version string, s
urlText := strings.TrimSpace(definition.DefaultDownloadURL)
if urlText == "" && effectiveDriverEngine(definition) == driverEngineGo {
urlText = fmt.Sprintf("builtin://activate/%s", driverType)
urlText = fmt.Sprintf("builtin://activate/%s", optionalDriverPublicTypeName(driverType))
}
if urlText == "" {
return "", "", false
@@ -1400,13 +1430,13 @@ func resolveDriverVersionPackageSizeBytes(definition driverDefinition, option dr
tag := "v" + version
if sizeByAsset, ok := readReleaseAssetSizesFromCache("tag:" + tag); ok {
return sizeByAsset[assetName]
return resolveOptionalDriverAssetSize(sizeByAsset, driverType)
}
// 下拉版本列表要求快速返回:仅复用已有缓存,不在这里触发网络请求。
if strings.EqualFold(strings.TrimSpace(option.Source), "latest") {
if sizeByAsset, ok := readReleaseAssetSizesFromCache("latest"); ok {
return sizeByAsset[assetName]
return resolveOptionalDriverAssetSize(sizeByAsset, driverType)
}
}
return 0
@@ -1635,13 +1665,14 @@ func resolveDriverVersionOptionsFromReleases(definition driverDefinition) []driv
}
assetName := optionalDriverReleaseAssetName(driverType)
assetNames := optionalDriverReleaseAssetNames(driverType)
result := make([]driverVersionOptionItem, 0, len(releases))
for _, release := range releases {
if release.Prerelease {
continue
}
tag := strings.TrimSpace(release.TagName)
if tag == "" || !releaseContainsAsset(release, assetName) {
if tag == "" || !releaseContainsAnyAsset(release, assetNames) {
continue
}
result = append(result, driverVersionOptionItem{
@@ -1718,14 +1749,24 @@ func fetchDriverReleaseList() ([]githubRelease, error) {
return releases, nil
}
func releaseContainsAsset(release githubRelease, assetName string) bool {
name := strings.TrimSpace(assetName)
if name == "" {
func releaseContainsAnyAsset(release githubRelease, assetNames []string) bool {
normalizedNames := make([]string, 0, len(assetNames))
for _, assetName := range assetNames {
name := strings.TrimSpace(assetName)
if name == "" {
continue
}
normalizedNames = append(normalizedNames, name)
}
if len(normalizedNames) == 0 {
return false
}
for _, asset := range release.Assets {
if strings.EqualFold(strings.TrimSpace(asset.Name), name) {
return true
assetName := strings.TrimSpace(asset.Name)
for _, expected := range normalizedNames {
if strings.EqualFold(assetName, expected) {
return true
}
}
}
return false
@@ -2194,7 +2235,7 @@ func installOptionalDriverAgentPackage(a *App, definition driverDefinition, sele
}, nil
}
func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePath string, resolvedDir string, selectedVersion string) (installedDriverPackage, error) {
func installOptionalDriverAgentFromLocalPath(definition driverDefinition, filePath string, resolvedDir string, selectedVersion string) (installedDriverPackage, error) {
driverType := normalizeDriverType(definition.Type)
displayName := resolveDriverDisplayName(definition)
pathText := strings.TrimSpace(filePath)
@@ -2208,9 +2249,6 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa
if statErr != nil {
return installedDriverPackage{}, fmt.Errorf("读取本地驱动包失败:%w", statErr)
}
if info.IsDir() {
return installedDriverPackage{}, fmt.Errorf("本地驱动包路径为目录:%s", pathText)
}
executablePath, err := db.ResolveOptionalDriverAgentExecutablePath(resolvedDir, driverType)
if err != nil {
@@ -2220,8 +2258,23 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa
return installedDriverPackage{}, fmt.Errorf("创建 %s 驱动目录失败:%w", displayName, mkErr)
}
sourcePath := pathText
sourceName := filepath.Base(pathText)
downloadSource := fmt.Sprintf("local://manual/%s", filepath.Base(pathText))
if strings.EqualFold(filepath.Ext(pathText), ".zip") {
if info.IsDir() {
matchedPath, matchedEntry, resolveErr := resolveLocalDriverAgentFromDirectory(pathText, driverType)
if resolveErr != nil {
return installedDriverPackage{}, resolveErr
}
sourcePath = matchedPath
sourceName = filepath.Base(matchedPath)
downloadSource = fmt.Sprintf("local://manual-dir/%s", filepath.Base(pathText))
if strings.TrimSpace(matchedEntry) != "" {
downloadSource = downloadSource + "#" + matchedEntry
}
}
if !info.IsDir() && strings.EqualFold(filepath.Ext(pathText), ".zip") {
entryName, extractErr := installOptionalDriverAgentFromLocalZip(pathText, definition, executablePath)
if extractErr != nil {
return installedDriverPackage{}, extractErr
@@ -2230,7 +2283,7 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa
downloadSource = downloadSource + "#" + entryName
}
} else {
if copyErr := copyAgentBinary(pathText, executablePath); copyErr != nil {
if copyErr := copyAgentBinary(sourcePath, executablePath); copyErr != nil {
return installedDriverPackage{}, fmt.Errorf("导入本地驱动代理失败:%w", copyErr)
}
}
@@ -2242,8 +2295,8 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa
return installedDriverPackage{
DriverType: driverType,
Version: strings.TrimSpace(selectedVersion),
FilePath: pathText,
FileName: filepath.Base(pathText),
FilePath: sourcePath,
FileName: sourceName,
ExecutablePath: executablePath,
DownloadURL: downloadSource,
SHA256: hash,
@@ -2251,6 +2304,153 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa
}, nil
}
type localDriverCandidate struct {
absPath string
relativePath string
depth int
inPlatformDir bool
}
func resolveLocalDriverAgentFromDirectory(directoryPath string, driverType string) (string, string, error) {
root := strings.TrimSpace(directoryPath)
if root == "" {
return "", "", fmt.Errorf("本地驱动目录路径为空")
}
if absPath, absErr := filepath.Abs(root); absErr == nil {
root = absPath
}
info, statErr := os.Stat(root)
if statErr != nil {
return "", "", fmt.Errorf("读取本地驱动目录失败:%w", statErr)
}
if !info.IsDir() {
return "", "", fmt.Errorf("本地驱动目录路径不是目录:%s", root)
}
normalizedType := normalizeDriverType(driverType)
displayDefinition, found := resolveDriverDefinition(normalizedType)
if !found {
displayDefinition = driverDefinition{Type: normalizedType, Name: normalizedType}
}
displayName := resolveDriverDisplayName(displayDefinition)
platformDir := optionalDriverBundlePlatformDir(stdRuntime.GOOS)
assetNameCandidates := optionalDriverReleaseAssetNames(normalizedType)
baseNameCandidates := optionalDriverExecutableBaseNames(normalizedType)
assetName := optionalDriverReleaseAssetName(normalizedType)
exactRelativePath := filepath.ToSlash(filepath.Join(platformDir, assetName))
for _, candidateName := range assetNameCandidates {
exactPath := filepath.Join(root, platformDir, candidateName)
if exactInfo, err := os.Stat(exactPath); err == nil && !exactInfo.IsDir() {
return exactPath, filepath.ToSlash(filepath.Join(platformDir, candidateName)), nil
}
}
for _, candidateName := range assetNameCandidates {
rootAssetPath := filepath.Join(root, candidateName)
if rootAssetInfo, err := os.Stat(rootAssetPath); err == nil && !rootAssetInfo.IsDir() {
return rootAssetPath, filepath.ToSlash(candidateName), nil
}
}
assetCandidates := make([]localDriverCandidate, 0, 8)
baseCandidates := make([]localDriverCandidate, 0, 8)
visited := 0
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
visited++
if visited > localDriverDirectoryScanMaxEntries {
return errLocalDriverDirScanLimit
}
if d.IsDir() {
return nil
}
name := strings.TrimSpace(d.Name())
if name == "" {
return nil
}
relative, relErr := filepath.Rel(root, path)
if relErr != nil {
relative = name
}
normalizedRelative := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(relative), "./"))
if normalizedRelative == "" {
normalizedRelative = name
}
normalizedLower := strings.ToLower(normalizedRelative)
platformPrefix := strings.ToLower(platformDir) + "/"
inPlatformDir := normalizedLower == strings.ToLower(platformDir) || strings.HasPrefix(normalizedLower, platformPrefix)
depth := strings.Count(normalizedRelative, "/")
candidate := localDriverCandidate{
absPath: path,
relativePath: normalizedRelative,
depth: depth,
inPlatformDir: inPlatformDir,
}
for _, candidateName := range assetNameCandidates {
if strings.EqualFold(name, candidateName) {
assetCandidates = append(assetCandidates, candidate)
return nil
}
}
for _, candidateName := range baseNameCandidates {
if strings.EqualFold(name, candidateName) {
baseCandidates = append(baseCandidates, candidate)
return nil
}
}
return nil
})
if errors.Is(walkErr, errLocalDriverDirScanLimit) {
return "", "", fmt.Errorf("本地驱动目录条目过多(超过 %d请缩小目录范围或直接选择 zip/单文件", localDriverDirectoryScanMaxEntries)
}
if walkErr != nil {
return "", "", fmt.Errorf("扫描本地驱动目录失败:%w", walkErr)
}
selectBest := func(candidates []localDriverCandidate) (localDriverCandidate, bool) {
if len(candidates) == 0 {
return localDriverCandidate{}, false
}
sort.Slice(candidates, func(i, j int) bool {
left := candidates[i]
right := candidates[j]
if left.inPlatformDir != right.inPlatformDir {
return left.inPlatformDir
}
if left.depth != right.depth {
return left.depth < right.depth
}
leftRelative := strings.ToLower(left.relativePath)
rightRelative := strings.ToLower(right.relativePath)
if leftRelative != rightRelative {
return leftRelative < rightRelative
}
return strings.ToLower(left.absPath) < strings.ToLower(right.absPath)
})
return candidates[0], true
}
if candidate, ok := selectBest(assetCandidates); ok {
return candidate.absPath, candidate.relativePath, nil
}
if candidate, ok := selectBest(baseCandidates); ok {
return candidate.absPath, candidate.relativePath, nil
}
return "", "", fmt.Errorf(
"目录中未找到 %s 代理文件(优先路径 %s候选文件名 %s / %s",
displayName,
exactRelativePath,
strings.Join(assetNameCandidates, " | "),
strings.Join(baseNameCandidates, " | "),
)
}
func installOptionalDriverAgentFromLocalZip(zipPath string, definition driverDefinition, executablePath string) (string, error) {
driverType := normalizeDriverType(definition.Type)
displayName := resolveDriverDisplayName(definition)
@@ -2261,24 +2461,31 @@ func installOptionalDriverAgentFromLocalZip(zipPath string, definition driverDef
defer reader.Close()
entryPath := optionalDriverBundleEntryPath(driverType)
expectedBaseName := optionalDriverReleaseAssetName(driverType)
entryPaths := optionalDriverBundleEntryPaths(driverType)
expectedBaseNames := optionalDriverReleaseAssetNames(driverType)
findEntry := func() *zip.File {
for _, file := range reader.File {
name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./"))
if name == entryPath {
return file
for _, expectedPath := range entryPaths {
if name == expectedPath {
return file
}
}
}
for _, file := range reader.File {
name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./"))
if strings.EqualFold(name, entryPath) {
return file
for _, expectedPath := range entryPaths {
if strings.EqualFold(name, expectedPath) {
return file
}
}
}
for _, file := range reader.File {
name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./"))
if strings.EqualFold(filepath.Base(name), expectedBaseName) {
return file
for _, expectedName := range expectedBaseNames {
if strings.EqualFold(filepath.Base(name), expectedName) {
return file
}
}
}
return nil
@@ -2472,24 +2679,31 @@ func downloadOptionalDriverAgentFromBundle(a *App, definition driverDefinition,
defer reader.Close()
entryPath := optionalDriverBundleEntryPath(driverType)
expectedBaseName := optionalDriverReleaseAssetName(driverType)
entryPaths := optionalDriverBundleEntryPaths(driverType)
expectedBaseNames := optionalDriverReleaseAssetNames(driverType)
findEntry := func() *zip.File {
for _, file := range reader.File {
name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./"))
if name == entryPath {
return file
for _, expectedPath := range entryPaths {
if name == expectedPath {
return file
}
}
}
for _, file := range reader.File {
name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./"))
if strings.EqualFold(name, entryPath) {
return file
for _, expectedPath := range entryPaths {
if strings.EqualFold(name, expectedPath) {
return file
}
}
}
for _, file := range reader.File {
name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./"))
if strings.EqualFold(filepath.Base(name), expectedBaseName) {
return file
for _, expectedName := range expectedBaseNames {
if strings.EqualFold(filepath.Base(name), expectedName) {
return file
}
}
}
return nil
@@ -2640,22 +2854,93 @@ func fileExists(path string) bool {
return err == nil && !info.IsDir()
}
func optionalDriverExecutableBaseName(driverType string) string {
name := fmt.Sprintf("%s-driver-agent", normalizeDriverType(driverType))
func optionalDriverPublicTypeName(driverType string) string {
switch normalizeDriverType(driverType) {
case "diros":
return "doris"
default:
return normalizeDriverType(driverType)
}
}
func optionalDriverExecutableBaseNameForType(typeName string) string {
base := strings.TrimSpace(typeName)
if base == "" {
base = "unknown"
}
name := fmt.Sprintf("%s-driver-agent", base)
if stdRuntime.GOOS == "windows" {
return name + ".exe"
}
return name
}
func optionalDriverReleaseAssetName(driverType string) string {
name := fmt.Sprintf("%s-driver-agent-%s-%s", normalizeDriverType(driverType), stdRuntime.GOOS, stdRuntime.GOARCH)
if stdRuntime.GOOS == "windows" {
func optionalDriverReleaseAssetNameForType(typeName string, goos string, goarch string) string {
base := strings.TrimSpace(typeName)
if base == "" {
base = "unknown"
}
name := fmt.Sprintf("%s-driver-agent-%s-%s", base, goos, goarch)
if strings.EqualFold(goos, "windows") {
return name + ".exe"
}
return name
}
func optionalDriverExecutableBaseNames(driverType string) []string {
names := make([]string, 0, 2)
seen := make(map[string]struct{}, 2)
appendName := func(typeName string) {
name := optionalDriverExecutableBaseNameForType(typeName)
if strings.TrimSpace(name) == "" {
return
}
if _, ok := seen[name]; ok {
return
}
seen[name] = struct{}{}
names = append(names, name)
}
appendName(optionalDriverPublicTypeName(driverType))
return names
}
func optionalDriverReleaseAssetNames(driverType string) []string {
names := make([]string, 0, 2)
seen := make(map[string]struct{}, 2)
appendName := func(typeName string) {
name := optionalDriverReleaseAssetNameForType(typeName, stdRuntime.GOOS, stdRuntime.GOARCH)
if strings.TrimSpace(name) == "" {
return
}
if _, ok := seen[name]; ok {
return
}
seen[name] = struct{}{}
names = append(names, name)
}
appendName(optionalDriverPublicTypeName(driverType))
return names
}
func optionalDriverExecutableBaseName(driverType string) string {
names := optionalDriverExecutableBaseNames(driverType)
if len(names) == 0 {
return optionalDriverExecutableBaseNameForType("")
}
return names[0]
}
func optionalDriverReleaseAssetName(driverType string) string {
names := optionalDriverReleaseAssetNames(driverType)
if len(names) == 0 {
return optionalDriverReleaseAssetNameForType("", stdRuntime.GOOS, stdRuntime.GOARCH)
}
return names[0]
}
func optionalDriverBundlePlatformDir(goos string) string {
switch strings.ToLower(strings.TrimSpace(goos)) {
case "windows":
@@ -2669,8 +2954,41 @@ func optionalDriverBundlePlatformDir(goos string) string {
}
}
func optionalDriverBundleEntryPaths(driverType string) []string {
platformDir := optionalDriverBundlePlatformDir(stdRuntime.GOOS)
assetNames := optionalDriverReleaseAssetNames(driverType)
result := make([]string, 0, len(assetNames))
seen := make(map[string]struct{}, len(assetNames))
for _, assetName := range assetNames {
entry := filepath.ToSlash(filepath.Join(platformDir, assetName))
if _, ok := seen[entry]; ok {
continue
}
seen[entry] = struct{}{}
result = append(result, entry)
}
return result
}
func optionalDriverBundleEntryPath(driverType string) string {
return filepath.ToSlash(filepath.Join(optionalDriverBundlePlatformDir(stdRuntime.GOOS), optionalDriverReleaseAssetName(driverType)))
paths := optionalDriverBundleEntryPaths(driverType)
if len(paths) == 0 {
return filepath.ToSlash(filepath.Join(optionalDriverBundlePlatformDir(stdRuntime.GOOS), optionalDriverReleaseAssetName(driverType)))
}
return paths[0]
}
func resolveOptionalDriverAssetSize(sizeByAsset map[string]int64, driverType string) int64 {
if len(sizeByAsset) == 0 {
return 0
}
for _, assetName := range optionalDriverReleaseAssetNames(driverType) {
sizeBytes := sizeByAsset[assetName]
if sizeBytes > 0 {
return sizeBytes
}
}
return 0
}
func resolveOptionalDriverBundleDownloadURLs() []string {
@@ -2719,12 +3037,16 @@ func resolveOptionalDriverAgentDownloadURLs(definition driverDefinition, rawURL
}
}
assetName := optionalDriverReleaseAssetName(driverType)
assetNames := optionalDriverReleaseAssetNames(driverType)
currentVersion := normalizeVersion(getCurrentVersion())
if currentVersion != "" && currentVersion != "0.0.0" {
appendURL(fmt.Sprintf("https://github.com/Syngnat/GoNavi/releases/download/v%s/%s", currentVersion, assetName))
for _, assetName := range assetNames {
appendURL(fmt.Sprintf("https://github.com/Syngnat/GoNavi/releases/download/v%s/%s", currentVersion, assetName))
}
}
for _, assetName := range assetNames {
appendURL(fmt.Sprintf("https://github.com/Syngnat/GoNavi/releases/latest/download/%s", assetName))
}
appendURL(fmt.Sprintf("https://github.com/Syngnat/GoNavi/releases/latest/download/%s", assetName))
return candidates
}
@@ -2753,8 +3075,23 @@ func findExistingOptionalDriverAgentCandidate(definition driverDefinition, targe
func resolveOptionalDriverAgentCandidatePaths(definition driverDefinition) []string {
driverType := normalizeDriverType(definition.Type)
name := optionalDriverExecutableBaseName(driverType)
assetName := optionalDriverReleaseAssetName(driverType)
names := optionalDriverExecutableBaseNames(driverType)
assetNames := optionalDriverReleaseAssetNames(driverType)
pathTypeNames := make([]string, 0, 2)
seenPathType := make(map[string]struct{}, 2)
appendPathType := func(typeName string) {
trimmed := strings.TrimSpace(typeName)
if trimmed == "" {
return
}
if _, ok := seenPathType[trimmed]; ok {
return
}
seenPathType[trimmed] = struct{}{}
pathTypeNames = append(pathTypeNames, trimmed)
}
appendPathType(optionalDriverPublicTypeName(driverType))
candidates := make([]string, 0, 12)
appendPath := func(pathText string) {
trimmed := strings.TrimSpace(pathText)
@@ -2769,18 +3106,36 @@ func resolveOptionalDriverAgentCandidatePaths(definition driverDefinition) []str
resolved = evalPath
}
exeDir := filepath.Dir(resolved)
appendPath(filepath.Join(exeDir, name))
appendPath(filepath.Join(exeDir, assetName))
appendPath(filepath.Join(exeDir, "drivers", driverType, name))
appendPath(filepath.Join(exeDir, "drivers", driverType, assetName))
for _, name := range names {
appendPath(filepath.Join(exeDir, name))
}
for _, assetName := range assetNames {
appendPath(filepath.Join(exeDir, assetName))
}
for _, typeName := range pathTypeNames {
for _, name := range names {
appendPath(filepath.Join(exeDir, "drivers", typeName, name))
}
for _, assetName := range assetNames {
appendPath(filepath.Join(exeDir, "drivers", typeName, assetName))
}
}
resourcesDir := filepath.Clean(filepath.Join(exeDir, "..", "Resources"))
appendPath(filepath.Join(resourcesDir, "drivers", driverType, name))
appendPath(filepath.Join(resourcesDir, "drivers", driverType, assetName))
for _, typeName := range pathTypeNames {
for _, name := range names {
appendPath(filepath.Join(resourcesDir, "drivers", typeName, name))
}
for _, assetName := range assetNames {
appendPath(filepath.Join(resourcesDir, "drivers", typeName, assetName))
}
}
}
if wd, err := os.Getwd(); err == nil && strings.TrimSpace(wd) != "" {
appendPath(filepath.Join(wd, "dist", assetName))
appendPath(filepath.Join(wd, assetName))
for _, assetName := range assetNames {
appendPath(filepath.Join(wd, "dist", assetName))
appendPath(filepath.Join(wd, assetName))
}
}
unique := make([]string, 0, len(candidates))
@@ -2896,8 +3251,7 @@ func preloadOptionalDriverPackageSizes(definitions []driverDefinition) map[strin
fillFromSizes := func(sizeByAsset map[string]int64, driverTypes []string) []string {
missing := make([]string, 0, len(driverTypes))
for _, driverType := range driverTypes {
assetName := optionalDriverReleaseAssetName(driverType)
sizeBytes := sizeByAsset[assetName]
sizeBytes := resolveOptionalDriverAssetSize(sizeByAsset, driverType)
if sizeBytes > 0 {
result[driverType] = sizeBytes
continue

View File

@@ -1291,7 +1291,7 @@ func dumpTableSQL(
createSQL = ddl
}
} else {
ddl, err := dbInst.GetCreateStatement(schemaName, pureTableName)
ddl, err := resolveCreateStatementWithFallback(dbInst, config, dbName, tableName)
if err != nil {
if viewDDL, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName); ok {
createSQL = viewDDL

View File

@@ -4,6 +4,9 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"sync"
@@ -107,14 +110,20 @@ func (a *App) RedisTestConnection(config connection.ConnectionConfig) connection
}
// RedisScanKeys scans keys matching a pattern
func (a *App) RedisScanKeys(config connection.ConnectionConfig, pattern string, cursor uint64, count int64) connection.QueryResult {
func (a *App) RedisScanKeys(config connection.ConnectionConfig, pattern string, cursor any, count int64) connection.QueryResult {
config.Type = "redis"
client, err := a.getRedisClient(config)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
result, err := client.ScanKeys(pattern, cursor, count)
parsedCursor, err := parseRedisScanCursor(cursor)
if err != nil {
logger.Warnf("RedisScanKeys 游标解析失败已回退到起始游标cursor=%v err=%v", cursor, err)
parsedCursor = 0
}
result, err := client.ScanKeys(pattern, parsedCursor, count)
if err != nil {
logger.Error(err, "RedisScanKeys 扫描失败pattern=%s", pattern)
return connection.QueryResult{Success: false, Message: err.Error()}
@@ -123,6 +132,82 @@ func (a *App) RedisScanKeys(config connection.ConnectionConfig, pattern string,
return connection.QueryResult{Success: true, Data: result}
}
func parseRedisScanCursor(cursor any) (uint64, error) {
switch v := cursor.(type) {
case nil:
return 0, nil
case uint64:
return v, nil
case uint32:
return uint64(v), nil
case uint16:
return uint64(v), nil
case uint8:
return uint64(v), nil
case uint:
return uint64(v), nil
case int64:
if v < 0 {
return 0, fmt.Errorf("游标不能为负数: %d", v)
}
return uint64(v), nil
case int32:
if v < 0 {
return 0, fmt.Errorf("游标不能为负数: %d", v)
}
return uint64(v), nil
case int16:
if v < 0 {
return 0, fmt.Errorf("游标不能为负数: %d", v)
}
return uint64(v), nil
case int8:
if v < 0 {
return 0, fmt.Errorf("游标不能为负数: %d", v)
}
return uint64(v), nil
case int:
if v < 0 {
return 0, fmt.Errorf("游标不能为负数: %d", v)
}
return uint64(v), nil
case float64:
return parseRedisScanCursorFromFloat(v)
case float32:
return parseRedisScanCursorFromFloat(float64(v))
case json.Number:
return parseRedisScanCursor(strings.TrimSpace(v.String()))
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" {
return 0, nil
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
if err != nil {
return 0, fmt.Errorf("无效游标: %q", v)
}
return parsed, nil
default:
return 0, fmt.Errorf("不支持的游标类型: %T", cursor)
}
}
func parseRedisScanCursorFromFloat(value float64) (uint64, error) {
if math.IsNaN(value) || math.IsInf(value, 0) {
return 0, fmt.Errorf("无效浮点游标: %v", value)
}
if value < 0 {
return 0, fmt.Errorf("游标不能为负数: %v", value)
}
if math.Trunc(value) != value {
return 0, fmt.Errorf("游标必须为整数: %v", value)
}
if value > float64(math.MaxUint64) {
return 0, fmt.Errorf("游标超出范围: %v", value)
}
return uint64(value), nil
}
// RedisGetValue gets the value of a key
func (a *App) RedisGetValue(config connection.ConnectionConfig, key string) connection.QueryResult {
config.Type = "redis"

View File

@@ -0,0 +1,50 @@
package app
import (
"encoding/json"
"testing"
)
func TestParseRedisScanCursor(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
input any
want uint64
wantErr bool
}{
{name: "nil defaults to zero", input: nil, want: 0},
{name: "empty string defaults to zero", input: " ", want: 0},
{name: "string cursor", input: "123", want: 123},
{name: "uint64 cursor", input: uint64(456), want: 456},
{name: "int cursor", input: int(789), want: 789},
{name: "float cursor", input: float64(42), want: 42},
{name: "json number cursor", input: json.Number("88"), want: 88},
{name: "negative int rejected", input: -1, wantErr: true},
{name: "fraction float rejected", input: float64(1.5), wantErr: true},
{name: "invalid string rejected", input: "abc", wantErr: true},
{name: "unsupported type rejected", input: true, wantErr: true},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := parseRedisScanCursor(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got nil (value=%d)", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("parseRedisScanCursor() mismatch, want=%d got=%d", tc.want, got)
}
})
}
}

View File

@@ -17,7 +17,7 @@ import (
"GoNavi-Wails/internal/ssh"
"GoNavi-Wails/internal/utils"
_ "github.com/ClickHouse/clickhouse-go/v2"
clickhouse "github.com/ClickHouse/clickhouse-go/v2"
)
const (
@@ -100,25 +100,20 @@ func applyClickHouseURI(config connection.ConnectionConfig) connection.Connectio
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), "/"),
func (c *ClickHouseDB) buildClickHouseOptions(config connection.ConnectionConfig) *clickhouse.Options {
timeout := getConnectTimeout(config)
return &clickhouse.Options{
Addr: []string{
net.JoinHostPort(config.Host, strconv.Itoa(config.Port)),
},
Auth: clickhouse.Auth{
Database: strings.TrimSpace(config.Database),
Username: strings.TrimSpace(config.User),
Password: config.Password,
},
DialTimeout: timeout,
ReadTimeout: timeout,
}
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 {
@@ -165,11 +160,7 @@ func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error {
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
c.conn = clickhouse.OpenDB(c.buildClickHouseOptions(runConfig))
if err := c.Ping(); err != nil {
_ = c.Close()

View File

@@ -21,7 +21,7 @@ const (
defaultDirosPort = 9030
)
// DirosDB 使用独立 driver 名称diros接入底层协议兼容 MySQL。
// DirosDB 使用独立 driver 名称diros接入底层协议兼容 MySQL(对外显示为 Doris
type DirosDB struct {
MySQLDB
}
@@ -146,7 +146,7 @@ func (d *DirosDB) getDSN(config connection.ConnectionConfig) string {
protocol = netName
address = normalizeMySQLAddress(config.Host, config.Port)
} else {
logger.Warnf("注册 Diros SSH 网络失败,将尝试直连:地址=%s:%d 用户=%s原因%v", config.Host, config.Port, config.User, err)
logger.Warnf("注册 Doris SSH 网络失败,将尝试直连:地址=%s:%d 用户=%s原因%v", config.Host, config.Port, config.User, err)
}
}
@@ -177,7 +177,7 @@ func (d *DirosDB) Connect(config connection.ConnectionConfig) error {
runConfig := applyDirosURI(config)
addresses := collectDirosAddresses(runConfig)
if len(addresses) == 0 {
return fmt.Errorf("连接建立后验证失败:未找到可用的 Diros 地址")
return fmt.Errorf("连接建立后验证失败:未找到可用的 Doris 地址")
}
var errorDetails []string
@@ -214,7 +214,7 @@ func (d *DirosDB) Connect(config connection.ConnectionConfig) error {
}
if len(errorDetails) == 0 {
return fmt.Errorf("连接建立后验证失败:未找到可用的 Diros 地址")
return fmt.Errorf("连接建立后验证失败:未找到可用的 Doris 地址")
}
return fmt.Errorf("连接建立后验证失败:%s", strings.Join(errorDetails, ""))
}

View File

@@ -61,7 +61,7 @@ func driverDisplayName(driverType string) string {
case "mariadb":
return "MariaDB"
case "diros":
return "Diros"
return "Doris"
case "sphinx":
return "Sphinx"
case "postgres":

View File

@@ -5,6 +5,7 @@ package db
import (
"strings"
"testing"
"time"
"GoNavi-Wails/internal/connection"
)
@@ -115,7 +116,7 @@ func TestTDengineDSN_UsesWebSocketFormat(t *testing.T) {
}
}
func TestClickHouseDSN_EscapesPasswordAndSetsTimeout(t *testing.T) {
func TestClickHouseOptions_UsesStructuredTimeoutAndAuth(t *testing.T) {
c := &ClickHouseDB{}
cfg := normalizeClickHouseConfig(connection.ConnectionConfig{
Type: "clickhouse",
@@ -127,17 +128,35 @@ func TestClickHouseDSN_EscapesPasswordAndSetsTimeout(t *testing.T) {
Timeout: 15,
})
dsn := c.getDSN(cfg)
if strings.Contains(dsn, cfg.Password) {
t.Fatalf("dsn 包含原始密码:%s", dsn)
opts := c.buildClickHouseOptions(cfg)
if opts == nil {
t.Fatal("options 为空")
}
if !strings.Contains(dsn, "p%40ss%3Awo%2Frd") {
t.Fatalf("dsn 未正确转义密码%s", dsn)
if len(opts.Addr) != 1 || opts.Addr[0] != "127.0.0.1:9000" {
t.Fatalf("addr 不符合预期%v", opts.Addr)
}
if !strings.Contains(dsn, "dial_timeout=15s") {
t.Fatalf("dsn 缺少 dial_timeout 参数:%s", dsn)
if opts.Auth.Username != "default" {
t.Fatalf("username 不符合预期:%s", opts.Auth.Username)
}
if !strings.Contains(dsn, "/analytics") {
t.Fatalf("dsn 缺少数据库路径:%s", dsn)
if opts.Auth.Password != cfg.Password {
t.Fatalf("password 不符合预期:%s", opts.Auth.Password)
}
if opts.Auth.Database != "analytics" {
t.Fatalf("database 不符合预期:%s", opts.Auth.Database)
}
if opts.DialTimeout != 15*time.Second {
t.Fatalf("dial timeout 不符合预期:%s", opts.DialTimeout)
}
if opts.ReadTimeout != 15*time.Second {
t.Fatalf("read timeout 不符合预期:%s", opts.ReadTimeout)
}
if _, ok := opts.Settings["write_timeout"]; ok {
t.Fatalf("options 不应包含 write_timeout 设置:%v", opts.Settings)
}
if _, ok := opts.Settings["read_timeout"]; ok {
t.Fatalf("options 不应通过 settings 传递 read_timeout%v", opts.Settings)
}
if _, ok := opts.Settings["dial_timeout"]; ok {
t.Fatalf("options 不应通过 settings 传递 dial_timeout%v", opts.Settings)
}
}

View File

@@ -26,7 +26,7 @@ type RedisKeyInfo struct {
// RedisScanResult represents the result of a SCAN operation
type RedisScanResult struct {
Keys []RedisKeyInfo `json:"keys"`
Cursor uint64 `json:"cursor"`
Cursor string `json:"cursor"`
}
// RedisClient defines the interface for Redis operations

View File

@@ -175,7 +175,7 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
return &RedisScanResult{
Keys: r.loadRedisKeyInfos(ctx, keys),
Cursor: currentCursor,
Cursor: strconv.FormatUint(currentCursor, 10),
}, nil
}