Compare commits

..

43 Commits

Author SHA1 Message Date
Syngnat
5a99b5ae4d 合并拉取请求 #559
Release/0.8.0
2026-06-12 23:12:55 +08:00
Syngnat
05ebf3d93f Release/0.7.9 2026-06-02 16:00:51 +08:00
Syngnat
e7379d99a2 Release/0.7.8 2026-05-31 22:50:47 +08:00
Syngnat
d681c44232 Revert "🐛 fix(sidebar): 修复新版左侧分组与 Host 拖拽排序"
This reverts commit e4438780fe.
2026-05-29 08:39:25 +08:00
Syngnat
e4438780fe 🐛 fix(sidebar): 修复新版左侧分组与 Host 拖拽排序
- 新增 sidebarRootOrder 持久化左侧根节点顺序
- 支持分组与未分组 Host 在新版左侧根层混排
- 统一 v2 rail 与树视图拖拽写回根层排序
- 拖拽期间抑制误选中与 Host 误切换
- 补充 Sidebar 与 store 拖拽排序回归测试
2026-05-28 22:34:03 +08:00
Syngnat
b7c5db181a Release/0.7.7 2026-05-28 08:37:07 +08:00
Syngnat
7d1b8b49aa Release/0.7.6 2026-05-18 11:23:57 +08:00
Syngnat
050ba62455 Release/0.7.5 2026-05-15 17:39:01 +08:00
Syngnat
81e7f0a39d Release/0.7.4 2026-05-10 20:56:35 +08:00
Syngnat
6c98e98611 release/0.7.3 2026-04-30 17:42:08 +08:00
Syngnat
ca246725f3 release/0.7.2 2026-04-29 20:08:56 +08:00
Syngnat
2be09c1918 release/0.7.2 2026-04-29 17:34:59 +08:00
Syngnat
9792278fa3 release/0.7.1 2026-04-28 19:38:48 +08:00
Syngnat
bd608cac46 Release/0.7.0 2026-04-26 20:56:39 +08:00
Syngnat
b9ac1ab9b7 合并拉取请求 #396
release/0.6.9
2026-04-17 21:17:38 +08:00
Syngnat
797db8cd36 release/0.6.8 (#372) 2026-04-13 12:51:16 +08:00
Syngnat
bf5a9c3306 合并拉取请求 #362
release/0.6.7
2026-04-12 12:51:23 +08:00
Syngnat
7beb08c960 合并拉取请求 #358
Release/0.6.6
2026-04-11 22:56:29 +08:00
Syngnat
9bbdcea3fd Release/0.6.5 2026-04-01 16:47:42 +08:00
Syngnat
d9cbbc6c31 Release/0.6.4 2026-03-28 11:24:23 +08:00
Syngnat
6ec1072d2e Release/0.6.3 2026-03-20 16:53:11 +08:00
Syngnat
9f2f8b33e8 Release/0.6.3 2026-03-20 16:24:26 +08:00
Syngnat
d984a15508 release/0.6.3 2026-03-20 16:08:38 +08:00
Syngnat
bfa918cb9d Release/v0.6.2 (#263) 2026-03-19 21:18:01 +08:00
Syngnat
4e73f6d8b5 release/0.6.1 2026-03-19 11:59:25 +08:00
Syngnat
2f4e20a34a release/0.6.0 2026-03-18 21:27:31 +08:00
Syngnat
dfabd77615 🐛 fix(ci): 修复 Chocolatey UPX 包不可用导致 Windows 构建失败 2026-03-18 17:30:33 +08:00
Syngnat
4a2dda8aa2 合并拉取请求 #254
release/0.5.9
2026-03-18 17:21:55 +08:00
Syngnat
d1d3fa26f1 🔧 fix(frontend/ci): 移除前端测试对 node:assert 的类型依赖 (#234) 2026-03-13 15:37:16 +08:00
Syngnat
fc8e62b997 release/0.5.8 (#233) 2026-03-13 15:29:53 +08:00
Syngnat
b0eb93bfa3 Release/0.5.7 (#230)
🔧 fix(ci/release-winget): 修复 Node20 弃用告警并强制启用 Node24 运行时

- 在 release-winget workflow 增加 FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true
- 与现有 release/test workflow 的 Node24 配置保持一致
- 避免 actions/checkout、setup-go、setup-node 触发 Node20 弃用告警

🔧 fix(window): 修复Windows启动全屏锁死并补齐标题栏退出全屏逻辑
2026-03-12 19:46:40 +08:00
杨国锋
11b8e0f12a Merge branch 'dev' into release/0.5.7 2026-03-12 19:39:42 +08:00
Syngnat
8c5fee1c7a * 🔧 fix(release/macos): 移除 macOS 打包链路的 UPX 压缩逻辑 2026-03-12 19:08:05 +08:00
杨国锋
ec05f518a9 Merge remote-tracking branch 'origin/main' into release/0.5.7
# Conflicts:
#	.github/workflows/release.yml
#	.github/workflows/test-build-all-platforms.yml
#	build-release.sh
2026-03-12 19:06:48 +08:00
杨国锋
2c9aa640fd Merge branch 'dev' into release/0.5.7 2026-03-12 19:04:20 +08:00
Syngnat
9f7cc58fad Release/0.5.7 (#227)
* 🎨 style(DataGrid): 清理冗余代码与静态分析告警

- 类型重构:通过修正 React Context 的函数签名解决了 void 类型的链式调用错误
- 代码精简:利用 Nullish Coalescing (??) 优化组件配置项降级逻辑,剥离无意义的隐式 undefined 赋值
- 工具链适配:适配 IDE 拼写检查与 Promise strict rules,确保全文件零警

* 🔧 fix(db/kingbase_impl): 修复标识符无条件加双引号导致SQL语法报错

- quoteKingbaseIdent 改为条件引用,仅对大写字母、保留字、特殊字符的标识符添加双引号
- 新增 kingbaseIdentNeedsQuote 判断标识符是否需要引用
- 新增 isKingbaseReservedWord 检测常见SQL保留字
- 补充 TestQuoteKingbaseIdent、TestKingbaseIdentNeedsQuote 单测覆盖各场景
- refs #176

* 🔧 fix(release,db/kingbase_impl): 修复金仓默认 schema 并静默生成 DMG

- Kingbase:在 current_schema() 为 public 时探测候选 schema,并通过 DSN search_path 重连,兼容未限定 schema 的查询
- 候选优先级:数据库名/用户名同名 schema(存在性校验),否则仅在“唯一用户 schema 有表”场景兜底
- 避免连接污染:每次 Connect 重置探测结果,重连成功后替换连接并关闭旧连接
- 打包脚本:create-dmg 增加 --sandbox-safe,避免构建时自动弹出/打开挂载窗口
- 产物格式:强制 --format UDZO,并将 rw.*.dmg/UDRW 中间产物转换为可分发 DMG
- 校验门禁:增加 hdiutil verify,失败时保留 .app 便于排查,同时修正卷图标探测并补 ad-hoc 签名

* 🐛 fix(connection/redis): 修复 Redis URI 用户名处理导致认证失败

- Redis URI 解析回填 user 字段,兼容 redis://user:pass@... 与 redis://:pass@...
- 生成 URI 时按需输出 user/password,避免丢失用户名信息
- Redis 类型默认用户名置空,并在构建配置时清理历史默认 root
- 避免 go-redis 触发 ACL AUTH(user, pass) 导致 WRONGPASS
- refs #212

* 🔧 fix(release,ssh): 修复 SSH 误判连接成功并纠正 DMG 打包结构

- SSH 缓存 key 纳入认证指纹(password/keyPath),避免改错凭证仍复用旧连接/端口转发
- MySQL/MariaDB/Doris:SSH 隧道建立失败直接返回错误,不再回退直连导致测试误判成功
- 新增最小单测覆盖 SSH cache key 与 UseSSH 异常路径
- build-release.sh:create-dmg 使用 staging 目录作为 source,避免 DMG 根目录变成 Contents
- refs #213

* fix: KingBase 连接后自动设置 search_path,修复自定义 schema 下表查询报 relation does not exist 的问题 (#215)

* 🔧 fix(driver/kingbase,mongodb): 修复外置驱动事务引用与连接测试链路问题

- 金仓外置驱动链路增加表名与变更字段归一化,修复 ApplyChanges 场景下双引号转义异常导致的 SQL 语法错误
- 新增金仓公共标识符工具并复用到 kingbase_impl 与 optional_driver_agent_impl,统一处理多重转义、schema.table 拆分与引用规范
- 金仓代理连接后自动探测并设置 search_path,降低查询时必须手写 schema 前缀的概率
- MongoDB 连接参数改为显式 host/hosts 优先,避免被 URI 中 localhost 覆盖;代理链路保留目标地址不再改写为本地地址
- 连接测试增加前后端超时收敛与日志增强,避免长时间转圈;连接错误文案在未启用 TLS 时移除误导性的“SSL”前缀
- 统一日志级别为 INFO/WARN/ERROR,默认日志目录收敛到 ~/.GoNavi/Logs,并补充驱动构建脚本 build-driver-agents.sh

* 🔧 fix(release/sidebar): 统一跨平台UPX压缩并修复PG函数列表查询兼容性

- 构建脚本新增通用 UPX 压缩函数,覆盖 macOS、Linux、Windows 产物
- 本地打包改为强制压缩策略:未安装 upx、压缩失败或校验失败直接终止
- macOS 打包在签名前压缩 .app 主程序并执行 upx -t 校验
- Linux 打包在生成 tar.gz 前压缩可执行文件并执行 upx -t 校验
- GitHub Release 与测试构建流程补齐 macOS/Linux/Windows 的 upx 安装与压缩步骤
- PostgreSQL/PG-like 函数元数据查询增加多路兼容 SQL,修复函数列表不显示问题
- refs #221
- refs #222

* 🔧 fix(release/ci): 修复跨平台UPX兼容并处理Windows ARM64打包失败

- CI 工作流统一启用 Node24 JavaScript 运行时,消除 Node20 退役告警干扰
- macOS 打包阶段为 UPX 增加 --force-macos,修复 Mach-O 压缩失败
- Windows 打包按架构分流:arm64 跳过 UPX 并保留原始 EXE,amd64 继续强制压缩
- Windows 压缩流程新增 $LASTEXITCODE 显式校验,避免命令失败被误判为成功
- 本地 build-release.sh 同步 macOS/Windows 的 UPX 兼容策略与错误处理逻辑

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: 凌封 <49424247+fengin@users.noreply.github.com>
2026-03-12 17:58:05 +08:00
Syngnat
97bf891df3 Merge remote-tracking branch 'origin/main' into release/0.5.7
# Conflicts:
#	.github/workflows/release.yml
#	.github/workflows/test-build-all-platforms.yml
#	build-release.sh
2026-03-12 17:55:17 +08:00
Syngnat
72a9692200 Merge branch 'dev' into release/0.5.7 2026-03-12 17:54:26 +08:00
Syngnat
eaa45f17fd Release/0.5.7 (#226)
* 🎨 style(DataGrid): 清理冗余代码与静态分析告警

- 类型重构:通过修正 React Context 的函数签名解决了 void 类型的链式调用错误
- 代码精简:利用 Nullish Coalescing (??) 优化组件配置项降级逻辑,剥离无意义的隐式 undefined 赋值
- 工具链适配:适配 IDE 拼写检查与 Promise strict rules,确保全文件零警

* 🔧 fix(db/kingbase_impl): 修复标识符无条件加双引号导致SQL语法报错

- quoteKingbaseIdent 改为条件引用,仅对大写字母、保留字、特殊字符的标识符添加双引号
- 新增 kingbaseIdentNeedsQuote 判断标识符是否需要引用
- 新增 isKingbaseReservedWord 检测常见SQL保留字
- 补充 TestQuoteKingbaseIdent、TestKingbaseIdentNeedsQuote 单测覆盖各场景
- refs #176

* 🔧 fix(release,db/kingbase_impl): 修复金仓默认 schema 并静默生成 DMG

- Kingbase:在 current_schema() 为 public 时探测候选 schema,并通过 DSN search_path 重连,兼容未限定 schema 的查询
- 候选优先级:数据库名/用户名同名 schema(存在性校验),否则仅在“唯一用户 schema 有表”场景兜底
- 避免连接污染:每次 Connect 重置探测结果,重连成功后替换连接并关闭旧连接
- 打包脚本:create-dmg 增加 --sandbox-safe,避免构建时自动弹出/打开挂载窗口
- 产物格式:强制 --format UDZO,并将 rw.*.dmg/UDRW 中间产物转换为可分发 DMG
- 校验门禁:增加 hdiutil verify,失败时保留 .app 便于排查,同时修正卷图标探测并补 ad-hoc 签名

* 🐛 fix(connection/redis): 修复 Redis URI 用户名处理导致认证失败

- Redis URI 解析回填 user 字段,兼容 redis://user:pass@... 与 redis://:pass@...
- 生成 URI 时按需输出 user/password,避免丢失用户名信息
- Redis 类型默认用户名置空,并在构建配置时清理历史默认 root
- 避免 go-redis 触发 ACL AUTH(user, pass) 导致 WRONGPASS
- refs #212

* 🔧 fix(release,ssh): 修复 SSH 误判连接成功并纠正 DMG 打包结构

- SSH 缓存 key 纳入认证指纹(password/keyPath),避免改错凭证仍复用旧连接/端口转发
- MySQL/MariaDB/Doris:SSH 隧道建立失败直接返回错误,不再回退直连导致测试误判成功
- 新增最小单测覆盖 SSH cache key 与 UseSSH 异常路径
- build-release.sh:create-dmg 使用 staging 目录作为 source,避免 DMG 根目录变成 Contents
- refs #213

* fix: KingBase 连接后自动设置 search_path,修复自定义 schema 下表查询报 relation does not exist 的问题 (#215)

* 🔧 fix(driver/kingbase,mongodb): 修复外置驱动事务引用与连接测试链路问题

- 金仓外置驱动链路增加表名与变更字段归一化,修复 ApplyChanges 场景下双引号转义异常导致的 SQL 语法错误
- 新增金仓公共标识符工具并复用到 kingbase_impl 与 optional_driver_agent_impl,统一处理多重转义、schema.table 拆分与引用规范
- 金仓代理连接后自动探测并设置 search_path,降低查询时必须手写 schema 前缀的概率
- MongoDB 连接参数改为显式 host/hosts 优先,避免被 URI 中 localhost 覆盖;代理链路保留目标地址不再改写为本地地址
- 连接测试增加前后端超时收敛与日志增强,避免长时间转圈;连接错误文案在未启用 TLS 时移除误导性的“SSL”前缀
- 统一日志级别为 INFO/WARN/ERROR,默认日志目录收敛到 ~/.GoNavi/Logs,并补充驱动构建脚本 build-driver-agents.sh

* 🔧 fix(release/sidebar): 统一跨平台UPX压缩并修复PG函数列表查询兼容性

- 构建脚本新增通用 UPX 压缩函数,覆盖 macOS、Linux、Windows 产物
- 本地打包改为强制压缩策略:未安装 upx、压缩失败或校验失败直接终止
- macOS 打包在签名前压缩 .app 主程序并执行 upx -t 校验
- Linux 打包在生成 tar.gz 前压缩可执行文件并执行 upx -t 校验
- GitHub Release 与测试构建流程补齐 macOS/Linux/Windows 的 upx 安装与压缩步骤
- PostgreSQL/PG-like 函数元数据查询增加多路兼容 SQL,修复函数列表不显示问题
- refs #221
- refs #222

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: 凌封 <49424247+fengin@users.noreply.github.com>
2026-03-12 17:40:35 +08:00
Syngnat
f101a59d32 Merge remote-tracking branch 'origin/main' into release/0.5.7
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/components/ConnectionModal.tsx
#	frontend/src/components/DataGrid.tsx
2026-03-12 17:34:07 +08:00
Syngnat
6ad690cffc release/0.5.6 (#210)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链

- 将 DuckDB 编译链从 MINGW64 切换为 MSYS2 UCRT64
- 修正 Windows AMD64 的 gcc 和 g++ 探测路径
- 增加 DuckDB 编译器版本校验步骤

* 📝 docs(contributing): 补充中英文贡献指南并统一 README 入口

- 新增英文版 CONTRIBUTING.md 作为正式贡献文档
- 新增中文版 CONTRIBUTING.zh-CN.md 作为中文贡献说明
- 调整 README 和 README.zh-CN 的贡献入口指向对应语言文档

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188) (#190)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>

*  feat(release-notes): 支持自动生成 Release 更新说明并区分配置文件命名

* 🔁 chore(sync): 回灌 main 到 dev (#192)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* 🐛 fix(branch-sync): 修复 main 回灌 dev 时 mergeable 异步计算导致漏开自动合并

- 增加 mergeable 状态轮询,避免新建同步 PR 后立即返回 UNKNOWN
- 在合并状态未稳定时输出中文告警与执行摘要
- 保持冲突分支、待计算分支与自动合并分支的处理路径清晰

* 🔁 chore(sync): 回灌 main 到 dev (#195)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

* - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* ♻️ refactor(frontend-sync): 优化桌面交互细节并移除 main 回灌 dev 自动化

- 优化新建连接、主题设置、侧边栏工具区与 SQL 日志的界面表现
- 调整分页、筛选、透明模式与弹窗样式,统一整体交互层次
- 收口外观参数生效逻辑并补齐多组件适配
- 删除 sync-main-to-dev 工作流并同步维护者手动回灌说明

* feat: 统一筛选条件逻辑按钮宽度 (#201)

* 🐛 fix(oracle-query): 修复 Oracle 表数据分页 SQL 兼容问题 refs #196 (#202)

*  feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿

*  feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿
- refs #166

* 🐛 fix(dameng): 修复达梦连接成功后数据库列表为空问题

- 调整达梦数据库列表获取策略,优先回退查询当前 schema 与当前用户
- 保留可见用户与 owner 聚合逻辑,兼容低权限账号场景
- 补充前端空列表提示与后端单元测试,降低排查成本
- close #203

*  feat(data-sync): 扩展跨库迁移链路并优化数据同步交互

- 统一同库同步与跨库迁移入口,补充模式区分与风险提示
- 扩展 ClickHouse 与 PG-like 双向迁移,并新增 PG-like、ClickHouse、TDengine 到 MongoDB 的迁移路由
- 完善 TDengine 目标端建表规划、回归测试与需求追踪文档
- refs #51

* 🐛 fix(connection): 修复新建连接时标签切换导致表单数据丢失

- 在 SSH 标签页测试连接时,基础信息的 host 回退为默认值 localhost
- 在基础信息标签页保存时,SSH 配置丢失
- 保存结果仅包含当前选中标签页的字段
- refs #208

* 🐛 fix(mongodb): 修复单机模式连接副本集实例时地址被替换为内网地址

- getURI 在 topology=single 时未设置 directConnection=true
- 驱动连接目标地址后自动跟随副本集成员发现,切换到 localhost:27017
- 在 mongodb_impl.go 和 mongodb_impl_v1.go 中添加 directConnection=true
- 仅在 topology 非 replica、无 replicaSet、非 SRV 时生效
- refs #205

* 🐛 fix(DataGrid): 修复虚拟滚动模式下右键菜单失效

- 行级和单元格级右键菜单的启用条件互斥,虚拟滚动模式下两者同时失效
- enableLargeResultOptimizedEditing 关闭了内联编辑但未回退启用行级菜单
- 修改 useContextMenuRow 和 enableRowContextMenu 条件,虚拟模式下启用行级菜单
- 更新 dataContextValue 的 useMemo 依赖数组
- refs #209

* 🐛 fix(sqlserver): 修复 SQL Server 查看表数据时分页语法和标识符引用错误

- quoteIdentPart 缺少 sqlserver 分支,标识符使用双引号而非 [bracket]
- buildPaginatedSelectSQL 增加 mssql 别名兜底,避免 dbType 变体导致走 default 分支
- 修复后标识符使用 [bracket],分页使用 OFFSET FETCH NEXT 语法
- refs #204

*  feat(DataGrid): 统一表格右键菜单交互体验

- 彻底移除功能较少的行级右键菜单 ContextMenuRow,统一使用功能更丰富的单元格右键菜单
- 优化虚拟滚动模式和只读模式下的渲染,支持触发单元格右键菜单
- 菜单展示自适应:在只读或不可修改数据的场景下自动隐藏「设置为 NULL」与「填充到选中行」等编辑项
- refs #209

* 🔧 fix(DataGrid): 默认开启虚拟滚动并修复多选单元格高亮失效问题

- 移除根据数据量和列数动态判断是否开启虚拟滚动的阈值限制,改为在表格视图下默认全量开启,彻底解决卡顿问题
- 修复 `updateCellSelection` 在查找坐标节点时硬编码 `td` 选择器的问题,改为精确匹配 `.ant-table-cell`,兼容虚拟滚动时的 `div` 渲染模式
- 修复因透明窗口特性导致的 `transparent !important` 把高亮样式强行覆盖的问题,拔高了多选状态下背景与边框 CSS 的优先级
- 解决单元格内外多重属性嵌套导致的高亮右侧留白现象,使得高亮框完全贴合表格单元格边缘
- 适配主题色响应(暗黑模式使用黄色深色高亮,白昼模式使用默认蓝色高亮)

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: TSS <266256496+Zencok@users.noreply.github.com>
2026-03-10 11:26:02 +08:00
Syngnat
22bd1c4c28 Release/0.5.5 (#207)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链

- 将 DuckDB 编译链从 MINGW64 切换为 MSYS2 UCRT64
- 修正 Windows AMD64 的 gcc 和 g++ 探测路径
- 增加 DuckDB 编译器版本校验步骤

* 📝 docs(contributing): 补充中英文贡献指南并统一 README 入口

- 新增英文版 CONTRIBUTING.md 作为正式贡献文档
- 新增中文版 CONTRIBUTING.zh-CN.md 作为中文贡献说明
- 调整 README 和 README.zh-CN 的贡献入口指向对应语言文档

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188) (#190)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>

*  feat(release-notes): 支持自动生成 Release 更新说明并区分配置文件命名

* 🔁 chore(sync): 回灌 main 到 dev (#192)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* 🐛 fix(branch-sync): 修复 main 回灌 dev 时 mergeable 异步计算导致漏开自动合并

- 增加 mergeable 状态轮询,避免新建同步 PR 后立即返回 UNKNOWN
- 在合并状态未稳定时输出中文告警与执行摘要
- 保持冲突分支、待计算分支与自动合并分支的处理路径清晰

* 🔁 chore(sync): 回灌 main 到 dev (#195)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

* - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* ♻️ refactor(frontend-sync): 优化桌面交互细节并移除 main 回灌 dev 自动化

- 优化新建连接、主题设置、侧边栏工具区与 SQL 日志的界面表现
- 调整分页、筛选、透明模式与弹窗样式,统一整体交互层次
- 收口外观参数生效逻辑并补齐多组件适配
- 删除 sync-main-to-dev 工作流并同步维护者手动回灌说明

* feat: 统一筛选条件逻辑按钮宽度 (#201)

* 🐛 fix(oracle-query): 修复 Oracle 表数据分页 SQL 兼容问题 refs #196 (#202)

*  feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿

*  feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿
- refs #166

* 🐛 fix(dameng): 修复达梦连接成功后数据库列表为空问题

- 调整达梦数据库列表获取策略,优先回退查询当前 schema 与当前用户
- 保留可见用户与 owner 聚合逻辑,兼容低权限账号场景
- 补充前端空列表提示与后端单元测试,降低排查成本
- close #203

*  feat(data-sync): 扩展跨库迁移链路并优化数据同步交互

- 统一同库同步与跨库迁移入口,补充模式区分与风险提示
- 扩展 ClickHouse 与 PG-like 双向迁移,并新增 PG-like、ClickHouse、TDengine 到 MongoDB 的迁移路由
- 完善 TDengine 目标端建表规划、回归测试与需求追踪文档
- refs #51

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: TSS <266256496+Zencok@users.noreply.github.com>
2026-03-09 17:36:52 +08:00
Syngnat
89c81823bc Release/0.5.4 (#199)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链

- 将 DuckDB 编译链从 MINGW64 切换为 MSYS2 UCRT64
- 修正 Windows AMD64 的 gcc 和 g++ 探测路径
- 增加 DuckDB 编译器版本校验步骤

* 📝 docs(contributing): 补充中英文贡献指南并统一 README 入口

- 新增英文版 CONTRIBUTING.md 作为正式贡献文档
- 新增中文版 CONTRIBUTING.zh-CN.md 作为中文贡献说明
- 调整 README 和 README.zh-CN 的贡献入口指向对应语言文档

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188) (#190)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>

*  feat(release-notes): 支持自动生成 Release 更新说明并区分配置文件命名

* 🔁 chore(sync): 回灌 main 到 dev (#192)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* 🐛 fix(branch-sync): 修复 main 回灌 dev 时 mergeable 异步计算导致漏开自动合并

- 增加 mergeable 状态轮询,避免新建同步 PR 后立即返回 UNKNOWN
- 在合并状态未稳定时输出中文告警与执行摘要
- 保持冲突分支、待计算分支与自动合并分支的处理路径清晰

* 🔁 chore(sync): 回灌 main 到 dev (#195)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

* - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* ♻️ refactor(frontend-sync): 优化桌面交互细节并移除 main 回灌 dev 自动化

- 优化新建连接、主题设置、侧边栏工具区与 SQL 日志的界面表现
- 调整分页、筛选、透明模式与弹窗样式,统一整体交互层次
- 收口外观参数生效逻辑并补齐多组件适配
- 删除 sync-main-to-dev 工作流并同步维护者手动回灌说明

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
2026-03-07 17:15:30 +08:00
1539 changed files with 44736 additions and 390319 deletions

View File

@@ -1,18 +0,0 @@
.git
.github
.idea
.ace-tool
.playwright-mcp
.superpowers
.worktrees
bin
build
dist
frontend/dist
node_modules
frontend/node_modules
tools/__pycache__
GoNavi-Wails
GoNavi-Wails.exe
optional-driver-agent
optional-driver-agent.exe

View File

@@ -1,18 +0,0 @@
.git
.github
.idea
.ace-tool
.playwright-mcp
.superpowers
.worktrees
bin
build
dist
frontend/dist
node_modules
frontend/node_modules
tools/__pycache__
GoNavi-Wails
GoNavi-Wails.exe
optional-driver-agent
optional-driver-agent.exe

View File

@@ -25,18 +25,6 @@ jobs:
with:
go-version-file: 'go.mod'
- name: Test release asset contracts
run: |
bash tools/detect-changed-driver-agents.test.sh
bash tools/generate-driver-agent-revisions.test.sh
python3 tools/generate-driver-release-manifest.test.py
python3 tools/generate-update-latest-manifest.test.py
python3 tools/r2-release-retention.test.py
python3 tools/package-driver-release-assets.test.py
python3 tools/legal-release-assets.test.py
python3 tools/windows-release-artifacts.test.py
python3 tools/generate-release-notes.test.py
- name: Setup Node
uses: actions/setup-node@v5
with:
@@ -48,7 +36,7 @@ jobs:
uses: actions/cache@v5
with:
path: frontend/node_modules
key: ${{ runner.os }}-node20-frontend-${{ hashFiles('frontend/package-lock.json', 'frontend/patches/*.patch') }}
key: ${{ runner.os }}-node20-frontend-${{ hashFiles('frontend/package-lock.json') }}
- name: Install Wails
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
@@ -63,9 +51,6 @@ jobs:
node frontend/scripts/wails-frontend-install.mjs
npm --prefix frontend run build
- name: Test frontend virtual scrolling
run: npm --prefix frontend test -- src/components/dataGridVirtualScroll.test.ts src/components/DataGrid.layout.test.tsx
- name: Pack frontend dist
shell: bash
run: tar -cf frontend-dist.tar -C frontend/dist .
@@ -83,12 +68,6 @@ jobs:
runs-on: ubuntu-latest
outputs:
drivers: ${{ steps.detect.outputs.drivers }}
drivers_darwin_amd64: ${{ steps.detect.outputs.drivers_darwin_amd64 }}
drivers_darwin_arm64: ${{ steps.detect.outputs.drivers_darwin_arm64 }}
drivers_windows_amd64: ${{ steps.detect.outputs.drivers_windows_amd64 }}
drivers_windows_arm64: ${{ steps.detect.outputs.drivers_windows_arm64 }}
drivers_linux_amd64: ${{ steps.detect.outputs.drivers_linux_amd64 }}
drivers_linux_arm64: ${{ steps.detect.outputs.drivers_linux_arm64 }}
has_changes: ${{ steps.detect.outputs.has_changes }}
release_source: ${{ steps.detect.outputs.release_source }}
compare_base: ${{ steps.detect.outputs.compare_base }}
@@ -101,12 +80,6 @@ jobs:
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
cache: true
- name: Resolve published driver release source
id: published_source
env:
@@ -176,21 +149,6 @@ jobs:
printf '%s\n' "$merged"
}
platform_output_key() {
case "$1" in
darwin/amd64) echo "darwin_amd64" ;;
darwin/arm64) echo "darwin_arm64" ;;
windows/amd64) echo "windows_amd64" ;;
windows/arm64) echo "windows_arm64" ;;
linux/amd64) echo "linux_amd64" ;;
linux/arm64) echo "linux_arm64" ;;
*)
echo "unknown platform: $1" >&2
return 1
;;
esac
}
BASE_REF="${{ steps.published_source.outputs.source_commit }}"
HAS_MANIFEST="${{ steps.published_source.outputs.has_manifest }}"
MANIFEST_VALID="${{ steps.published_source.outputs.manifest_valid }}"
@@ -215,53 +173,27 @@ jobs:
BASE_REF="all"
fi
echo "🧭 Final driver detection base: $BASE_REF"
FORCE_GLOBAL_DRIVER_BUILDS="$(bash ./tools/should-force-global-driver-builds.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
PLATFORM_OUTPUTS=()
PLATFORM_DIFF_FAILED=false
if [[ "$BASE_REF" != "all" && "$FORCE_GLOBAL_DRIVER_BUILDS" != "true" ]]; then
if [[ "$BASE_REF" != "all" ]]; then
REVISION_DRIVERS=""
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
PLATFORM_KEY="$(platform_output_key "$PLATFORM")"
DIFF_ARGS=(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")
if [[ -n "$DRIVERS" ]]; then
DIFF_ARGS+=(--drivers "$DRIVERS")
fi
PLATFORM_DRIVERS="$("${DIFF_ARGS[@]}")" || {
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do
PLATFORM_DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")" || {
echo "⚠️ 平台 revision 差异对比失败(${PLATFORM}),保守回退为全量驱动重建"
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")"
REVISION_DRIVERS=""
PLATFORM_DIFF_FAILED=true
PLATFORM_OUTPUTS=()
break
}
PLATFORM_OUTPUTS+=("drivers_${PLATFORM_KEY}=${PLATFORM_DRIVERS}")
REVISION_DRIVERS="$(merge_csv "$REVISION_DRIVERS" "$PLATFORM_DRIVERS")"
done
if [[ "$PLATFORM_DIFF_FAILED" == "false" && -n "$REVISION_DRIVERS" ]]; then
if [[ -n "$REVISION_DRIVERS" ]]; then
echo "🧭 Revision diff union drivers: $REVISION_DRIVERS"
DRIVERS="$(merge_csv "$DRIVERS" "$REVISION_DRIVERS")"
fi
fi
if [[ "$BASE_REF" == "all" || "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" || "$PLATFORM_DIFF_FAILED" == "true" ]]; then
PLATFORM_OUTPUTS=()
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
PLATFORM_KEY="$(platform_output_key "$PLATFORM")"
PLATFORM_OUTPUTS+=("drivers_${PLATFORM_KEY}=${DRIVERS}")
done
elif [[ ${#PLATFORM_OUTPUTS[@]} -eq 0 ]]; then
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
PLATFORM_KEY="$(platform_output_key "$PLATFORM")"
PLATFORM_OUTPUTS+=("drivers_${PLATFORM_KEY}=")
done
fi
FORCE_GLOBAL_DRIVER_BUILDS="$(bash ./tools/should-force-global-driver-builds.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
echo "drivers=${DRIVERS}" >> "$GITHUB_OUTPUT"
echo "compare_base=${BASE_REF}" >> "$GITHUB_OUTPUT"
echo "force_global_driver_builds=${FORCE_GLOBAL_DRIVER_BUILDS}" >> "$GITHUB_OUTPUT"
for OUTPUT_LINE in "${PLATFORM_OUTPUTS[@]}"; do
echo "$OUTPUT_LINE" >> "$GITHUB_OUTPUT"
done
if [ -n "$DRIVERS" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
echo "🧭 Changed driver agents: $DRIVERS"
@@ -329,15 +261,6 @@ jobs:
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
- os: ubuntu-22.04-arm
platform: linux/arm64
os_name: Linux
arch_name: Arm64
build_name: gonavi-build-linux-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
- os: ubuntu-24.04
platform: linux/amd64
os_name: Linux
@@ -406,24 +329,6 @@ jobs:
}
& $upxCmd --version
- name: Install WiX Toolset (Windows)
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
$wixVersion = "6.0.2"
$wixTools = Join-Path $env:RUNNER_TEMP "wix-tools"
dotnet tool install wix --tool-path $wixTools --version $wixVersion
if ($LASTEXITCODE -ne 0) {
throw "WiX Toolset 安装失败,退出码: $LASTEXITCODE"
}
$wixTools | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
$env:PATH = "$wixTools;$env:PATH"
wix extension add --global "WixToolset.UI.wixext/$wixVersion"
if ($LASTEXITCODE -ne 0) {
throw "WiX UI 扩展安装失败,退出码: $LASTEXITCODE"
}
wix --version
- name: Install Linux Dependencies
if: contains(matrix.platform, 'linux')
run: |
@@ -441,12 +346,6 @@ jobs:
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
if [ "${{ matrix.arch_name }}" != "Amd64" ]; then
echo "⚠️ Linux arm64 暂不生成 AppImage仅产出 tar.gz"
touch /tmp/skip-appimage
exit 0
fi
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
@@ -549,11 +448,6 @@ jobs:
& "$env:CC" --version
& "$env:CXX" --version
- name: Test Windows Online Updater (Windows AMD64)
if: ${{ matrix.platform == 'windows/amd64' }}
shell: pwsh
run: go test . ./internal/app -run '^(TestShouldEnableWindowsMSISingleInstanceOnlyForInstalledMainGUI|TestPrimaryWindowActivatorQueuesRequestsUntilRuntimeStartup|TestAcquireWindowsMSISingleInstance|TestResolveWindowsUpdateMaintenanceName|TestAcquireWindowsUpdateMaintenance|TestPrepareWindowsUpdateHandoff|TestWindowsPowerShellUpdater|TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix|TestShouldWindowsUpdateLaunchDownloadedAssetDirectly|TestResolveUpdateInstallMode|TestExpectedAssetNameForWindowsInstallMode|TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory|TestValidateUpdatePackageForCurrentInstallMode|TestWindowsUpdateRequiresExplicitCloseConfirmation|TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows|TestInstallUpdateAndRestartMSI|TestFindOtherWindowsUpdateInstances|TestCloseWindowsUpdateInstances|TestInstallUpdateAndRestartClosesOtherTargetInstances|TestBuildWindowsMSIUpdatePowerShellScript|TestBuildWindowsMSILaunchCommand|TestResolveWindowsMSIExecPath)' -count=1 -timeout=3m
# ---- 生成 dev 版本号 ----
- name: Generate Dev Version
id: version
@@ -587,50 +481,47 @@ jobs:
wails build -s -skipbindings -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}"
fi
- name: Select Platform Driver Set
id: platform_drivers
- name: Resolve Platform Driver Revision Diff
id: revision_diff
if: ${{ matrix.build_optional_agents && needs.driver_agents.outputs.has_changes == 'true' }}
shell: bash
run: |
set -euo pipefail
case "${{ matrix.platform }}" in
darwin/amd64)
DRIVERS='${{ needs.driver_agents.outputs.drivers_darwin_amd64 }}'
;;
darwin/arm64)
DRIVERS='${{ needs.driver_agents.outputs.drivers_darwin_arm64 }}'
;;
windows/amd64)
DRIVERS='${{ needs.driver_agents.outputs.drivers_windows_amd64 }}'
;;
windows/arm64)
DRIVERS='${{ needs.driver_agents.outputs.drivers_windows_arm64 }}'
;;
linux/amd64)
DRIVERS='${{ needs.driver_agents.outputs.drivers_linux_amd64 }}'
;;
linux/arm64)
DRIVERS='${{ needs.driver_agents.outputs.drivers_linux_arm64 }}'
;;
*)
echo "unsupported platform: ${{ matrix.platform }}" >&2
exit 1
;;
esac
echo "drivers=${DRIVERS}" >> "$GITHUB_OUTPUT"
BASE_REF="${{ needs.driver_agents.outputs.compare_base }}"
FALLBACK_DRIVERS="${{ needs.driver_agents.outputs.drivers }}"
FORCE_GLOBAL_DRIVER_BUILDS="${{ needs.driver_agents.outputs.force_global_driver_builds }}"
if [[ -z "$BASE_REF" || "$BASE_REF" == "all" || "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" ]]; then
if [[ "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" && -n "$BASE_REF" && "$BASE_REF" != "all" ]]; then
echo "⚠️ 当前提交涉及 driver 构建/发布链路,保留全局驱动重建结果:${FALLBACK_DRIVERS}"
else
echo "⚠️ 当前 driver 检测基线不可做平台 diff回退使用全局检测结果${FALLBACK_DRIVERS}"
fi
echo "drivers=${FALLBACK_DRIVERS}" >> "$GITHUB_OUTPUT"
else
echo "🧭 对比当前平台 revisionbase=${BASE_REF} head=${GITHUB_SHA} platform=${{ matrix.platform }}"
if DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "${{ matrix.platform }}")"; then
echo "🧭 当前平台实际需要重建的 driver agents: ${DRIVERS:-<empty>}"
echo "drivers=${DRIVERS}" >> "$GITHUB_OUTPUT"
else
echo "⚠️ revision 差异对比失败,保守回退为全量重建"
ALL_DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")"
echo "drivers=${ALL_DRIVERS}" >> "$GITHUB_OUTPUT"
fi
fi
DRIVERS="$(sed -n 's/^drivers=//p' "$GITHUB_OUTPUT" | tail -n 1)"
if [[ -n "$DRIVERS" ]]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
echo "🧭 当前平台最终需要重建的 driver agents: ${DRIVERS}"
else
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "🧭 当前平台无需重建 driver agents"
fi
- name: Build Optional Driver Agents
if: ${{ matrix.build_optional_agents && steps.platform_drivers.outputs.has_changes == 'true' }}
if: ${{ matrix.build_optional_agents && steps.revision_diff.outputs.has_changes == 'true' }}
shell: bash
env:
CHANGED_DRIVER_AGENTS: ${{ steps.platform_drivers.outputs.drivers }}
CHANGED_DRIVER_AGENTS: ${{ steps.revision_diff.outputs.drivers }}
run: |
set -euo pipefail
TARGET_PLATFORM="${{ matrix.platform }}"
@@ -646,45 +537,7 @@ jobs:
export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}"
mkdir -p "$GOCACHE"
echo "🧭 可选驱动使用隔离 GOCACHE$GOCACHE"
normalize_driver_token() {
local value
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
case "$value" in
"") return 1 ;;
doris|diros) echo "doris" ;;
open_gauss|open-gauss) echo "opengauss" ;;
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
elastic|elasticsearch) echo "elasticsearch" ;;
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse|trino)
echo "$value"
;;
*)
echo "❌ 不支持的 driver-agent${1:-}" >&2
return 1
;;
esac
}
build_driver_name() {
case "$1" in
doris) echo "diros" ;;
*) echo "$1" ;;
esac
}
declare -a RAW_DRIVERS=()
declare -a DRIVERS=()
IFS=',' read -r -a RAW_DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
for RAW_DRIVER in "${RAW_DRIVERS[@]}"; do
DRIVER="$(normalize_driver_token "$RAW_DRIVER")" || continue
DRIVERS+=("$DRIVER")
done
if [ ${#DRIVERS[@]} -eq 0 ]; then
echo "🧭 没有需要构建的 driver-agent"
exit 0
fi
NORMALIZED_CHANGED_DRIVER_AGENTS="$(IFS=,; echo "${DRIVERS[*]}")"
echo "🧭 归一后的 driver-agent 构建列表:${NORMALIZED_CHANGED_DRIVER_AGENTS}"
IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
@@ -740,7 +593,10 @@ jobs:
}
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$(build_driver_name "$DRIVER")"
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
echo "⚠️ 跳过 DuckDB driver当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64"
continue
@@ -835,12 +691,7 @@ jobs:
bash ./tools/verify-driver-agent-revisions.sh \
--assets-dir drivers \
--platform "$TARGET_PLATFORM" \
--drivers "$NORMALIZED_CHANGED_DRIVER_AGENTS"
mkdir -p driver-provenance
python3 ./tools/generate-driver-release-manifest.py \
--assets-dir drivers \
--provenance-output "driver-provenance/${GOOS}-${GOARCH}.json"
--drivers "$CHANGED_DRIVER_AGENTS"
# macOS Packaging
- name: Package macOS DMG
@@ -856,9 +707,6 @@ jobs:
exit 1
fi
APP_NAME=$(basename "$APP_PATH")
mkdir -p "$APP_NAME/Contents/Resources"
cp ../../LICENSE "$APP_NAME/Contents/Resources/LICENSE"
cp ../../NOTICE "$APP_NAME/Contents/Resources/NOTICE"
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
if [ -z "$APP_BIN" ]; then
@@ -902,16 +750,14 @@ jobs:
mv "$DMG_NAME" "../../$FINAL_NAME"
# Windows Packaging
- name: Package Windows Portable EXE and MSI
- name: Package Windows EXE
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
Set-Location build/bin
$version = "${{ steps.version.outputs.version }}"
$target = "${{ matrix.build_name }}"
$archName = "${{ matrix.arch_name }}"
$portableExeName = "GoNavi-$version-${{ matrix.os_name }}-$archName${{ matrix.artifact_suffix }}-Portable.exe"
$msiName = "GoNavi-$version-${{ matrix.os_name }}-$archName${{ matrix.artifact_suffix }}-Installer.msi"
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
if (Test-Path "$target.exe") {
$finalExe = "$target.exe"
@@ -954,65 +800,8 @@ jobs:
}
}
Write-Host "📦 输出 Windows Portable 可执行文件 $portableExeName..."
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$portableExeName" -Force
$runNumber = [int64]$env:GITHUB_RUN_NUMBER
if ($runNumber -le 0 -or $runNumber -gt 65535) {
throw "GitHub run number 无法映射为 MSI 版本号: $runNumber"
}
$productVersion = "255.0.$runNumber"
$wixArch = if ($archName -eq "Arm64") { "arm64" } else { "x64" }
$componentGuid = if ($archName -eq "Arm64") {
"7F4C7757-329F-42B0-B312-D4B8AD50415E"
} else {
"0BCEE70B-9CF2-449C-9ADE-190188493234"
}
$desktopShortcutComponentGuid = if ($archName -eq "Arm64") {
"3CF7DCD2-2658-432D-8677-AB6BB34A9659"
} else {
"114030DD-DB5F-460A-9CAC-997680E9F938"
}
$licenseFile = (Resolve-Path -LiteralPath "..\\..\\LICENSE").Path
$noticeFile = (Resolve-Path -LiteralPath "..\\..\\NOTICE").Path
$licenseText = Get-Content -LiteralPath $licenseFile -Raw
$escapedLicense = $licenseText.Replace('\', '\\').Replace('{', '\{').Replace('}', '\}')
$escapedLicense = $escapedLicense -replace "`r?`n", "\par`r`n"
$licenseRtf = Join-Path $env:RUNNER_TEMP "GoNavi-License.rtf"
$rtfContent = "{\rtf1\ansi\deff0{\fonttbl{\f0 Segoe UI;}}\f0\fs18 $escapedLicense}"
Set-Content -LiteralPath $licenseRtf -Value $rtfContent -Encoding ascii
$sourceExe = (Resolve-Path -LiteralPath $finalExe).Path
$wxsPath = (Resolve-Path -LiteralPath "..\\windows\\installer.wxs").Path
$iconPath = (Resolve-Path -LiteralPath "..\\windows\\icon.ico").Path
$installMarkerPath = Join-Path $env:RUNNER_TEMP ".gonavi-msi-install"
Set-Content -LiteralPath $installMarkerPath -Value "MSI" -Encoding ascii -NoNewline
$installMarkerPath = (Resolve-Path -LiteralPath $installMarkerPath).Path
$wixIntermediateDir = Join-Path $env:RUNNER_TEMP "wixobj-$archName"
Write-Host "📦 生成 Windows MSI 安装程序 $msiName (version=$productVersion arch=$wixArch)..."
wix build $wxsPath `
-arch $wixArch `
-ext "WixToolset.UI.wixext/6.0.2" `
-d "ProductName=GoNavi" `
-d "ProductVersion=$productVersion" `
-d "UpgradeCode=CDD6BF2F-ED1E-4345-A0AB-DCDB7E15FB23" `
-d "ComponentGuid=$componentGuid" `
-d "DesktopShortcutComponentGuid=$desktopShortcutComponentGuid" `
-d "InstallFolderName=GoNavi" `
-d "RegistryKeyName=GoNavi" `
-d "SourceExe=$sourceExe" `
-d "InstallMarker=$installMarkerPath" `
-d "IconPath=$iconPath" `
-d "LicenseRtf=$licenseRtf" `
-d "LicenseFile=$licenseFile" `
-d "NoticeFile=$noticeFile" `
-pdbtype none `
-intermediatefolder $wixIntermediateDir `
-out "..\\..\\$msiName"
if ($LASTEXITCODE -ne 0) {
throw "WiX MSI 构建失败,退出码: $LASTEXITCODE"
}
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
# Linux Packaging
- name: Package Linux
@@ -1043,7 +832,7 @@ jobs:
fi
echo "📦 正在打包 $TAR_NAME..."
tar -czvf "$TAR_NAME" "$TARGET" -C ../.. LICENSE NOTICE
tar -czvf "$TAR_NAME" "$TARGET"
mv "$TAR_NAME" ../../
if [ -f /tmp/skip-appimage ]; then
@@ -1054,12 +843,9 @@ jobs:
echo "📦 正在生成 AppImage..."
mkdir -p AppDir/usr/bin
mkdir -p AppDir/usr/share/applications
mkdir -p AppDir/usr/share/doc/gonavi
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
cp "$TARGET" AppDir/usr/bin/gonavi
cp ../../LICENSE AppDir/usr/share/doc/gonavi/LICENSE
cp ../../NOTICE AppDir/usr/share/doc/gonavi/NOTICE
printf '%s\n' \
'[Desktop Entry]' \
@@ -1106,11 +892,9 @@ jobs:
path: |
GoNavi-*.dmg
GoNavi-*.exe
GoNavi-*.msi
GoNavi-*.tar.gz
GoNavi-*.AppImage
drivers/**
driver-provenance/**
retention-days: 7
# 汇总所有产物并发布为 Pre-release
@@ -1119,21 +903,11 @@ jobs:
needs:
- build
- driver_agents
# Serialize only the publication stage with stable releases so both
# channels cannot pass the retained-byte preflight concurrently.
concurrency:
group: gonavi-r2-publication
cancel-in-progress: false
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Download All Artifacts
uses: actions/download-artifact@v7
with:
@@ -1141,29 +915,9 @@ jobs:
pattern: dev-build-artifacts-*
merge-multiple: true
- name: Add legal documents
shell: bash
run: install -m 0644 LICENSE NOTICE release-assets/
- name: List Assets
run: ls -R release-assets
- name: Download Previous Driver Manifest
if: needs.driver_agents.outputs.has_changes == 'true' && needs.driver_agents.outputs.release_source != 'all'
env:
DRIVER_RELEASE_SOURCE: ${{ needs.driver_agents.outputs.release_source }}
shell: bash
run: |
set -euo pipefail
manifest_path="$RUNNER_TEMP/previous-driver-manifest.json"
manifest_url="https://github.com/Syngnat/GoNavi-DriverAgents/releases/download/${DRIVER_RELEASE_SOURCE}/GoNavi-DriverAgents-Manifest.json"
if curl --retry 3 --retry-delay 2 --retry-all-errors -fsSL "$manifest_url" -o "$manifest_path"; then
echo "🧭 已下载上一版 driver manifest 作为复用资产 provenance"
else
rm -f "$manifest_path"
echo "⚠️ 上一版 driver manifest 不可用;未重建资产将由 manifest 生成阶段拒绝"
fi
- name: Complete Driver Agent Assets
if: needs.driver_agents.outputs.has_changes == 'true'
env:
@@ -1197,19 +951,11 @@ jobs:
drivers \
../driver-release-assets
MANIFEST_ARGS=(
--assets-dir drivers
python3 ../tools/generate-driver-release-manifest.py \
--assets-dir drivers \
--output ../driver-release-assets/GoNavi-DriverAgents-Manifest.json
)
if [ -d driver-provenance ]; then
MANIFEST_ARGS+=(--provenance driver-provenance)
fi
if [ -s "$RUNNER_TEMP/previous-driver-manifest.json" ]; then
MANIFEST_ARGS+=(--provenance "$RUNNER_TEMP/previous-driver-manifest.json")
fi
python3 ../tools/generate-driver-release-manifest.py "${MANIFEST_ARGS[@]}"
rm -rf drivers driver-provenance
rm -rf drivers
echo "has_driver_assets=true" >> "$GITHUB_OUTPUT"
- name: Generate SHA256SUMS
@@ -1219,9 +965,6 @@ jobs:
FILES=()
while IFS= read -r file; do
if [ -n "$file" ]; then
case "$file" in
SHA256SUMS|latest.json|latest-dev.json) continue ;;
esac
FILES+=("$file")
fi
done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort)
@@ -1256,26 +999,6 @@ jobs:
DEV_VERSION="dev-${SHORT_SHA}"
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
# 静态更新清单dev 通道读
# https://github.com/Syngnat/GoNavi/releases/download/dev-latest/latest-dev.json
- name: Generate static update manifest (latest-dev.json)
shell: bash
run: |
set -euo pipefail
DEV_VERSION="${{ steps.version.outputs.version }}"
python3 tools/generate-update-latest-manifest.py \
--assets-dir release-assets \
--version "$DEV_VERSION" \
--tag dev-latest \
--channel dev \
--name "Dev Build (${DEV_VERSION})" \
--download-base-url "https://download.syngnat.top/gonavi/dev/releases/download" \
--download-tag "$DEV_VERSION" \
--output release-assets/latest-dev.json
test -s release-assets/latest-dev.json
echo "📄 latest-dev.json ready:"
head -n 40 release-assets/latest-dev.json
- name: Format Build Time
id: build_time
shell: bash
@@ -1430,249 +1153,3 @@ jobs:
> 每次 push 到 `dev` 分支会自动覆盖此 release。
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Mirror, promote, and retain latest dev release in Cloudflare R2
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
DEV_VERSION: ${{ steps.version.outputs.version }}
HAS_DRIVER_ASSETS: ${{ steps.driver_assets.outputs.has_driver_assets }}
R2_BUCKET: gonavi-downloads
shell: bash
run: |
set -euo pipefail
if [[ -z "${CLOUDFLARE_ACCOUNT_ID}" || -z "${AWS_ACCESS_KEY_ID}" || -z "${AWS_SECRET_ACCESS_KEY}" ]]; then
echo "Cloudflare R2 release credentials are not configured" >&2
exit 1
fi
endpoint="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"
app_dir="release-assets"
driver_dir="driver-release-assets"
app_prefix="gonavi/dev/releases/download/${DEV_VERSION}"
driver_prefix="drivers/dev/releases/download/${DEV_VERSION}"
jq -e \
--arg version "${DEV_VERSION}" \
--arg mirror_base "https://download.syngnat.top/gonavi/dev/releases/download" \
--arg github_base "https://github.com/${GITHUB_REPOSITORY}/releases/download/dev-latest" \
'
.channel == "dev"
and .tagName == "dev-latest"
and .version == $version
and (.assets | type == "array" and length > 0)
and all(.assets[];
(.url | type == "string" and startswith($mirror_base + "/" + $version + "/"))
and (.apiUrl | type == "string" and startswith($github_base + "/"))
and (.sha256 | type == "string" and test("^[0-9a-fA-F]{64}$"))
)
' "${app_dir}/latest-dev.json" >/dev/null
driver_latest_file="${RUNNER_TEMP}/driver-dev-latest-index.json"
if [[ "${HAS_DRIVER_ASSETS}" == "true" ]]; then
[[ -s "${driver_dir}/SHA256SUMS" ]] || {
echo "DriverAgents SHA256SUMS is missing" >&2
exit 1
}
(cd "${driver_dir}" && sha256sum --check SHA256SUMS)
jq \
--arg tag "dev-latest" \
--arg mirror_tag "${DEV_VERSION}" \
'.tagName = $tag | .mirrorTagName = $mirror_tag | del(.assets["GoNavi-DriverAgents.zip"])' \
"${driver_dir}/GoNavi-DriverAgents-Index.json" \
> "${driver_latest_file}"
jq -e '.assets | type == "object" and length > 0' "${driver_latest_file}" >/dev/null
else
current_driver_state="${RUNNER_TEMP}/r2-current-dev-driver-state.json"
if python3 tools/r2-release-retention.py validate-driver-pointer \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--channel dev > "${current_driver_state}"; then
current_driver_keep_prefix="$(jq -r '.keepPrefix' "${current_driver_state}")"
python3 tools/r2-release-retention.py prune \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--root-prefix "drivers/dev/releases/download/" \
--keep-prefix "${current_driver_keep_prefix}"
else
echo "::warning::Current dev driver pointer could not be fully validated; preserving every dev driver object"
fi
fi
app_manifest_size="$(stat -c '%s' "${app_dir}/latest-dev.json")"
planned_bytes="$(jq '[.assets[].size] | add // 0' "${app_dir}/latest-dev.json")"
planned_bytes="$((planned_bytes + app_manifest_size * 2))"
budget_args=(
measure
--bucket "${R2_BUCKET}"
--endpoint-url "${endpoint}"
--prefix "gonavi/"
--prefix "drivers/"
--exclude-prefix "gonavi/dev/releases/download/"
--exclude-prefix "gonavi/dev/releases/latest/"
)
if [[ -s "${driver_latest_file}" ]]; then
driver_index_size="$(stat -c '%s' "${driver_latest_file}")"
driver_asset_bytes="$(jq '[.assets[]] | add // 0' "${driver_latest_file}")"
planned_bytes="$((planned_bytes + driver_asset_bytes + driver_index_size * 2))"
budget_args+=(
--exclude-prefix "drivers/dev/releases/download/"
--exclude-prefix "drivers/dev/releases/latest/"
)
fi
python3 tools/r2-release-retention.py "${budget_args[@]}" \
--add-bytes "${planned_bytes}" \
--max-bytes 9000000000
upload_immutable() {
local file="$1"
local key="$2"
local content_type="${3:-}"
local sha size head_json remote_sha remote_size
sha="$(sha256sum "${file}" | awk '{print $1}')"
size="$(stat -c '%s' "${file}")"
if head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json 2>&1)"; then
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
echo "Refusing to overwrite immutable R2 object ${key}" >&2
exit 1
fi
return
elif ! grep -Eq '\(404\)|Not Found|NoSuchKey' <<<"${head_json}"; then
echo "Unable to inspect immutable R2 object ${key}: ${head_json}" >&2
exit 1
fi
args=(s3 cp "${file}" "s3://${R2_BUCKET}/${key}" --endpoint-url "${endpoint}" --cache-control "public,max-age=31536000,immutable" --metadata "sha256=${sha}" --only-show-errors)
if [[ -n "${content_type}" ]]; then
args+=(--content-type "${content_type}")
fi
aws "${args[@]}"
head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json)"
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
echo "R2 verification failed for ${key}" >&2
exit 1
fi
}
while IFS=$'\t' read -r name expected_size expected_sha; do
[[ "${name}" == "$(basename "${name}")" ]] || {
echo "Invalid dev app asset name: ${name}" >&2
exit 1
}
file="${app_dir}/${name}"
[[ -f "${file}" ]] || {
echo "Dev app asset is missing: ${name}" >&2
exit 1
}
actual_size="$(stat -c '%s' "${file}")"
actual_sha="$(sha256sum "${file}" | awk '{print $1}')"
if [[ "${actual_size}" != "${expected_size}" || "${actual_sha}" != "${expected_sha,,}" ]]; then
echo "Dev app asset does not match latest-dev.json: ${name}" >&2
exit 1
fi
upload_immutable "${file}" "${app_prefix}/${name}"
done < <(jq -r '.assets[] | [.name, (.size | tostring), .sha256] | @tsv' "${app_dir}/latest-dev.json")
upload_immutable \
"${app_dir}/latest-dev.json" \
"${app_prefix}/latest-dev.json" \
"application/json; charset=utf-8"
if [[ "${HAS_DRIVER_ASSETS}" == "true" ]]; then
while IFS=$'\t' read -r name expected_size; do
[[ "${name}" == "$(basename "${name}")" ]] || {
echo "Invalid dev driver asset name: ${name}" >&2
exit 1
}
file="${driver_dir}/${name}"
[[ -f "${file}" ]] || {
echo "Dev driver asset is missing: ${name}" >&2
exit 1
}
actual_size="$(stat -c '%s' "${file}")"
if [[ "${actual_size}" != "${expected_size}" ]]; then
echo "Dev driver asset size does not match index: ${name}" >&2
exit 1
fi
upload_immutable "${file}" "${driver_prefix}/${name}"
done < <(jq -r '.assets | to_entries[] | [.key, (.value | tostring)] | @tsv' "${driver_latest_file}")
upload_immutable \
"${driver_latest_file}" \
"${driver_prefix}/GoNavi-DriverAgents-Index.json" \
"application/json; charset=utf-8"
fi
promote_mutable() {
local file="$1"
local key="$2"
local sha size head_json remote_sha remote_size
sha="$(sha256sum "${file}" | awk '{print $1}')"
size="$(stat -c '%s' "${file}")"
aws s3 cp "${file}" "s3://${R2_BUCKET}/${key}" \
--endpoint-url "${endpoint}" \
--cache-control "no-cache,must-revalidate" \
--content-type "application/json; charset=utf-8" \
--metadata "sha256=${sha}" \
--only-show-errors
head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json)"
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
echo "R2 mutable object verification failed for ${key}" >&2
exit 1
fi
}
app_latest_key="gonavi/dev/releases/latest/latest-dev.json"
promote_mutable "${app_dir}/latest-dev.json" "${app_latest_key}"
curl --fail --silent --show-error \
"https://download.syngnat.top/${app_latest_key}?verify=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
| jq -e --arg version "${DEV_VERSION}" '.channel == "dev" and .version == $version' >/dev/null
if [[ -s "${driver_latest_file}" ]]; then
driver_latest_key="drivers/dev/releases/latest/GoNavi-DriverAgents-Index.json"
promote_mutable "${driver_latest_file}" "${driver_latest_key}"
curl --fail --silent --show-error \
"https://download.syngnat.top/${driver_latest_key}?verify=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
| jq -e \
--arg mirror_tag "${DEV_VERSION}" \
'.tagName == "dev-latest" and .mirrorTagName == $mirror_tag' >/dev/null
fi
python3 tools/r2-release-retention.py prune \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--root-prefix "gonavi/dev/releases/download/" \
--keep-prefix "${app_prefix}/"
driver_keep_prefix=""
if [[ -s "${driver_latest_file}" ]]; then
driver_keep_prefix="${driver_prefix}/"
else
current_driver_state="${RUNNER_TEMP}/r2-current-dev-driver-state.json"
if python3 tools/r2-release-retention.py validate-driver-pointer \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--channel dev > "${current_driver_state}"; then
driver_keep_prefix="$(jq -r '.keepPrefix' "${current_driver_state}")"
else
echo "::warning::Skipping dev driver cleanup because the current R2 pointer or retained assets are invalid"
fi
fi
if [[ -n "${driver_keep_prefix}" ]]; then
python3 tools/r2-release-retention.py prune \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--root-prefix "drivers/dev/releases/download/" \
--keep-prefix "${driver_keep_prefix}"
fi
python3 tools/r2-release-retention.py measure \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--prefix "gonavi/" \
--prefix "drivers/" \
--max-bytes 9000000000

View File

@@ -1,260 +0,0 @@
name: Docker Images
on:
push:
branches:
- dev
tags:
- 'v*'
concurrency:
group: docker-images-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
jobs:
build:
name: Build ${{ matrix.image_name }} (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
image_name:
- gonavi-mcp-server
- gonavi-web-server
- gonavi-build-env
platform:
- linux/amd64
- linux/arm64
include:
- image_name: gonavi-mcp-server
dockerfile: Dockerfile.mcp-server
description: GoNavi MCP Server container image
- image_name: gonavi-web-server
dockerfile: Dockerfile.web-server
description: GoNavi Web Server (browser UI) container image
- image_name: gonavi-build-env
dockerfile: Dockerfile.build-env
description: GoNavi Linux build environment image
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Validate runtime Dockerfiles
if: matrix.image_name == 'gonavi-web-server'
shell: bash
run: |
set -euo pipefail
bash tools/validate-web-server-dockerfile.test.sh
env_file="$(mktemp)"
trap 'rm -f "$env_file"' EXIT
printf 'GONAVI_HOST_DATA_ROOT=%s\nGONAVI_WEB_PASSWORD=123456\n' "${RUNNER_TEMP}/gonavi-data" >"$env_file"
rendered="$(docker compose --env-file "$env_file" -f docker-compose.web-server.yml config)"
grep -Eq "GONAVI_WEB_PASSWORD: ['\"]?123456['\"]?$" <<<"$rendered"
- name: Prepare build variables
id: prep
shell: bash
run: |
set -euo pipefail
echo "owner_lc=$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
echo "platform_pair=$(printf '%s' "${{ matrix.platform }}" | tr '/' '-')" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ steps.prep.outputs.owner_lc }}/${{ matrix.image_name }}
labels: |
org.opencontainers.image.title=${{ matrix.image_name }}
org.opencontainers.image.description=${{ matrix.description }}
org.opencontainers.image.licenses=Apache-2.0
- name: Build smoke image
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
load: true
tags: codex-smoke/${{ matrix.image_name }}:local
cache-from: type=gha,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
- name: Smoke test MCP Server image
if: matrix.image_name == 'gonavi-mcp-server'
shell: bash
run: |
set -euo pipefail
cid="$(docker run -d -p 8765:8765 -e GONAVI_MCP_HTTP_TOKEN=smoke-token codex-smoke/${{ matrix.image_name }}:local http)"
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
for _ in 1 2 3 4 5 6 7 8 9 10; do
if curl --fail --silent http://127.0.0.1:8765/healthz >/dev/null; then
exit 0
fi
sleep 1
done
docker logs "$cid"
echo "MCP Server image smoke test failed" >&2
exit 1
- name: Smoke test Web Server image
if: matrix.image_name == 'gonavi-web-server'
shell: bash
run: |
set -euo pipefail
cookie_jar="$(mktemp)"
cid=""
trap 'rm -f "$cookie_jar"; if [[ -n "$cid" ]]; then docker rm -f "$cid" >/dev/null 2>&1 || true; fi' EXIT
cid="$(docker run -d -p 127.0.0.1::34116 -e GONAVI_WEB_ADDR=0.0.0.0:34116 -e GONAVI_WEB_PASSWORD=123456 codex-smoke/${{ matrix.image_name }}:local web-server)"
host_port="$(docker port "$cid" 34116/tcp | awk -F: 'NR == 1 { print $NF }')"
if [[ ! "$host_port" =~ ^[0-9]+$ ]]; then
docker logs "$cid"
echo "Failed to resolve the Web Server smoke-test port" >&2
exit 1
fi
base_url="http://127.0.0.1:${host_port}"
ready=false
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
if curl --fail --silent "${base_url}/__gonavi/healthz" >/dev/null; then
ready=true
break
fi
sleep 1
done
if [[ "$ready" != "true" ]]; then
docker logs "$cid"
echo "Web Server image smoke test failed" >&2
exit 1
fi
status="$(curl --fail --silent --show-error "${base_url}/__gonavi/auth/status")"
grep -Fq '"configured":true' <<<"$status"
login="$(curl --fail --silent --show-error -c "$cookie_jar" -H 'Content-Type: application/json' -d '{"password":"123456","code":""}' "${base_url}/__gonavi/auth/login")"
grep -Fq '"success":true' <<<"$login"
app_status="$(curl --silent --show-error -o /dev/null -w '%{http_code}' -b "$cookie_jar" "${base_url}/")"
[[ "$app_status" == "200" ]]
- name: Smoke test build environment image
if: matrix.image_name == 'gonavi-build-env'
shell: bash
run: |
set -euo pipefail
docker run --rm codex-smoke/${{ matrix.image_name }}:local \
bash -lc 'go version && node -v && npm -v && wails version'
- name: Push by digest
id: push
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ steps.prep.outputs.owner_lc }}/${{ matrix.image_name }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
cache-to: type=gha,mode=max,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
- name: Export digest
shell: bash
run: |
set -euo pipefail
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.push.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Publish ${{ matrix.image_name }}
runs-on: ubuntu-latest
needs: build
strategy:
fail-fast: false
matrix:
include:
- image_name: gonavi-mcp-server
description: GoNavi MCP Server container image
- image_name: gonavi-web-server
description: GoNavi Web Server (browser UI) container image
- image_name: gonavi-build-env
description: GoNavi Linux build environment image
steps:
- name: Normalize image namespace
id: vars
shell: bash
run: |
set -euo pipefail
echo "owner_lc=$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-${{ matrix.image_name }}-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ matrix.image_name }}
flavor: |
latest=false
tags: |
type=raw,value=dev-latest,enable=${{ github.ref == 'refs/heads/dev' }}
type=sha,format=short,prefix=dev-,enable=${{ github.ref == 'refs/heads/dev' }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
labels: |
org.opencontainers.image.title=${{ matrix.image_name }}
org.opencontainers.image.description=${{ matrix.description }}
org.opencontainers.image.licenses=Apache-2.0
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
shell: bash
run: |
set -euo pipefail
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ matrix.image_name }}@sha256:%s ' *)
- name: Inspect image
shell: bash
run: |
set -euo pipefail
docker buildx imagetools inspect "ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ matrix.image_name }}:${{ steps.meta.outputs.version }}"

View File

@@ -1,293 +0,0 @@
# 评论认领:在 Issue 下评论「我想做」等关键词后,自动指派/标记认领人。
# 说明:
# - 仓库协作者write 及以上)可成功写入 Assignees
# - 外部贡献者若无写权限GitHub 不允许设为 assignee此时会打 claimed 标签并回复确认
# - 支持取消认领:取消认领 / /unclaim
name: Issue Claim
on:
issue_comment:
types: [created]
permissions:
issues: write
contents: read
jobs:
claim:
name: Auto claim or unclaim
if: ${{ !github.event.issue.pull_request }}
runs-on: ubuntu-latest
steps:
- name: Process claim / unclaim comment
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const comment = context.payload.comment;
const actor = comment.user.login;
const actorType = comment.user.type;
// 忽略机器人自己的评论,避免循环
if (actorType === 'Bot') {
core.info('Skip bot comment');
return;
}
const raw = (comment.body || '').replace(/\r\n/g, '\n').trim();
if (!raw) {
return;
}
// 仅认领短指令:整段或首行匹配,避免长讨论误触发
const firstLine = raw.split('\n').map((l) => l.trim()).find(Boolean) || '';
const normalize = (s) =>
s
.replace(/^[@/]/g, '')
.replace(/[!。..、,\s]+$/g, '')
.trim()
.toLowerCase();
const claimPhrases = new Set([
'我想做',
'我来做',
'我来',
'认领',
'我认领',
'claim',
'i claim',
'i want this',
'i will take this',
'assign me',
]);
const unclaimPhrases = new Set([
'取消认领',
'不认领了',
'放弃认领',
'unclaim',
'release',
]);
const nFull = normalize(raw);
const nFirst = normalize(firstLine);
// 允许 /claim /unclaim 形式
const slash = firstLine.match(/^\/(claim|unclaim)\b/i);
const isClaim =
claimPhrases.has(nFull) ||
claimPhrases.has(nFirst) ||
(slash && slash[1].toLowerCase() === 'claim');
const isUnclaim =
unclaimPhrases.has(nFull) ||
unclaimPhrases.has(nFirst) ||
(slash && slash[1].toLowerCase() === 'unclaim');
if (!isClaim && !isUnclaim) {
core.info('Comment is not a claim/unclaim command');
return;
}
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = issue.number;
const CLAIMED_LABEL = 'claimed';
async function ensureClaimedLabel() {
try {
await github.rest.issues.getLabel({ owner, repo, name: CLAIMED_LABEL });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({
owner,
repo,
name: CLAIMED_LABEL,
color: '0E8A16',
description: '已被社区认领,进行中',
});
}
}
async function ensureLabels(names) {
const current = new Set((issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name)));
const toAdd = names.filter((n) => !current.has(n));
if (toAdd.length) {
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: toAdd });
}
}
async function removeLabels(names) {
for (const name of names) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number, name });
} catch (e) {
if (e.status !== 404) throw e;
}
}
}
async function tryAssign(login) {
try {
await github.rest.issues.addAssignees({
owner,
repo,
issue_number,
assignees: [login],
});
return { ok: true };
} catch (e) {
const msg = e.message || String(e);
core.warning(`assign failed for ${login}: ${msg}`);
return { ok: false, message: msg };
}
}
async function tryUnassign(login) {
try {
await github.rest.issues.removeAssignees({
owner,
repo,
issue_number,
assignees: [login],
});
return { ok: true };
} catch (e) {
core.warning(`unassign failed for ${login}: ${e.message || e}`);
return { ok: false };
}
}
// 从 issue 正文底部的认领锚点解析当前认领人
const CLAIM_MARKER_RE =
/<!--\s*gonavi-claim\s*:\s*([A-Za-z0-9-]+)\s*-->/;
const body = issue.body || '';
const markerMatch = body.match(CLAIM_MARKER_RE);
const markerClaimer = markerMatch ? markerMatch[1] : null;
const assignees = (issue.assignees || []).map((a) => a.login);
const currentClaimer = assignees[0] || markerClaimer || null;
const hasClaimedLabel = (issue.labels || []).some(
(l) => (typeof l === 'string' ? l : l.name) === CLAIMED_LABEL
);
if (isUnclaim) {
if (!currentClaimer && !hasClaimedLabel) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: ` @${actor} 当前无人认领,无需取消。`,
});
return;
}
if (currentClaimer && currentClaimer.toLowerCase() !== actor.toLowerCase()) {
// 仅认领人本人可取消;维护者可强制取消
const isMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
context.payload.comment.author_association
);
if (!isMaintainer) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: `⚠️ @${actor} 本 Issue 当前由 @${currentClaimer} 认领。请对方评论 \`取消认领\`,或联系维护者。`,
});
return;
}
}
const who = currentClaimer || actor;
await tryUnassign(who);
await removeLabels([CLAIMED_LABEL]);
let newBody = body;
if (CLAIM_MARKER_RE.test(newBody)) {
newBody = newBody
.replace(/\n?<!--\s*gonavi-claim\s*:\s*[A-Za-z0-9-]+\s*-->\s*/g, '\n')
.replace(/\n?### 🔐 认领状态[\s\S]*?(?=\n## |\n# |$)/, '\n')
.trimEnd();
await github.rest.issues.update({ owner, repo, issue_number, body: newBody });
}
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: `🔓 @${actor} 已取消认领${who !== actor ? `(原认领人 @${who}` : ''}。其他人可评论 \`我想做\` 重新认领。`,
});
return;
}
// —— claim ——
if (currentClaimer && currentClaimer.toLowerCase() !== actor.toLowerCase()) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: `⚠️ @${actor} 本 Issue 已由 @${currentClaimer} 认领。\n\n- 若对方长期无进展,可评论提醒,或由维护者 \`取消认领\` 后重开\n- 你也可以先在本线程说明想做的方向,维护者可协助拆分子任务`,
});
return;
}
if (currentClaimer && currentClaimer.toLowerCase() === actor.toLowerCase()) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: `✅ @${actor} 你已经是本 Issue 的认领人,无需重复认领。开干吧 💪`,
});
return;
}
await ensureClaimedLabel();
const assignResult = await tryAssign(actor);
await ensureLabels([CLAIMED_LABEL]);
// 在正文写入可机读认领锚点assignee 失败时仍可防双认领)
const stamp = new Date().toISOString().slice(0, 10);
const claimBlock =
`\n\n### 🔐 认领状态\n\n` +
`- 认领人:@${actor}\n` +
`- 认领日期:${stamp}\n` +
`- 取消方式:评论 \`取消认领\` 或 \`/unclaim\`\n` +
`<!-- gonavi-claim: ${actor} -->\n`;
let newBody = body;
if (CLAIM_MARKER_RE.test(newBody)) {
newBody = newBody.replace(
CLAIM_MARKER_RE,
`<!-- gonavi-claim: ${actor} -->`
);
if (!/### 🔐 认领状态/.test(newBody)) {
newBody = newBody.trimEnd() + claimBlock;
}
} else {
newBody = newBody.trimEnd() + claimBlock;
}
await github.rest.issues.update({ owner, repo, issue_number, body: newBody });
if (assignResult.ok) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body:
`✅ @${actor} 已认领本 Issue并已加入 **Assignees**。\n\n` +
`建议下一步:\n` +
`1. 从 \`dev\` 拉分支:\`fix/*\` 或 \`feature/*\`\n` +
`2. 完成后向 **\`dev\`** 提 PR\n` +
`3. 若暂无法继续,请评论 \`取消认领\`\n\n` +
`贡献指南https://github.com/Syngnat/GoNavi/blob/dev/CONTRIBUTING.zh-CN.md`,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body:
`✅ @${actor} 已认领本 Issue已标记 \`claimed\`)。\n\n` +
`> 说明GitHub 仅允许将**仓库协作者**设为正式 Assignee。你目前可能还没有写权限因此无法写入 Assignees 字段;**社区认领仍然有效**,其他人再评论「我想做」会被拦截。\n\n` +
`建议下一步:\n` +
`1. Fork 仓库,从 \`dev\` 拉分支开发\n` +
`2. 向 **\`dev\`** 提 PR并在 PR 描述里写 \`Fixes #${issue_number}\`\n` +
`3. 若暂无法继续,请评论 \`取消认领\`\n\n` +
`贡献指南https://github.com/Syngnat/GoNavi/blob/dev/CONTRIBUTING.zh-CN.md`,
});
}

View File

@@ -1,532 +0,0 @@
name: Publish Draft Release
on:
push:
branches:
- 'publish/v*'
workflow_dispatch:
inputs:
tag:
description: Release tag to publish (for example, v0.8.7)
required: true
type: string
permissions:
actions: read
contents: write
concurrency:
# Stable and dev publications share the same retained R2 byte budget.
group: gonavi-r2-publication
cancel-in-progress: false
jobs:
publish:
name: Validate, mirror, and publish release
runs-on: ubuntu-latest
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
steps:
- name: Validate release
id: validate
uses: actions/github-script@v8
with:
script: |
const requestedTag = String(process.env.RELEASE_TAG || '').trim();
const tag = requestedTag.startsWith('publish/')
? requestedTag.slice('publish/'.length)
: requestedTag;
if (!/^v\d+\.\d+\.\d+$/.test(tag)) {
core.setFailed(`Invalid release tag: ${tag}`);
return;
}
const { owner, repo } = context.repo;
const tagRef = await github.rest.git.getRef({
owner,
repo,
ref: `tags/${tag}`,
});
let commitSha = tagRef.data.object.sha;
if (tagRef.data.object.type === 'tag') {
const annotatedTag = await github.rest.git.getTag({
owner,
repo,
tag_sha: tagRef.data.object.sha,
});
commitSha = annotatedTag.data.object.sha;
}
const workflowRuns = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'release.yml',
event: 'push',
branch: tag,
per_page: 100,
});
const runs = workflowRuns.data.workflow_runs;
const successfulRun = runs.find((run) => (
run.head_sha === commitSha
&& run.status === 'completed'
&& run.conclusion === 'success'
));
if (!successfulRun) {
core.setFailed(`No successful Release workflow found for ${tag} at ${commitSha}`);
return;
}
const releaseList = await github.rest.repos.listReleases({
owner,
repo,
per_page: 100,
});
const releases = releaseList.data;
const release = releases.find((item) => item.tag_name === tag);
if (!release) {
core.setFailed(`Release not found for ${tag}`);
return;
}
let latestRelease = null;
try {
latestRelease = (await github.rest.repos.getLatestRelease({ owner, repo })).data;
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
const semverParts = (value) => value.slice(1).split('.').map(Number);
const compareSemver = (left, right) => {
const a = semverParts(left);
const b = semverParts(right);
for (let index = 0; index < 3; index += 1) {
if (a[index] !== b[index]) {
return a[index] - b[index];
}
}
return 0;
};
let shouldPublish = release.draft;
if (release.draft) {
const latestTag = String(latestRelease?.tag_name || '').trim();
if (latestTag && /^v\d+\.\d+\.\d+$/.test(latestTag) && compareSemver(tag, latestTag) <= 0) {
core.setFailed(`Refusing to publish ${tag} over current latest ${latestTag}`);
return;
}
} else if (!latestRelease || latestRelease.id !== release.id) {
core.setFailed(`Published release ${tag} is not the current GitHub latest release`);
return;
} else {
shouldPublish = false;
}
const version = tag.slice(1);
const expectedAssets = [
'LICENSE',
'NOTICE',
'SHA256SUMS',
'latest.json',
`GoNavi-${version}-Linux-Amd64-WebKit41.tar.gz`,
`GoNavi-${version}-Linux-Amd64.tar.gz`,
`GoNavi-${version}-Linux-Arm64.tar.gz`,
`GoNavi-${version}-MacOS-Amd64.dmg`,
`GoNavi-${version}-MacOS-Arm64.dmg`,
`GoNavi-${version}-Windows-Amd64-Installer.msi`,
`GoNavi-${version}-Windows-Amd64-Portable.exe`,
`GoNavi-${version}-Windows-Arm64-Installer.msi`,
`GoNavi-${version}-Windows-Arm64-Portable.exe`,
];
const assetsByName = new Map(release.assets.map((asset) => [asset.name, asset]));
const missingAssets = expectedAssets.filter((name) => {
const asset = assetsByName.get(name);
return !asset || asset.state !== 'uploaded' || asset.size <= 0;
});
if (missingAssets.length > 0) {
core.setFailed(`Release assets are incomplete: ${missingAssets.join(', ')}`);
return;
}
core.setOutput('tag', tag);
core.setOutput('release_id', String(release.id));
core.setOutput('should_publish', String(shouldPublish));
core.notice(`Validated ${release.draft ? 'draft' : 'published'} release ${tag} and its assets`);
- name: Checkout release tooling
uses: actions/checkout@v5
- name: Mirror and verify stable release in Cloudflare R2
env:
GH_TOKEN: ${{ github.token }}
DRIVER_RELEASE_TOKEN: ${{ secrets.DRIVER_RELEASE_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
RELEASE_TAG: ${{ steps.validate.outputs.tag }}
R2_BUCKET: gonavi-downloads
shell: bash
run: |
set -euo pipefail
if [[ -z "${CLOUDFLARE_ACCOUNT_ID}" || -z "${AWS_ACCESS_KEY_ID}" || -z "${AWS_SECRET_ACCESS_KEY}" || -z "${DRIVER_RELEASE_TOKEN}" ]]; then
echo "Cloudflare R2 or DriverAgents release credentials are not configured" >&2
exit 1
fi
endpoint="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"
app_dir="${RUNNER_TEMP}/gonavi-release"
driver_dir="${RUNNER_TEMP}/driver-release"
mkdir -p "${app_dir}" "${driver_dir}"
gh release download "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --dir "${app_dir}"
driver_release_json="${RUNNER_TEMP}/driver-release.json"
driver_status="$(curl --silent --show-error \
--output "${driver_release_json}" \
--write-out '%{http_code}' \
--header "Authorization: Bearer ${DRIVER_RELEASE_TOKEN}" \
--header 'Accept: application/vnd.github+json' \
--header 'X-GitHub-Api-Version: 2022-11-28' \
"https://api.github.com/repos/Syngnat/GoNavi-DriverAgents/releases/tags/${RELEASE_TAG}")"
has_driver_release=false
case "${driver_status}" in
200)
jq -e --arg tag "${RELEASE_TAG}" \
'.tag_name == $tag and .draft == false' "${driver_release_json}" >/dev/null
GH_TOKEN="${DRIVER_RELEASE_TOKEN}" gh release download "${RELEASE_TAG}" \
--repo Syngnat/GoNavi-DriverAgents --dir "${driver_dir}"
has_driver_release=true
;;
404)
echo "No DriverAgents release for ${RELEASE_TAG}; skipping driver mirror"
;;
*)
echo "DriverAgents release lookup failed with HTTP ${driver_status}" >&2
exit 1
;;
esac
jq -e \
--arg tag "${RELEASE_TAG}" \
--arg mirror_base "https://download.syngnat.top/gonavi/releases/download" \
--arg github_base "https://github.com/${GITHUB_REPOSITORY}/releases/download" \
'
.tagName == $tag
and (.assets | type == "array" and length > 0)
and all(.assets[];
(.url | type == "string" and startswith($mirror_base + "/" + $tag + "/"))
and (.apiUrl | type == "string" and startswith($github_base + "/" + $tag + "/"))
and (.sha256 | type == "string" and test("^[0-9a-fA-F]{64}$"))
)
' "${app_dir}/latest.json" >/dev/null
if [[ "${has_driver_release}" == true ]]; then
jq -e '.assets | type == "object" and length > 0' \
"${driver_dir}/GoNavi-DriverAgents-Index.json" >/dev/null
[[ -s "${driver_dir}/SHA256SUMS" ]] || {
echo "DriverAgents SHA256SUMS is missing" >&2
exit 1
}
(cd "${driver_dir}" && sha256sum --check SHA256SUMS)
jq --arg tag "${RELEASE_TAG}" \
'.tagName = $tag' \
"${driver_dir}/GoNavi-DriverAgents-Index.json" \
> "${RUNNER_TEMP}/driver-latest-index.json"
fi
while IFS=$'\t' read -r name expected_size expected_sha; do
[[ "${name}" == "$(basename "${name}")" ]] || {
echo "Invalid app asset name in latest.json: ${name}" >&2
exit 1
}
file="${app_dir}/${name}"
[[ -f "${file}" ]] || {
echo "App asset referenced by latest.json is missing: ${name}" >&2
exit 1
}
actual_size="$(stat -c '%s' "${file}")"
actual_sha="$(sha256sum "${file}" | awk '{print $1}')"
if [[ "${actual_size}" != "${expected_size}" || "${actual_sha}" != "${expected_sha,,}" ]]; then
echo "App asset does not match latest.json: ${name}" >&2
exit 1
fi
done < <(jq -r '.assets[] | [.name, (.size | tostring), .sha256] | @tsv' "${app_dir}/latest.json")
if [[ "${has_driver_release}" == true ]]; then
while IFS=$'\t' read -r name expected_size; do
[[ "${name}" == "$(basename "${name}")" ]] || {
echo "Invalid driver asset name in index: ${name}" >&2
exit 1
}
file="${driver_dir}/${name}"
[[ -f "${file}" ]] || {
echo "Driver asset referenced by index is missing: ${name}" >&2
exit 1
}
actual_size="$(stat -c '%s' "${file}")"
if [[ "${actual_size}" != "${expected_size}" ]]; then
echo "Driver asset size does not match index: ${name}" >&2
exit 1
fi
done < <(jq -r '.assets | to_entries[] | [.key, (.value | tostring)] | @tsv' \
"${driver_dir}/GoNavi-DriverAgents-Index.json")
else
current_driver_state="${RUNNER_TEMP}/r2-current-stable-driver-state.json"
if python3 tools/r2-release-retention.py validate-driver-pointer \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--channel stable > "${current_driver_state}"; then
current_driver_keep_prefix="$(jq -r '.keepPrefix' "${current_driver_state}")"
python3 tools/r2-release-retention.py prune \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--root-prefix "drivers/releases/download/" \
--keep-prefix "${current_driver_keep_prefix}"
else
echo "::warning::Current stable driver pointer could not be fully validated; preserving every stable driver object"
fi
fi
# Preflight the final retained footprint before uploading or
# publishing. Existing objects in the channel being replaced are
# excluded, then the exact local candidate size is added.
app_manifest_size="$(stat -c '%s' "${app_dir}/latest.json")"
planned_bytes="$(jq '[.assets[].size] | add // 0' "${app_dir}/latest.json")"
planned_bytes="$((planned_bytes + app_manifest_size * 2))"
budget_args=(
measure
--bucket "${R2_BUCKET}"
--endpoint-url "${endpoint}"
--prefix "gonavi/"
--prefix "drivers/"
--exclude-prefix "gonavi/releases/download/"
--exclude-prefix "gonavi/releases/latest/"
)
if [[ "${has_driver_release}" == true ]]; then
driver_index_size="$(stat -c '%s' "${driver_dir}/GoNavi-DriverAgents-Index.json")"
driver_latest_size="$(stat -c '%s' "${RUNNER_TEMP}/driver-latest-index.json")"
driver_asset_bytes="$(jq '[.assets[]] | add // 0' "${driver_dir}/GoNavi-DriverAgents-Index.json")"
planned_bytes="$((planned_bytes + driver_asset_bytes + driver_index_size + driver_latest_size))"
budget_args+=(
--exclude-prefix "drivers/releases/download/"
--exclude-prefix "drivers/releases/latest/"
)
fi
python3 tools/r2-release-retention.py "${budget_args[@]}" \
--add-bytes "${planned_bytes}" \
--max-bytes 9000000000
upload_immutable() {
local file="$1"
local key="$2"
local content_type="${3:-}"
local sha size head_json remote_sha remote_size
sha="$(sha256sum "${file}" | awk '{print $1}')"
size="$(stat -c '%s' "${file}")"
if head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json 2>&1)"; then
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
echo "Refusing to overwrite immutable R2 object ${key}" >&2
exit 1
fi
echo "R2 object already verified: ${key}"
return
elif ! grep -Eq '\(404\)|Not Found|NoSuchKey' <<<"${head_json}"; then
echo "Unable to inspect immutable R2 object ${key}: ${head_json}" >&2
exit 1
fi
args=(s3 cp "${file}" "s3://${R2_BUCKET}/${key}" --endpoint-url "${endpoint}" --cache-control "public,max-age=31536000,immutable" --metadata "sha256=${sha}" --only-show-errors)
if [[ -n "${content_type}" ]]; then
args+=(--content-type "${content_type}")
fi
aws "${args[@]}"
head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json)"
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
echo "R2 verification failed for ${key}" >&2
exit 1
fi
}
while IFS= read -r name; do
file="${app_dir}/${name}"
upload_immutable "${file}" "gonavi/releases/download/${RELEASE_TAG}/${name}"
done < <(jq -r '.assets[].name' "${app_dir}/latest.json")
if [[ "${has_driver_release}" == true ]]; then
# Upload only the standalone assets referenced by the index. The
# aggregate bundle duplicates them and remains on GitHub fallback.
while IFS= read -r name; do
file="${driver_dir}/${name}"
upload_immutable "${file}" "drivers/releases/download/${RELEASE_TAG}/${name}"
done < <(jq -r '.assets | keys[]' "${driver_dir}/GoNavi-DriverAgents-Index.json")
fi
# Metadata is the commit point: publish it only after every referenced object is verified.
if [[ "${has_driver_release}" == true ]]; then
upload_immutable \
"${driver_dir}/GoNavi-DriverAgents-Index.json" \
"drivers/releases/download/${RELEASE_TAG}/GoNavi-DriverAgents-Index.json" \
"application/json; charset=utf-8"
fi
upload_immutable \
"${app_dir}/latest.json" \
"gonavi/releases/download/${RELEASE_TAG}/latest.json" \
"application/json; charset=utf-8"
- name: Publish validated draft
if: steps.validate.outputs.should_publish == 'true'
uses: actions/github-script@v8
env:
RELEASE_TAG: ${{ steps.validate.outputs.tag }}
RELEASE_ID: ${{ steps.validate.outputs.release_id }}
with:
script: |
const tag = String(process.env.RELEASE_TAG || '').trim();
const releaseId = Number(process.env.RELEASE_ID || 0);
if (!/^v\d+\.\d+\.\d+$/.test(tag) || !Number.isSafeInteger(releaseId) || releaseId <= 0) {
core.setFailed(`Invalid validated release: tag=${tag} id=${releaseId}`);
return;
}
const { owner, repo } = context.repo;
const published = await github.rest.repos.updateRelease({
owner,
repo,
release_id: releaseId,
draft: false,
prerelease: false,
make_latest: 'true',
});
if (published.data.draft || !published.data.published_at) {
core.setFailed(`Release ${tag} was not published successfully`);
return;
}
core.notice(`Published ${tag}: ${published.data.html_url}`);
- name: Verify GitHub release is published and latest
uses: actions/github-script@v8
env:
RELEASE_TAG: ${{ steps.validate.outputs.tag }}
RELEASE_ID: ${{ steps.validate.outputs.release_id }}
with:
script: |
const tag = String(process.env.RELEASE_TAG || '').trim();
const releaseId = Number(process.env.RELEASE_ID || 0);
const { owner, repo } = context.repo;
const [releaseResult, latestResult] = await Promise.all([
github.rest.repos.getRelease({ owner, repo, release_id: releaseId }),
github.rest.repos.getLatestRelease({ owner, repo }),
]);
const release = releaseResult.data;
const latest = latestResult.data;
if (release.draft || !release.published_at || release.tag_name !== tag || latest.id !== release.id) {
core.setFailed(`Release ${tag} is not the current published GitHub latest release`);
return;
}
core.notice(`Verified GitHub latest release ${tag}`);
- name: Promote and verify Cloudflare R2 latest manifest
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
RELEASE_TAG: ${{ steps.validate.outputs.tag }}
R2_BUCKET: gonavi-downloads
shell: bash
run: |
set -euo pipefail
endpoint="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"
file="${RUNNER_TEMP}/gonavi-release/latest.json"
key="gonavi/releases/latest/latest.json"
sha="$(sha256sum "${file}" | awk '{print $1}')"
size="$(stat -c '%s' "${file}")"
aws s3 cp "${file}" "s3://${R2_BUCKET}/${key}" \
--endpoint-url "${endpoint}" \
--cache-control "no-cache,must-revalidate" \
--content-type "application/json; charset=utf-8" \
--metadata "sha256=${sha}" \
--only-show-errors
head_json="$(aws s3api head-object \
--bucket "${R2_BUCKET}" \
--key "${key}" \
--endpoint-url "${endpoint}" \
--output json)"
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
echo "R2 latest manifest verification failed" >&2
exit 1
fi
curl --fail --silent --show-error \
"https://download.syngnat.top/gonavi/releases/latest/latest.json?verify=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
| jq -e --arg tag "${RELEASE_TAG}" '.tagName == $tag' >/dev/null
driver_latest_file="${RUNNER_TEMP}/driver-latest-index.json"
if [[ -s "${driver_latest_file}" ]]; then
driver_key="drivers/releases/latest/GoNavi-DriverAgents-Index.json"
driver_sha="$(sha256sum "${driver_latest_file}" | awk '{print $1}')"
driver_size="$(stat -c '%s' "${driver_latest_file}")"
aws s3 cp "${driver_latest_file}" "s3://${R2_BUCKET}/${driver_key}" \
--endpoint-url "${endpoint}" \
--cache-control "no-cache,must-revalidate" \
--content-type "application/json; charset=utf-8" \
--metadata "sha256=${driver_sha}" \
--only-show-errors
driver_head_json="$(aws s3api head-object \
--bucket "${R2_BUCKET}" \
--key "${driver_key}" \
--endpoint-url "${endpoint}" \
--output json)"
remote_driver_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${driver_head_json}")"
remote_driver_size="$(jq -r '.ContentLength // 0' <<<"${driver_head_json}")"
if [[ "${remote_driver_sha}" != "${driver_sha}" || "${remote_driver_size}" != "${driver_size}" ]]; then
echo "R2 latest driver index verification failed" >&2
exit 1
fi
curl --fail --silent --show-error \
"https://download.syngnat.top/${driver_key}?verify=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
| jq -e --arg tag "${RELEASE_TAG}" '.tagName == $tag' >/dev/null
fi
# Only prune after the mutable pointers are publicly verified. A
# failed cleanup leaves extra storage but never breaks the active
# updater path; rerunning this job is idempotent.
python3 tools/r2-release-retention.py prune \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--root-prefix "gonavi/releases/download/" \
--keep-prefix "gonavi/releases/download/${RELEASE_TAG}/"
driver_keep_prefix=""
if [[ -s "${driver_latest_file}" ]]; then
driver_keep_prefix="drivers/releases/download/${RELEASE_TAG}/"
else
current_driver_state="${RUNNER_TEMP}/r2-current-stable-driver-state.json"
if python3 tools/r2-release-retention.py validate-driver-pointer \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--channel stable > "${current_driver_state}"; then
driver_keep_prefix="$(jq -r '.keepPrefix' "${current_driver_state}")"
else
echo "::warning::Skipping stable driver cleanup because the current R2 pointer or retained assets are invalid"
fi
fi
if [[ -n "${driver_keep_prefix}" ]]; then
python3 tools/r2-release-retention.py prune \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--root-prefix "drivers/releases/download/" \
--keep-prefix "${driver_keep_prefix}"
fi
# Keep a 1 GB safety margin below the 10 GB-month free allowance.
python3 tools/r2-release-retention.py measure \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--prefix "gonavi/" \
--prefix "drivers/" \
--max-bytes 9000000000

22
.github/workflows/release-winget.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Publish to WinGet
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
release_tag:
required: true
description: 'Tag of release you want to publish'
type: string
jobs:
publish:
runs-on: windows-2025-vs2026
steps:
- uses: vedantmgoyal9/winget-releaser@v2
with:
identifier: Syngnat.GoNavi
installers-regex: 'GoNavi-windows-(amd64|arm64)\.exe$'
release-tag: ${{ inputs.release_tag || github.ref_name }}
token: ${{ secrets.WINGET_TOKEN }}

View File

@@ -7,7 +7,6 @@ on:
permissions:
contents: write
pull-requests: read
jobs:
frontend:
@@ -22,18 +21,6 @@ jobs:
with:
go-version-file: 'go.mod'
- name: Test release asset contracts
run: |
bash tools/detect-changed-driver-agents.test.sh
bash tools/generate-driver-agent-revisions.test.sh
python3 tools/generate-driver-release-manifest.test.py
python3 tools/generate-update-latest-manifest.test.py
python3 tools/r2-release-retention.test.py
python3 tools/package-driver-release-assets.test.py
python3 tools/legal-release-assets.test.py
python3 tools/windows-release-artifacts.test.py
python3 tools/generate-release-notes.test.py
- name: Setup Node
uses: actions/setup-node@v5
with:
@@ -45,7 +32,7 @@ jobs:
uses: actions/cache@v5
with:
path: frontend/node_modules
key: ${{ runner.os }}-node20-frontend-${{ hashFiles('frontend/package-lock.json', 'frontend/patches/*.patch') }}
key: ${{ runner.os }}-node20-frontend-${{ hashFiles('frontend/package-lock.json') }}
- name: Install Wails
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
@@ -60,9 +47,6 @@ jobs:
node frontend/scripts/wails-frontend-install.mjs
npm --prefix frontend run build
- name: Test frontend virtual scrolling
run: npm --prefix frontend test -- src/components/dataGridVirtualScroll.test.ts src/components/DataGrid.layout.test.tsx
- name: Pack frontend dist
shell: bash
run: tar -cf frontend-dist.tar -C frontend/dist .
@@ -193,7 +177,7 @@ jobs:
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
if [[ "$BASE_REF" != "all" ]]; then
REVISION_DRIVERS=""
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do
PLATFORM_DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")" || {
echo "⚠️ 平台 revision 差异对比失败(${PLATFORM}),保守回退为全量驱动重建"
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")"
@@ -279,15 +263,6 @@ jobs:
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
- os: ubuntu-22.04-arm
platform: linux/arm64
os_name: Linux
arch_name: Arm64
build_name: gonavi-build-linux-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
# Debian 13 (trixie) 默认仓库已切到 WebKitGTK 4.1:单独提供 4.1 变体产物
- os: ubuntu-24.04
platform: linux/amd64
@@ -359,24 +334,6 @@ jobs:
}
& $upxCmd --version
- name: Install WiX Toolset (Windows)
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
$wixVersion = "6.0.2"
$wixTools = Join-Path $env:RUNNER_TEMP "wix-tools"
dotnet tool install wix --tool-path $wixTools --version $wixVersion
if ($LASTEXITCODE -ne 0) {
throw "WiX Toolset 安装失败,退出码: $LASTEXITCODE"
}
$wixTools | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
$env:PATH = "$wixTools;$env:PATH"
wix extension add --global "WixToolset.UI.wixext/$wixVersion"
if ($LASTEXITCODE -ne 0) {
throw "WiX UI 扩展安装失败,退出码: $LASTEXITCODE"
}
wix --version
# Linux Dependencies (GTK3, WebKit2GTK required by Wails)
- name: Install Linux Dependencies
if: contains(matrix.platform, 'linux')
@@ -397,12 +354,6 @@ jobs:
# AppImage 运行/打包可能需要 FUSE2。不同发行版/版本包名不同,做兼容兜底。
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
if [ "${{ matrix.arch_name }}" != "Amd64" ]; then
echo "⚠️ Linux arm64 暂不生成 AppImage仅产出 tar.gz"
touch /tmp/skip-appimage
exit 0
fi
# Download linuxdeploy tools for AppImage packaging
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
@@ -506,11 +457,6 @@ jobs:
& "$env:CC" --version
& "$env:CXX" --version
- name: Test Windows Online Updater (Windows AMD64)
if: ${{ matrix.platform == 'windows/amd64' }}
shell: pwsh
run: go test . ./internal/app -run '^(TestShouldEnableWindowsMSISingleInstanceOnlyForInstalledMainGUI|TestPrimaryWindowActivatorQueuesRequestsUntilRuntimeStartup|TestAcquireWindowsMSISingleInstance|TestResolveWindowsUpdateMaintenanceName|TestAcquireWindowsUpdateMaintenance|TestPrepareWindowsUpdateHandoff|TestWindowsPowerShellUpdater|TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix|TestShouldWindowsUpdateLaunchDownloadedAssetDirectly|TestResolveUpdateInstallMode|TestExpectedAssetNameForWindowsInstallMode|TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory|TestValidateUpdatePackageForCurrentInstallMode|TestWindowsUpdateRequiresExplicitCloseConfirmation|TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows|TestInstallUpdateAndRestartMSI|TestFindOtherWindowsUpdateInstances|TestCloseWindowsUpdateInstances|TestInstallUpdateAndRestartClosesOtherTargetInstances|TestBuildWindowsMSIUpdatePowerShellScript|TestBuildWindowsMSILaunchCommand|TestResolveWindowsMSIExecPath)' -count=1 -timeout=3m
- name: Build
shell: bash
run: |
@@ -589,45 +535,7 @@ jobs:
export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}"
mkdir -p "$GOCACHE"
echo "🧭 可选驱动使用隔离 GOCACHE$GOCACHE"
normalize_driver_token() {
local value
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
case "$value" in
"") return 1 ;;
doris|diros) echo "doris" ;;
open_gauss|open-gauss) echo "opengauss" ;;
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
elastic|elasticsearch) echo "elasticsearch" ;;
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse|trino)
echo "$value"
;;
*)
echo "❌ 不支持的 driver-agent${1:-}" >&2
return 1
;;
esac
}
build_driver_name() {
case "$1" in
doris) echo "diros" ;;
*) echo "$1" ;;
esac
}
declare -a RAW_DRIVERS=()
declare -a DRIVERS=()
IFS=',' read -r -a RAW_DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
for RAW_DRIVER in "${RAW_DRIVERS[@]}"; do
DRIVER="$(normalize_driver_token "$RAW_DRIVER")" || continue
DRIVERS+=("$DRIVER")
done
if [ ${#DRIVERS[@]} -eq 0 ]; then
echo "🧭 没有需要构建的 driver-agent"
exit 0
fi
NORMALIZED_CHANGED_DRIVER_AGENTS="$(IFS=,; echo "${DRIVERS[*]}")"
echo "🧭 归一后的 driver-agent 构建列表:${NORMALIZED_CHANGED_DRIVER_AGENTS}"
IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
@@ -683,7 +591,10 @@ jobs:
}
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$(build_driver_name "$DRIVER")"
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
echo "⚠️ 跳过 DuckDB driver当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64"
continue
@@ -778,12 +689,7 @@ jobs:
bash ./tools/verify-driver-agent-revisions.sh \
--assets-dir drivers \
--platform "$TARGET_PLATFORM" \
--drivers "$NORMALIZED_CHANGED_DRIVER_AGENTS"
mkdir -p driver-provenance
python3 ./tools/generate-driver-release-manifest.py \
--assets-dir drivers \
--provenance-output "driver-provenance/${GOOS}-${GOARCH}.json"
--drivers "$CHANGED_DRIVER_AGENTS"
# macOS Packaging
- name: Package macOS DMG
@@ -800,9 +706,6 @@ jobs:
exit 1
fi
APP_NAME=$(basename "$APP_PATH")
mkdir -p "$APP_NAME/Contents/Resources"
cp ../../LICENSE "$APP_NAME/Contents/Resources/LICENSE"
cp ../../NOTICE "$APP_NAME/Contents/Resources/NOTICE"
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
if [ -z "$APP_BIN" ]; then
@@ -848,7 +751,7 @@ jobs:
mv "$DMG_NAME" "../../$FINAL_NAME"
# Windows Packaging
- name: Package Windows Portable EXE and MSI
- name: Package Windows EXE
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
@@ -858,9 +761,7 @@ jobs:
$version = $version.Substring(1)
}
$target = "${{ matrix.build_name }}"
$archName = "${{ matrix.arch_name }}"
$portableExeName = "GoNavi-$version-${{ matrix.os_name }}-$archName${{ matrix.artifact_suffix }}-Portable.exe"
$msiName = "GoNavi-$version-${{ matrix.os_name }}-$archName${{ matrix.artifact_suffix }}-Installer.msi"
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
if (Test-Path "$target.exe") {
$finalExe = "$target.exe"
@@ -903,71 +804,8 @@ jobs:
}
}
Write-Host "📦 输出 Windows Portable 可执行文件 $portableExeName..."
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$portableExeName" -Force
if ($version -match '^(?<major>\d+)\.(?<minor>\d+)\.(?<build>\d+)(?:[-+].*)?$') {
$major = [int]$Matches.major
$minor = [int]$Matches.minor
$build = [int]$Matches.build
} else {
throw "Windows MSI 版本必须是 major.minor.patch 格式,当前为: $version"
}
if ($major -gt 255 -or $minor -gt 255 -or $build -gt 65535) {
throw "Windows MSI 版本超出范围 (major/minor <= 255, patch <= 65535): $version"
}
$productVersion = "$major.$minor.$build"
$wixArch = if ($archName -eq "Arm64") { "arm64" } else { "x64" }
$componentGuid = if ($archName -eq "Arm64") {
"7F4C7757-329F-42B0-B312-D4B8AD50415E"
} else {
"0BCEE70B-9CF2-449C-9ADE-190188493234"
}
$desktopShortcutComponentGuid = if ($archName -eq "Arm64") {
"3CF7DCD2-2658-432D-8677-AB6BB34A9659"
} else {
"114030DD-DB5F-460A-9CAC-997680E9F938"
}
$licenseFile = (Resolve-Path -LiteralPath "..\\..\\LICENSE").Path
$noticeFile = (Resolve-Path -LiteralPath "..\\..\\NOTICE").Path
$licenseText = Get-Content -LiteralPath $licenseFile -Raw
$escapedLicense = $licenseText.Replace('\', '\\').Replace('{', '\{').Replace('}', '\}')
$escapedLicense = $escapedLicense -replace "`r?`n", "\par`r`n"
$licenseRtf = Join-Path $env:RUNNER_TEMP "GoNavi-License.rtf"
$rtfContent = "{\rtf1\ansi\deff0{\fonttbl{\f0 Segoe UI;}}\f0\fs18 $escapedLicense}"
Set-Content -LiteralPath $licenseRtf -Value $rtfContent -Encoding ascii
$sourceExe = (Resolve-Path -LiteralPath $finalExe).Path
$wxsPath = (Resolve-Path -LiteralPath "..\\windows\\installer.wxs").Path
$iconPath = (Resolve-Path -LiteralPath "..\\windows\\icon.ico").Path
$installMarkerPath = Join-Path $env:RUNNER_TEMP ".gonavi-msi-install"
Set-Content -LiteralPath $installMarkerPath -Value "MSI" -Encoding ascii -NoNewline
$installMarkerPath = (Resolve-Path -LiteralPath $installMarkerPath).Path
$wixIntermediateDir = Join-Path $env:RUNNER_TEMP "wixobj-$archName"
Write-Host "📦 生成 Windows MSI 安装程序 $msiName (version=$productVersion arch=$wixArch)..."
wix build $wxsPath `
-arch $wixArch `
-ext "WixToolset.UI.wixext/6.0.2" `
-d "ProductName=GoNavi" `
-d "ProductVersion=$productVersion" `
-d "UpgradeCode=CDD6BF2F-ED1E-4345-A0AB-DCDB7E15FB23" `
-d "ComponentGuid=$componentGuid" `
-d "DesktopShortcutComponentGuid=$desktopShortcutComponentGuid" `
-d "InstallFolderName=GoNavi" `
-d "RegistryKeyName=GoNavi" `
-d "SourceExe=$sourceExe" `
-d "InstallMarker=$installMarkerPath" `
-d "IconPath=$iconPath" `
-d "LicenseRtf=$licenseRtf" `
-d "LicenseFile=$licenseFile" `
-d "NoticeFile=$noticeFile" `
-pdbtype none `
-intermediatefolder $wixIntermediateDir `
-out "..\\..\\$msiName"
if ($LASTEXITCODE -ne 0) {
throw "WiX MSI 构建失败,退出码: $LASTEXITCODE"
}
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
# Linux Packaging (tar.gz and AppImage)
- name: Package Linux
@@ -1000,7 +838,7 @@ jobs:
# 1. Create tar.gz
echo "📦 正在打包 $TAR_NAME..."
tar -czvf "$TAR_NAME" "$TARGET" -C ../.. LICENSE NOTICE
tar -czvf "$TAR_NAME" "$TARGET"
mv "$TAR_NAME" ../../
# 2. Create AppImage (skip for ARM64 or if tools unavailable)
@@ -1014,12 +852,9 @@ jobs:
# Create AppDir structure
mkdir -p AppDir/usr/bin
mkdir -p AppDir/usr/share/applications
mkdir -p AppDir/usr/share/doc/gonavi
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
cp "$TARGET" AppDir/usr/bin/gonavi
cp ../../LICENSE AppDir/usr/share/doc/gonavi/LICENSE
cp ../../NOTICE AppDir/usr/share/doc/gonavi/NOTICE
# Create desktop file
printf '%s\n' \
@@ -1072,11 +907,9 @@ jobs:
path: |
GoNavi-*.dmg
GoNavi-*.exe
GoNavi-*.msi
GoNavi-*.tar.gz
GoNavi-*.AppImage
drivers/**
driver-provenance/**
retention-days: 1
# Phase 2: Collect all artifacts and Publish Release (Single Job)
@@ -1090,11 +923,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Download All Artifacts
uses: actions/download-artifact@v7
with:
@@ -1102,29 +930,9 @@ jobs:
pattern: build-artifacts-*
merge-multiple: true
- name: Add legal documents
shell: bash
run: install -m 0644 LICENSE NOTICE release-assets/
- name: List Assets
run: ls -R release-assets
- name: Download Previous Driver Manifest
if: needs.driver_agents.outputs.has_changes == 'true' && needs.driver_agents.outputs.release_source != 'all'
env:
DRIVER_RELEASE_SOURCE: ${{ needs.driver_agents.outputs.release_source }}
shell: bash
run: |
set -euo pipefail
manifest_path="$RUNNER_TEMP/previous-driver-manifest.json"
manifest_url="https://github.com/Syngnat/GoNavi-DriverAgents/releases/download/${DRIVER_RELEASE_SOURCE}/GoNavi-DriverAgents-Manifest.json"
if curl --retry 3 --retry-delay 2 --retry-all-errors -fsSL "$manifest_url" -o "$manifest_path"; then
echo "🧭 已下载上一版 driver manifest 作为复用资产 provenance"
else
rm -f "$manifest_path"
echo "⚠️ 上一版 driver manifest 不可用;未重建资产将由 manifest 生成阶段拒绝"
fi
- name: Complete Driver Agent Assets
if: needs.driver_agents.outputs.has_changes == 'true' && needs.driver_agents.outputs.release_source != 'all'
env:
@@ -1198,20 +1006,12 @@ jobs:
drivers \
../driver-release-assets
MANIFEST_ARGS=(
--assets-dir drivers
python3 ../tools/generate-driver-release-manifest.py \
--assets-dir drivers \
--output ../driver-release-assets/GoNavi-DriverAgents-Manifest.json
)
if [ -d driver-provenance ]; then
MANIFEST_ARGS+=(--provenance driver-provenance)
fi
if [ -s "$RUNNER_TEMP/previous-driver-manifest.json" ]; then
MANIFEST_ARGS+=(--provenance "$RUNNER_TEMP/previous-driver-manifest.json")
fi
python3 ../tools/generate-driver-release-manifest.py "${MANIFEST_ARGS[@]}"
# GoNavi 主仓库只保留主程序包;驱动资产发布到独立仓库。
rm -rf drivers driver-provenance
rm -rf drivers
echo "has_driver_assets=true" >> "$GITHUB_OUTPUT"
- name: Generate SHA256SUMS
@@ -1221,9 +1021,6 @@ jobs:
FILES=()
while IFS= read -r file; do
if [ -n "$file" ]; then
case "$file" in
SHA256SUMS|latest.json|latest-dev.json) continue ;;
esac
FILES+=("$file")
fi
done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort)
@@ -1234,25 +1031,6 @@ jobs:
sha256sum "${FILES[@]}" > SHA256SUMS
fi
# 静态更新清单:客户端优先下载 github.com/.../latest/download/latest.json
# 避免终端用户检查更新打爆 api.github.com 未认证配额。
- name: Generate static update manifest (latest.json)
shell: bash
run: |
set -euo pipefail
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
python3 tools/generate-update-latest-manifest.py \
--assets-dir release-assets \
--version "$VERSION" \
--tag "$TAG" \
--channel latest \
--download-base-url https://download.syngnat.top/gonavi/releases/download \
--output release-assets/latest.json
test -s release-assets/latest.json
echo "📄 latest.json ready:"
head -n 40 release-assets/latest.json
- name: Generate Driver SHA256SUMS
if: steps.driver_assets.outputs.has_driver_assets == 'true'
shell: bash
@@ -1302,19 +1080,65 @@ jobs:
- name: Generate Changelog
id: changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
cd repo-for-changelog
CHANGELOG_FILE="$RUNNER_TEMP/changelog.md"
python3 tools/generate-release-notes.py \
--repo "${{ github.repository }}" \
--tag "${{ github.ref_name }}" \
--repository-url "${{ github.server_url }}/${{ github.repository }}" \
--output "$CHANGELOG_FILE"
echo "changelog_file=$CHANGELOG_FILE" >> "$GITHUB_OUTPUT"
TAG="${{ github.ref_name }}"
# 获取上一个 tag
PREV_TAG=$(git tag --sort=-creatordate | grep -E '^v' | sed -n '2p' || true)
if [ -z "$PREV_TAG" ]; then
echo "⚠️ 未找到上一个 tag使用全部 commit"
RANGE="$TAG"
else
RANGE="${PREV_TAG}..${TAG}"
fi
echo "📋 生成更新日志:$RANGE"
# 提取 commit 消息(排除 merge commit
COMMITS=$(git log "$RANGE" --no-merges --pretty=format:'%s' 2>/dev/null || true)
if [ -z "$COMMITS" ]; then
BODY="暂无提交记录。"
else
CAT_FEAT=""
CAT_FIX=""
CAT_PERF=""
CAT_REFACTOR=""
CAT_I18N=""
CAT_OTHER=""
while IFS= read -r line; do
[ -z "$line" ] && continue
case "$line" in
✨*|*feat*) CAT_FEAT="${CAT_FEAT}\n- ${line}" ;;
🐛*|*fix*) CAT_FIX="${CAT_FIX}\n- ${line}" ;;
⚡*|*perf*) CAT_PERF="${CAT_PERF}\n- ${line}" ;;
♻️*|*refactor*) CAT_REFACTOR="${CAT_REFACTOR}\n- ${line}" ;;
🌐*) CAT_I18N="${CAT_I18N}\n- ${line}" ;;
🔧*|🔨*|*chore*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
esac
done <<< "$COMMITS"
BODY=""
[ -n "$CAT_FEAT" ] && BODY="${BODY}## ✨ 新功能\n${CAT_FEAT}\n\n"
[ -n "$CAT_FIX" ] && BODY="${BODY}## 🐛 问题修复\n${CAT_FIX}\n\n"
[ -n "$CAT_PERF" ] && BODY="${BODY}## ⚡ 性能优化\n${CAT_PERF}\n\n"
[ -n "$CAT_REFACTOR" ] && BODY="${BODY}## ♻️ 重构\n${CAT_REFACTOR}\n\n"
[ -n "$CAT_I18N" ] && BODY="${BODY}## 🌐 国际化\n${CAT_I18N}\n\n"
[ -n "$CAT_OTHER" ] && BODY="${BODY}## 🔧 其他变更\n${CAT_OTHER}\n\n"
# 附加 compare 链接
if [ -n "$PREV_TAG" ]; then
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
BODY="${BODY}---\n**完整变更**: [${PREV_TAG}...${TAG}](${REPO_URL}/compare/${PREV_TAG}...${TAG})\n"
fi
fi
# 写入到文件避免多行环境变量问题
printf '%b' "$BODY" > /tmp/changelog.md
echo "changelog_file=/tmp/changelog.md" >> "$GITHUB_OUTPUT"
- name: Create Release
uses: softprops/action-gh-release@v3

7
.gitignore vendored
View File

@@ -10,19 +10,12 @@ build/bin/
# wails / node artifacts (按需)
node_modules/
frontend/wailsjs/tsconfig.json
__pycache__/
*.py[cod]
dist/
.DS_Store
.gemini-clipboard
.env
docker.*.env
GoNavi-Wails
GoNavi-Wails.exe
optional-driver-agent
optional-driver-agent.exe
.ace-tool/
.superpowers/
.claude/

View File

@@ -1,58 +0,0 @@
# syntax=docker/dockerfile:1.7
ARG GO_IMAGE=golang:1.25-bookworm
ARG NODE_IMAGE=node:20-bookworm
FROM ${NODE_IMAGE} AS node
FROM ${GO_IMAGE}
ARG WEBKIT_API=4.0
ARG WAILS_VERSION=v2.11.0
ENV DEBIAN_FRONTEND=noninteractive
ENV PATH=/go/bin:/usr/local/go/bin:${PATH}
# Debian's /etc/profile resets PATH in login shells (bash -l), dropping the Go
# toolchain; restore it via profile.d which is sourced after that reset.
RUN printf 'export PATH=/go/bin:/usr/local/go/bin:$PATH\n' > /etc/profile.d/10-gonavi-build-env.sh
# Copy only Node.js runtime assets so the Go toolchain from the base image
# stays intact for smoke tests and interactive build shells.
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
# npm/npx/corepack are symlinks in the node image; COPY dereferences them into
# standalone scripts whose relative requires break, so recreate the symlinks.
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
&& ln -sf ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
build-essential \
ca-certificates \
curl \
file \
git \
make \
pkg-config \
python3 \
unzip \
zip \
libgtk-3-dev \
&& if [ "${WEBKIT_API}" = "4.1" ]; then \
apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev libsoup-3.0-dev; \
else \
apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev; \
fi \
&& go install github.com/wailsapp/wails/v2/cmd/wails@${WAILS_VERSION} \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /usr/share/doc/gonavi
COPY LICENSE NOTICE /usr/share/doc/gonavi/
WORKDIR /workspace
CMD ["bash"]

View File

@@ -1,49 +0,0 @@
# syntax=docker/dockerfile:1.7
FROM --platform=$BUILDPLATFORM golang:1.25-bookworm AS builder
# Declared without defaults so BuildKit injects the real target platform;
# a default like TARGETARCH=amd64 would win and ship amd64 binaries to arm64.
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
COPY third_party/highgo-pq/go.mod ./third_party/highgo-pq/go.mod
COPY third_party/go-irisnative/go.mod third_party/go-irisnative/go.sum ./third_party/go-irisnative/
RUN go mod download
COPY . .
RUN ./tools/generate-driver-agent-revisions.sh --platform "${TARGETOS}/${TARGETARCH}"
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /out/gonavi-mcp-server ./cmd/gonavi-mcp-server
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --create-home --home-dir /var/lib/gonavi --uid 10001 gonavi \
&& mkdir -p /data /var/lib/gonavi/logs /usr/share/doc/gonavi \
&& chown -R gonavi:gonavi /data /var/lib/gonavi
COPY --from=builder /out/gonavi-mcp-server /usr/local/bin/gonavi-mcp-server
COPY --from=builder /src/LICENSE /usr/share/doc/gonavi/LICENSE
COPY --from=builder /src/NOTICE /usr/share/doc/gonavi/NOTICE
ENV HOME=/var/lib/gonavi \
GONAVI_DATA_ROOT=/data \
GONAVI_LOG_DIR=/var/lib/gonavi/logs \
GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765 \
GONAVI_MCP_HTTP_PATH=/mcp \
GONAVI_MCP_SCHEMA_ONLY=true
VOLUME ["/data"]
EXPOSE 8765
USER gonavi
ENTRYPOINT ["/usr/local/bin/gonavi-mcp-server"]
CMD ["http"]

View File

@@ -1,65 +0,0 @@
# syntax=docker/dockerfile:1.7
#
# GoNavi Web Server浏览器访问版镜像。
# 复用主程序 web-server 模式 + 嵌入 frontend/dist不依赖桌面 WebView/CGO。
FROM --platform=$BUILDPLATFORM node:20-bookworm AS frontend
# 使用 monorepo 布局frontend 源码通过相对路径 ../../../shared/i18n 引用仓库 shared/
# (见 frontend/src/i18n/catalog.ts、shared/i18n/messages.ts、shared/i18n/translate.ts
# 仅 COPY frontend/ 会导致 tsc 报 Cannot find module '../../../shared/i18n/*'。
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
COPY frontend/patches ./patches
RUN npm ci --no-audit --no-fund
COPY frontend/ ./
COPY shared/ /src/shared/
# wailsjs 绑定已入库;容器内只构建静态前端
RUN npm run build
FROM --platform=$BUILDPLATFORM golang:1.25-bookworm AS builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
COPY third_party/highgo-pq/go.mod ./third_party/highgo-pq/go.mod
COPY third_party/go-irisnative/go.mod third_party/go-irisnative/go.sum ./third_party/go-irisnative/
RUN go mod download
COPY . .
RUN ./tools/generate-driver-agent-revisions.sh --platform "${TARGETOS}/${TARGETARCH}"
COPY --from=frontend /src/frontend/dist ./frontend/dist
# 纯 Go 构建主程序web-server 模式不需要 Wails WebView / CGO
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /out/gonavi .
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata curl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --create-home --home-dir /var/lib/gonavi --uid 10001 gonavi \
&& mkdir -p /data /var/lib/gonavi/logs /usr/share/doc/gonavi \
&& chown -R gonavi:gonavi /data /var/lib/gonavi
COPY --from=builder /out/gonavi /usr/local/bin/gonavi
COPY --from=builder /src/LICENSE /usr/share/doc/gonavi/LICENSE
COPY --from=builder /src/NOTICE /usr/share/doc/gonavi/NOTICE
ENV HOME=/var/lib/gonavi \
GONAVI_DATA_ROOT=/data \
GONAVI_LOG_DIR=/var/lib/gonavi/logs \
GONAVI_WEB_ADDR=0.0.0.0:34116
VOLUME ["/data"]
EXPOSE 34116
USER gonavi
# 未通过环境变量配置密码时,首次访问 /setup 初始化;认证状态落在数据目录 web_auth.json
ENTRYPOINT ["/usr/local/bin/gonavi"]
CMD ["web-server"]

212
NOTICE
View File

@@ -1,212 +0,0 @@
GoNavi
Copyright 2026 Syngnat
This product includes software developed by the following projects.
Apache RocketMQ
Copyright 2016-2022 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
Apache IoTDB
Copyright 2018-2020 The Apache Software Foundation.
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
============================================================================
IoTDB project uses 4 Chinese Patents:
* 201711384490X
* 201810111712.9
* 201711322631.5
* 201711319331.1
According to the Apache 2.0 License. The owner of the patents, Tsinghua University,
grant the users the right to the use of patent under the requirement of Apache 2.0 License.
============================================================================
Apache Thrift
Copyright (C) 2006 - 2019, The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
go-yaml
Copyright 2011-2016 Canonical Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Eclipse Paho MQTT Go
This content is produced and maintained by the Eclipse Paho project.
* Project home: https://www.eclipse.org/paho/
Note that a separate mqtt v5 client (https://github.com/eclipse/paho.golang) also
exists. It is a full rewrite and deliberately incompatible with this library.
Eclipse Mosquitto trademarks of the Eclipse Foundation. Eclipse, and the
Eclipse Logo are registered trademarks of the Eclipse Foundation.
Paho is a trademark of the Eclipse Foundation. Eclipse, and the Eclipse Logo are
registered trademarks of the Eclipse Foundation.
All content is the property of the respective authors or their employers.
For more information regarding authorship of content, please consult the
listed source code repository logs.
This program and the accompanying materials are made available under the terms of the
Eclipse Public License v2.0 and Eclipse Distribution License v1.0 which accompany this
distribution.
The Eclipse Public License is available at
https://www.eclipse.org/legal/epl-2.0/
and the Eclipse Distribution License is available at
http://www.eclipse.org/org/documents/edl-v10.php.
For an explanation of what dual-licensing means to you, see:
https://www.eclipse.org/legal/eplfaq.php#DUALLIC
SPDX-License-Identifier: EPL-2.0 or BSD-3-Clause
The project maintains the following source code repository:
* https://github.com/eclipse/paho.mqtt.golang
This project makes use of the following third-party projects:
Go Programming Language and Standard Library
* License: BSD-style license (https://golang.org/LICENSE)
Go Networking
* License: BSD 3-Clause style license and patent grant.
* Project: https://cs.opensource.google/go/x/net
Go Sync
* License: BSD 3-Clause style license and patent grant.
* Project: https://cs.opensource.google/go/x/sync/
Gorilla Websockets v1.4.2
* License: BSD 2-Clause "Simplified" License
* Project: https://github.com/gorilla/websocket
Content may contain encryption software. The country in which you are currently
may have restrictions on the import, possession, and use, and/or re-export to
another country. BEFORE using any encryption software, please check the country's
laws, regulations and policies concerning the import, possession, and use, and/or
re-export of encryption software, to see if this is permitted.
Vendored HighGo pq-sm3 (derived from lib/pq)
Copyright (c) 2011-2013, 'pq' Contributors
Portions Copyright (C) 2011 Blake Mizerany
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The vendored SCRAM implementation includes the following BSD 3-Clause notice:
Copyright (c) 2014 - Gustavo Niemeyer <gustavo@niemeyer.net>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Tailscale hujson
Project: https://github.com/tailscale/hujson
Copyright (c) 2019 Tailscale Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Vendored InterSystems IRIS native driver
Copyright (c) 2025 Dmitry Maslennikov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

516
README.md
View File

@@ -1,182 +1,42 @@
<p align="center">
<img src="assets/readme-logo.png" width="128" alt="GoNavi Logo" />
</p>
# GoNavi - A Modern Lightweight Database Client
<h1 align="center">GoNavi</h1>
[![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)
[![Stars](https://img.shields.io/github/stars/Syngnat/GoNavi?style=social)](https://github.com/Syngnat/GoNavi/stargazers)
[![Downloads](https://img.shields.io/github/downloads/Syngnat/GoNavi/total?color=blue&label=downloads)](https://github.com/Syngnat/GoNavi/releases)
<p align="center">
<b>Navigate every data source — native speed, agent-ready, zero Electron bloat.</b>
</p>
**Language**: English | [简体中文](README.zh-CN.md)
<p align="center">
A high-performance cross-platform database client built with
<a href="https://wails.io">Wails</a> (Go) + <a href="https://react.dev">React</a>.
Desktop-first. MCP-ready. ~30MB class binaries.
</p>
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.
<p align="center">
<a href="https://github.com/Syngnat/GoNavi/releases"><img src="https://img.shields.io/github/v/release/Syngnat/GoNavi?style=for-the-badge&color=8B5CF6" alt="Release" /></a>
<a href="https://github.com/Syngnat/GoNavi/releases"><img src="https://img.shields.io/github/downloads/Syngnat/GoNavi/total?style=for-the-badge&color=6366F1&label=downloads" alt="Downloads" /></a>
<a href="https://github.com/Syngnat/GoNavi/stargazers"><img src="https://img.shields.io/github/stars/Syngnat/GoNavi?style=for-the-badge&color=F59E0B" alt="Stars" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-22C55E?style=for-the-badge" alt="License" /></a>
</p>
<p align="center">
<a href="https://go.dev"><img src="https://img.shields.io/github/go-mod/go-version/Syngnat/GoNavi?style=flat-square&logo=go&logoColor=white&label=Go" alt="Go" /></a>
<a href="https://wails.io"><img src="https://img.shields.io/badge/Wails-v2-red?style=flat-square" alt="Wails" /></a>
<a href="https://reactjs.org"><img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=white" alt="React" /></a>
<a href="https://github.com/Syngnat/GoNavi/actions"><img src="https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?style=flat-square&label=Build" alt="Build" /></a>
</p>
<p align="center">
<b>Language</b>: English · <a href="README.zh-CN.md">简体中文</a>
&nbsp;·&nbsp;
<a href="https://github.com/Syngnat/GoNavi/releases"><b>⬇ Download</b></a>
·
<a href="#-quick-start"><b>⚡ Quick Start</b></a>
·
<a href="#-key-features"><b>✨ Features</b></a>
·
<a href="#-mcp--agents"><b>🤖 MCP</b></a>
</p>
Compared with many Electron-based clients, GoNavi is typically smaller in binary size (around 10MB class), starts faster, and uses less memory.
---
## Why GoNavi?
## Project Overview
Most database GUIs are Electron shells with megabytes of tax. GoNavi takes a different path:
GoNavi is designed for developers and DBAs who need a unified desktop experience across multiple databases.
| | Typical Electron client | **GoNavi** |
|---|---|---|
| Runtime | Chromium + Node | **Go + native WebView** |
| Binary size | Hundreds of MB | **~30MB class** |
| Startup | Heavy | **Fast** |
| Memory | High baseline | **Lean** |
| AI / Agents | Bolt-on or absent | **First-class MCP + multi-provider AI** |
| Data sources | Mostly RDBMS | **SQL · Cache · Vector · MQ · Search · Time-series · Domestic DBs** |
- **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.
> **One cockpit for MySQL, Postgres, Redis, Kafka, Milvus, OceanBase, ClickHouse…**
> Query, edit, audit, sync — and hand structured context to coding agents without leaking passwords off-host.
## Supported Data Sources
---
## At a Glance
```text
┌──────────────────────────────────────────────────────────────────────┐
│ GoNavi Workbench │
│ ┌─────────────┐ ┌──────────────────┐ ┌─────────────────────────┐ │
│ │ Connections │ │ Monaco SQL + AI │ │ Virtualized DataGrid │ │
│ │ SSH / Proxy │ │ Schema context │ │ Batch edit · Export │ │
│ │ Drivers │ │ Slash commands │ │ Txn submit / rollback │ │
│ └─────────────┘ └────────┬─────────┘ └─────────────────────────┘ │
│ │ │
│ ┌─────────────▼─────────────┐ │
│ │ Go core · Audit · Sync │ │
│ │ MCP HTTP · Web Server │ │
│ └───────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
### Product screenshots
Each image is a **full GoNavi application window**, scaled proportionally for README display.
<p align="center">
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/01-home-workbench.png" alt="GoNavi full window — connections, queries, and workbench" width="560" />
&nbsp;
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/04-ai-assistant.png" alt="GoNavi full window — AI assistant with schema context" width="560" />
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/06-new-connection.png" alt="GoNavi full window — new connection data-source selector" width="560" />
</p>
<p align="center"><sub>Real desktop captures · full window</sub></p>
---
## ✨ Key Features
<table>
<tr>
<td width="50%" valign="top">
### 🤖 AI that knows your schema
- OpenAI · Gemini · Claude · custom OpenAI-compatible APIs
- Attach live table schemas as context
- Slash commands: generate SQL, explain, optimize, review
- **MCP**: install into Claude Code / Codex, or Streamable HTTP for remote agents
- Secrets stay on the GoNavi host — agents get tools, not raw passwords
</td>
<td width="50%" valign="top">
### ⚡ Built for large data
- Virtualized DataGrid for heavy result sets
- In-place cell edit · batch CRUD · transaction submit/rollback
- Large-field popup editor · smart read/write modes
- Export: CSV · XLSX · JSON · Markdown
- Monaco editor with context-aware completion
</td>
</tr>
<tr>
<td width="50%" valign="top">
### 🔌 Connectivity without drama
- URI generate / parse
- SSH tunnel · proxy
- Connection JSON import / export
- On-demand optional driver agents
- Custom Driver + DSN extensibility
</td>
<td width="50%" valign="top">
### 🛡️ Observable & shippable
- SQL execution logs with timing
- Audit center (redacted by default, retention, export)
- Desktop + experimental **Web Server** mode
- Docker / K8s / Helm / Podman packaging
- Auto update checks · multi-arch releases
</td>
</tr>
</table>
### 🧩 Stack
`Go 1.24` · `Wails v2` · `React 18` · `TypeScript` · `Vite` · `Ant Design 5` · `Zustand` · `Monaco`
---
## 🗄 Supported Data Sources
> **Built-in** — ready out of the box · **Optional agent** — install via Driver Manager
| | |
|---|---|
| **Built-in** | MySQL · GoldenDB · PostgreSQL · Oracle · Redis · Chroma · Qdrant · Milvus · RocketMQ · MQTT · Kafka · RabbitMQ |
| **Optional** | MariaDB · Doris · StarRocks · Sphinx · SQL Server · SQLite · DuckDB · OceanBase · Dameng · Kingbase · HighGo · Vastbase · OpenGauss · GaussDB · IRIS · MongoDB · TDengine · IoTDB · ClickHouse · Trino · Elasticsearch · Custom Driver/DSN |
<details>
<summary><b>Full capability matrix</b></summary>
> `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 |
| Domestic DB | GoldenDB | Built-in | MySQL-compatible query workflow and distributed transaction scenarios |
| 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 |
| Vector Database | Chroma | Built-in | Collection browsing, vector retrieval, metadata filtering |
| Vector Database | Qdrant | Built-in | Collection browsing, vector search, payload filtering |
| Vector Database | Milvus | Built-in | Collection browsing, vector search, scalar filtering |
| Message Queue | RocketMQ | Built-in | Topic browsing, consumer-group inspection, message-oriented workflow |
| Message Queue | MQTT | Built-in | Broker and topic-filter workflow with QoS-aware connection settings |
| Message Queue | Kafka | Built-in | Topic browsing, broker metadata, consumer-group workflow |
| Message Queue | RabbitMQ | Built-in | Queue/exchange browsing, virtual host inspection, management API workflow |
| Relational | MariaDB | Optional driver agent | Querying, object management, data editing |
| Relational | Doris | Optional driver agent | Querying, object browsing, SQL execution |
| Columnar Analytics | StarRocks | Optional driver agent | Querying, object browsing, SQL execution |
@@ -184,234 +44,162 @@ Each image is a **full GoNavi application window**, scaled proportionally for RE
| 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 | OceanBase | Optional driver agent | MySQL / Oracle tenant access, object browsing, query 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 |
| Domestic DB | OpenGauss | Optional driver agent | PostgreSQL-like schema browsing, SQL query, object management |
| Domestic DB | GaussDB | Optional driver agent | PostgreSQL-like schema browsing, SQL query, object management |
| Multi-model | InterSystems IRIS | Optional driver agent | Namespace browsing, SQL query, object management |
| Document | MongoDB | Optional driver agent | Document query, collection browsing, connection management |
| Time-series | TDengine | Optional driver agent | Time-series schema browsing and querying |
| Time-series | Apache IoTDB | Optional driver agent | Storage group / device / timeseries browsing and querying |
| Columnar Analytics | ClickHouse | Optional driver agent | Analytical query, object browsing, SQL execution |
| Federated Query | Trino | Optional driver agent | Cross-source SQL via multiple catalogs, `catalog.schema` browsing, SQL execution |
| Search | Elasticsearch | Optional driver agent | Index browsing, mapping inspection, JSON DSL / query_string search |
| Extensibility | Custom Driver/DSN | Custom | Extend to more data sources via Driver + DSN |
</details>
<h2 align="center">📸 Screenshots</h2>
<div align="center">
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
<br />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
</div>
---
## 🚀 Quick Start
## Key Features
### AI Assistant (New)
- **Multi-provider Support**: OpenAI, Google Gemini, Anthropic Claude, and custom API support.
- **Context-Aware Chat**: Attach table schemas to the AI context for accurate SQL generation and assistance.
- **Slash Commands**: Quick commands for generating SQL, explaining queries, optimizing performance, and reviewing schema designs.
### Performance
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
- **Virtualized rendering**: keeps large result sets responsive.
### 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.
### 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.
### Connectivity
- URI generation/parsing.
- SSH tunnel support.
- Proxy support.
- Config import/export (JSON).
- Optional driver management and activation.
### 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.
### 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
- **State Management**: Zustand
- **Editor**: Monaco Editor
---
## Installation and Run
### Prerequisites
- [Go](https://go.dev/dl/) 1.21+
- [Node.js](https://nodejs.org/) 18+
- [Wails CLI](https://wails.io/docs/gettingstarted/installation)
- [Wails CLI](https://wails.io/docs/gettingstarted/installation):
`go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0`
```bash
go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
```
### Development Mode
### Develop
```bash
```shell
# Clone
git clone https://github.com/Syngnat/GoNavi.git
cd GoNavi
wails dev # full hot reload
node tools/wails-fast-dev.mjs # faster when Go exports unchanged
node tools/wails-fast-dev.mjs --refresh-bindings # after Go export signature changes
# Start development with hot reload
wails dev
# Faster local startup when exported Go method signatures are unchanged
node tools/wails-fast-dev.mjs
# Refresh Wails JS bindings after changing exported Go method signatures
node tools/wails-fast-dev.mjs --refresh-bindings
# Windows PowerShell low-memory visual mode: disables transparent WebView/Acrylic backdrop
$env:GONAVI_LOW_MEMORY_MODE="1"; node tools/wails-fast-dev.mjs
```
### Build
```bash
# Build for current platform
wails build
wails build -clean # clean build before release
# Clean build (recommended before release)
wails build -clean
```
Artifacts `build/bin`.
Artifacts are generated in `build/bin`.
### Prefer a binary?
### Cross-Platform Release (GitHub Actions)
Grab the latest build from **[Releases](https://github.com/Syngnat/GoNavi/releases)**
(macOS AMD64/ARM64 · Windows AMD64 · Linux WebKitGTK 4.0/4.1).
The repository includes a release workflow.
Push a `v*` tag to trigger automated build and release.
Release notes are generated automatically from merged pull requests and categorized by `.github/release.yaml`.
Target artifacts include:
- macOS (AMD64 / ARM64)
- Windows (AMD64)
- Linux (AMD64, WebKitGTK 4.0 and 4.1 variants)
---
## 🌐 Web Server (Experimental)
## Troubleshooting
Same Go backend + React UI over HTTP — **not** a containerized Wails window.
### macOS: "App is damaged and cant be opened"
```powershell
go build .
.\GoNavi-Wails.exe web-server --addr 127.0.0.1:34116
```
Without Apple notarization, Gatekeeper may block startup.
- First visit → `/setup` (admin password; optional Google Authenticator)
- Bridge: `window.go.*` / `window.runtime.*` → HTTP / SSE
- Sessions, recovery codes, login rate limits
#### Docker / Podman
```bash
cp docker.web-server.env.example docker.web-server.env
# set GONAVI_HOST_DATA_ROOT (absolute path)
# optional: GONAVI_WEB_PASSWORD (min 6 chars)
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d
```
Open `http://127.0.0.1:34116`. Mount the active data root at `/data`
(`connections.json`, `daily_secrets.json`, optional `drivers/`).
Auth state → `web_auth.json`.
> **Do not expose an unhardened web entry to the public internet.** Use reverse proxy + HTTPS in production.
Health: `GET /__gonavi/healthz`
Local source build: add `-f docker-compose.web-server.local.yml --build`.
After changing env passwords:
```bash
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d --force-recreate
```
(`docker restart` does **not** reload env files.)
---
## 🤖 MCP & Agents
Ship schema tools to agents without shipping your vault:
```bash
cp docker.mcp-server.env.example docker.mcp-server.env
docker compose --env-file docker.mcp-server.env -f docker-compose.mcp-server.yml up -d
```
| Surface | Entry |
|---|---|
| MCP container | `docker-compose.mcp-server.yml``ghcr.io/syngnat/gonavi-mcp-server` |
| Web UI container | `docker-compose.web-server.yml``ghcr.io/syngnat/gonavi-web-server` |
| Podman / Quadlet | [deploy/podman/gonavi-mcp-server](deploy/podman/gonavi-mcp-server) |
| Kubernetes | [deploy/k8s/gonavi-mcp-server](deploy/k8s/gonavi-mcp-server) |
| Helm | [deploy/helm/gonavi-mcp-server](deploy/helm/gonavi-mcp-server) |
| Build-only image | `Dockerfile.build-env``ghcr.io/syngnat/gonavi-build-env` |
Safety defaults: remote `schema-only` omits `execute_sql`; mutating SQL requires explicit `allowMutating=true`.
Details: [cmd/gonavi-mcp-server/README.md](cmd/gonavi-mcp-server/README.md).
### Linux build environment only
```bash
docker build -f Dockerfile.build-env -t gonavi-build-env:local .
docker run --rm -it -v "$PWD:/workspace" -w /workspace gonavi-build-env:local bash
```
---
## 📦 Release Pipeline
Push a `v*` tag → GitHub Actions builds multi-arch releases.
Notes auto-generated from merged PRs via `.github/release.yaml`.
---
## 🛠 Troubleshooting
<details>
<summary><b>Windows: missing Microsoft Edge WebView2 Runtime (common on intranet images)</b></summary>
The GoNavi desktop app on Windows depends on the **Microsoft Edge WebView2 Runtime** (a system component, not full Chrome).
Some intranet / thin / Server / LTSC images ship without it. Symptoms:
- Process exits immediately, blank/white window
- Errors about missing WebView2 / WebView2 Runtime
- Installer blocked by AV or group policy
### 1. Check whether Runtime is installed
In **PowerShell**:
```powershell
# Common Evergreen install path (64-bit Windows)
Test-Path "${env:ProgramFiles(x86)}\Microsoft\EdgeWebView\Application"
# Registry (a `pv` version usually means installed)
Get-ItemProperty -Path "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" `
-ErrorAction SilentlyContinue |
Select-Object pv, name
```
If the path is missing and registry has no `pv`, install the Runtime.
### 2. Online install (simplest)
1. Open the official download page:
[Microsoft Edge WebView2](https://developer.microsoft.com/microsoft-edge/webview2/)
2. Download the **Evergreen Bootstrapper** (small; needs network during install)
3. Run as Administrator, then **restart GoNavi**
### 3. Offline / intranet: Standalone installer (recommended for IT)
The Bootstrapper fails on fully air-gapped machines. Use the **Evergreen Standalone Installer** instead:
1. On a machine with internet, download the matching architecture package, e.g.:
- `MicrosoftEdgeWebView2RuntimeInstallerX64.exe` (most 64-bit PCs)
- `…X86.exe` / `…ARM64.exe` as needed
2. Copy the installer into the intranet (USB, software center, share)
3. Install **as Administrator** on the target PC:
```powershell
# Interactive
.\MicrosoftEdgeWebView2RuntimeInstallerX64.exe
# Silent (batch / SCCM friendly)
.\MicrosoftEdgeWebView2RuntimeInstallerX64.exe /silent /install
```
4. Start GoNavi again. If it still fails, sign out or reboot Windows once.
### 4. Group policy / locked-down PCs
- Needs local admin, or IT push via SCCM / software center
- Ensure policies do not block Edge/WebView2 install or updates
- Enterprises may pin a [Fixed Version](https://developer.microsoft.com/microsoft-edge/webview2/) Runtime (most users should prefer Evergreen)
### 5. Temporary workaround: Web Server mode
If the desktop WebView cannot be installed yet, run the experimental Web Server and use a normal browser:
```powershell
.\GoNavi.exe web-server --addr 127.0.0.1:34116
```
Open `http://127.0.0.1:34116`. See the **Web Server** section above.
Do **not** expose an unhardened Web endpoint to the public internet.
Tracker / discussion: [#672](https://github.com/Syngnat/GoNavi/issues/672).
</details>
<details>
<summary><b>macOS: “App is damaged and cant be opened”</b></summary>
Without Apple notarization, Gatekeeper may block the app:
1. Move `GoNavi.app` to **Applications**.
2. Open **Terminal**.
3. Run:
```bash
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
```
Or right-click → Open (Control-click flow). Move the app to **Applications** first.
Or right-click the app in Finder and choose **Open** with Control key flow.
</details>
### Linux: missing `libwebkit2gtk` / `libjavascriptcoregtk`
<details>
<summary><b>Linux: missing WebKitGTK</b></summary>
GoNavi depends on WebKitGTK runtime libraries.
```bash
# Debian 13 / Ubuntu 24.04+
@@ -423,12 +211,11 @@ sudo apt-get update
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
```
Artifacts with `-WebKit41` prefer Debian 13 / Ubuntu 24.04+.
If you use Linux artifacts with the `-WebKit41` suffix, prefer Debian 13 / Ubuntu 24.04+.
</details>
### Linux: Chinese text appears as square boxes
<details>
<summary><b>Linux: Chinese glyphs as tofu boxes</b></summary>
Minimal Ubuntu 24.04 LTS desktop/server environments may not include Chinese CJK fonts. Install Noto / WenQuanYi fonts and restart GoNavi:
```bash
sudo apt-get update
@@ -436,32 +223,19 @@ sudo apt-get install -y fonts-noto-cjk fonts-wqy-microhei
fc-cache -fv
```
</details>
---
## 🤝 Contributing
## Contributing
Issues and PRs welcome. Branch from **`dev`**, PR against **`dev`**.
Issues and pull requests are welcome.
For the full workflow, branch model, and maintainer sync rules, see:
- [CONTRIBUTING.md](CONTRIBUTING.md)
- Call for contributors: [Issue #671](https://github.com/Syngnat/GoNavi/issues/671)
---
## 💬 Contact & Feedback
| Channel | Notes |
|---|---|
| **GitHub Issues** | Preferred for bugs, feature requests, and docs — easier to track and reproduce |
| **WeChat** | `ygf1140302783` (note “GoNavi”) — maintainer can invite you to the community group for discussion |
> For technical reports, include version, OS, and repro steps when possible. Windows blank/crash on intranet images: [WebView2 guide](https://github.com/Syngnat/GoNavi/issues/672).
---
## ⭐ Star History
External contributors should branch from `dev` and open pull requests against `dev`.
## Star History
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
@@ -470,8 +244,6 @@ Issues and PRs welcome. Branch from **`dev`**, PR against **`dev`**.
</picture>
</a>
---
## Links
- [linux.do](https://linux.do/)
@@ -479,8 +251,4 @@ Issues and PRs welcome. Branch from **`dev`**, PR against **`dev`**.
## License
[Apache-2.0](LICENSE)
<p align="center">
<sub>Built for people who live in SQL, schemas, and agent loops.</sub>
</p>
Licensed under [Apache-2.0](LICENSE).

View File

@@ -1,181 +1,41 @@
<p align="center">
<img src="assets/readme-logo.png" width="128" alt="GoNavi Logo" />
</p>
# GoNavi - 现代化轻量级数据库客户端
<h1 align="center">GoNavi</h1>
[![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)
[![Stars](https://img.shields.io/github/stars/Syngnat/GoNavi?style=social)](https://github.com/Syngnat/GoNavi/stargazers)
[![Downloads](https://img.shields.io/github/downloads/Syngnat/GoNavi/total?color=blue&label=downloads)](https://github.com/Syngnat/GoNavi/releases)
<p align="center">
<b>一套客户端,航行全部数据源 — 原生性能 · Agent 就绪 · 告别 Electron 膨胀。</b>
</p>
**语言**: [English](README.md) | 简体中文
<p align="center">
基于 <a href="https://wails.io">Wails</a>Go+ <a href="https://react.dev">React</a> 的跨平台数据库工作台。
桌面优先MCP 原生,二进制约 <b>30MB</b> 量级。
</p>
GoNavi 是基于 **Wails (Go)****React** 构建的跨平台数据库管理工具,强调原生性能、低资源占用与多数据源统一工作流。
<p align="center">
<a href="https://github.com/Syngnat/GoNavi/releases"><img src="https://img.shields.io/github/v/release/Syngnat/GoNavi?style=for-the-badge&color=8B5CF6" alt="Release" /></a>
<a href="https://github.com/Syngnat/GoNavi/releases"><img src="https://img.shields.io/github/downloads/Syngnat/GoNavi/total?style=for-the-badge&color=6366F1&label=downloads" alt="Downloads" /></a>
<a href="https://github.com/Syngnat/GoNavi/stargazers"><img src="https://img.shields.io/github/stars/Syngnat/GoNavi?style=for-the-badge&color=F59E0B" alt="Stars" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-22C55E?style=for-the-badge" alt="License" /></a>
</p>
<p align="center">
<a href="https://go.dev"><img src="https://img.shields.io/github/go-mod/go-version/Syngnat/GoNavi?style=flat-square&logo=go&logoColor=white&label=Go" alt="Go" /></a>
<a href="https://wails.io"><img src="https://img.shields.io/badge/Wails-v2-red?style=flat-square" alt="Wails" /></a>
<a href="https://reactjs.org"><img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=white" alt="React" /></a>
<a href="https://github.com/Syngnat/GoNavi/actions"><img src="https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?style=flat-square&label=Build" alt="Build" /></a>
</p>
<p align="center">
<b>语言</b><a href="README.md">English</a> · 简体中文
&nbsp;·&nbsp;
<a href="https://github.com/Syngnat/GoNavi/releases"><b>⬇ 下载</b></a>
·
<a href="#-快速开始"><b>⚡ 快速开始</b></a>
·
<a href="#-核心能力"><b>✨ 特性</b></a>
·
<a href="#-mcp--agents"><b>🤖 MCP</b></a>
</p>
相比常见 Electron 客户端GoNavi 在体积、启动速度和内存占用上更轻量。
---
## 为什么是 GoNavi
## 项目简介
市面上大量数据库 GUI 仍是 Electron 壳体积大、启动慢、内存高。GoNavi 换了一条路:
GoNavi 面向开发者与 DBA核心目标是让数据库操作在桌面端做到“快、稳、统一”。
| | 常见 Electron 客户端 | **GoNavi** |
|---|---|---|
| 运行时 | Chromium + Node | **Go + 系统 WebView** |
| 体积 | 动辄数百 MB | **约 30MB 量级** |
| 启动 | 偏重 | **更快** |
| 内存 | 基线高 | **更轻** |
| AI / Agent | 外挂或缺失 | **MCP + 多模型一等公民** |
| 数据源 | 以 RDBMS 为主 | **SQL · 缓存 · 向量 · 消息 · 搜索 · 时序 · 国产库** |
- **原生性能架构**WailsGo + WebView降低运行时开销。
- **大数据可用性**:虚拟滚动 + DataGrid 交互优化,提升大结果集可操作性。
- **统一连接能力**:支持 URI 生成/解析、SSH 隧道、代理、驱动按需安装。
- **工程化能力完整**:覆盖 SQL 编辑、对象管理、批量导出/备份、数据同步、执行日志、在线更新。
> **MySQL、Postgres、Redis、Kafka、Milvus、OceanBase、ClickHouse… 一个工作台打通。**
> 查询、编辑、审计、同步;把结构化上下文交给编码 Agent密码仍留在本机。
## 支持的数据源
---
## 一眼看懂
```text
┌──────────────────────────────────────────────────────────────────────┐
│ GoNavi 工作台 │
│ ┌─────────────┐ ┌──────────────────┐ ┌─────────────────────────┐ │
│ │ 连接管理 │ │ Monaco SQL + AI │ │ 虚拟滚动 DataGrid │ │
│ │ SSH / 代理 │ │ 表结构上下文 │ │ 批量编辑 · 导出 │ │
│ │ 驱动代理 │ │ 快捷指令 │ │ 事务提交 / 回滚 │ │
│ └─────────────┘ └────────┬─────────┘ └─────────────────────────┘ │
│ │ │
│ ┌─────────────▼─────────────┐ │
│ │ Go 核心 · 审计 · 同步 │ │
│ │ MCP HTTP · Web Server │ │
│ └───────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
### 产品截图
每张均为 **完整 GoNavi 应用窗口**,再通过 README 宽度控件等比例缩小展示。
<p align="center">
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/01-home-workbench.png" alt="GoNavi 全窗口 — 连接、查询与工作台" width="560" />
&nbsp;
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/04-ai-assistant.png" alt="GoNavi 全窗口 — 带表结构上下文的 AI 助手" width="560" />
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/06-new-connection.png" alt="GoNavi 全窗口 — 新建连接的数据源选择器" width="560" />
</p>
<p align="center"><sub>真实桌面全窗口截图</sub></p>
---
## ✨ 核心能力
<table>
<tr>
<td width="50%" valign="top">
### 🤖 懂表结构的 AI
- OpenAI · Gemini · Claude · 自定义 OpenAI 兼容 API
- 将当前库表结构注入对话上下文
- 快捷指令:生成 SQL、解释、优化、表设计评审
- **MCP**:一键装到 Claude Code / Codex或 Streamable HTTP 给远端 Agent
- 连接与密码留在运行 GoNavi 的主机上
</td>
<td width="50%" valign="top">
### ⚡ 大数据仍流畅
- 虚拟滚动 DataGrid扛住大结果集
- 单元格编辑 · 批量增删改 · 事务提交/回滚
- 大字段弹窗 · 按查询智能切换读写
- 导出CSV · XLSX · JSON · Markdown
- Monaco + 库/表/字段上下文补全
</td>
</tr>
<tr>
<td width="50%" valign="top">
### 🔌 连接与驱动
- URI 生成 / 解析
- SSH 隧道 · 代理
- 连接配置 JSON 导入导出
- 可选驱动代理按需安装
- Custom Driver + DSN 扩展
</td>
<td width="50%" valign="top">
### 🛡️ 可观测 & 可交付
- SQL 执行日志(含耗时)
- 审计中心(默认脱敏、保留策略、导出)
- 桌面端 + 实验性 **Web Server**
- Docker / K8s / Helm / Podman
- 更新检查 · 多架构发布
</td>
</tr>
</table>
### 🧩 技术栈
`Go 1.24` · `Wails v2` · `React 18` · `TypeScript` · `Vite` · `Ant Design 5` · `Zustand` · `Monaco`
---
## 🗄 支持的数据源
> **内置**:开箱即用 · **可选驱动代理**:在驱动管理中安装启用
| | |
|---|---|
| **内置** | MySQL · GoldenDB · PostgreSQL · Oracle · Redis · Chroma · Qdrant · Milvus · RocketMQ · MQTT · Kafka · RabbitMQ |
| **可选** | MariaDB · Doris · StarRocks · Sphinx · SQL Server · SQLite · DuckDB · OceanBase · 达梦 · 人大金仓 · 瀚高 · 海量 · openGauss · GaussDB · IRIS · MongoDB · TDengine · IoTDB · ClickHouse · Trino · Elasticsearch · Custom Driver/DSN |
<details>
<summary><b>完整能力矩阵</b></summary>
> `内置`:主程序开箱即用。
> `可选驱动代理`:需在驱动管理中安装启用后可用。
| 类别 | 数据源 | 驱动模式 | 典型能力 |
|---|---|---|---|
| 关系型 | MySQL | 内置 | 库表浏览、SQL 查询、数据编辑、导出/备份 |
| 国产数据库 | GoldenDB | 内置 | MySQL 兼容查询工作流、分布式事务场景 |
| 关系型 | PostgreSQL | 内置 | 库表浏览、SQL 查询、数据编辑、对象管理 |
| 关系型 | Oracle | 内置 | 连接查询、对象浏览、数据编辑 |
| 缓存 | Redis | 内置 | Key 浏览、命令执行、编码/视图切换 |
| 向量数据库 | Chroma | 内置 | Collection 浏览、向量检索、元数据过滤 |
| 向量数据库 | Qdrant | 内置 | Collection 浏览、向量搜索、Payload 过滤 |
| 向量数据库 | Milvus | 内置 | Collection 浏览、向量搜索、标量过滤 |
| 消息队列 | RocketMQ | 内置 | Topic 浏览、消费组检查、消息型工作流 |
| 消息队列 | MQTT | 内置 | Broker / Topic Filter 工作流与 QoS 连接配置 |
| 消息队列 | Kafka | 内置 | Topic 浏览、Broker 元数据、消费组工作流 |
| 消息队列 | RabbitMQ | 内置 | Queue / Exchange 浏览、Virtual Host 检查、Management API 工作流 |
| 关系型 | MariaDB | 可选驱动代理 | 连接查询、对象管理、数据编辑 |
| 关系型 | Doris | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
| 列式分析 | StarRocks | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
@@ -183,235 +43,148 @@
| 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 |
| 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 |
| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理 |
| 国产数据库 | OceanBase | 可选驱动代理 | MySQL / Oracle 租户接入、对象浏览、查询工作流 |
| 国产数据库 | Dameng | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | Kingbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | HighGo | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | Vastbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | OpenGauss | 可选驱动代理 | 类 PostgreSQL 的库表浏览、SQL 查询、对象管理 |
| 国产数据库 | GaussDB | 可选驱动代理 | 类 PostgreSQL 的库表浏览、SQL 查询、对象管理 |
| 多模型数据库 | InterSystems IRIS | 可选驱动代理 | Namespace 浏览、SQL 查询、对象管理 |
| 文档型 | MongoDB | 可选驱动代理 | 文档查询、集合浏览、连接管理 |
| 时序 | TDengine | 可选驱动代理 | 时序库表浏览、查询分析 |
| 时序 | Apache IoTDB | 可选驱动代理 | Storage Group / Device / Timeseries 浏览与查询 |
| 列式分析 | ClickHouse | 可选驱动代理 | 分析查询、对象浏览、SQL 执行 |
| 联邦查询 | Trino | 可选驱动代理 | 跨多数据源联邦 SQL、`catalog.schema` 浏览、SQL 执行 |
| 搜索 | Elasticsearch | 可选驱动代理 | 索引浏览、Mapping 检查、JSON DSL / query_string 查询 |
| 扩展接入 | Custom Driver/DSN | 自定义 | 通过 Driver + DSN 接入更多数据源 |
</details>
<h2 align="center">📸 项目截图</h2>
<div align="center">
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
<br />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
</div>
---
## 🚀 快速开始
## 核心特性
### AI 智能助手 (New)
- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude同时支持任意自定义兼容 OpenAI 格式的 API。
- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI让 SQL 生成、分析变得更精准。
- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等
### 性能与交互
- 大数据场景下保持流畅交互(含 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)
- [Wails CLI](https://wails.io/docs/gettingstarted/installation):
`go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0`
```bash
go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
```
### 开发模式
### 开发
```bash
```shell
# 克隆项目
git clone https://github.com/Syngnat/GoNavi.git
cd GoNavi
wails dev # 完整热重载
node tools/wails-fast-dev.mjs # Go 导出签名未变时更快
node tools/wails-fast-dev.mjs --refresh-bindings # 修改导出方法签名后刷新绑定
# 启动开发(热重载
wails dev
# 本地快速启动:未修改 Go 导出方法签名时使用
node tools/wails-fast-dev.mjs
# 修改 Go 导出方法签名后刷新 Wails JS 绑定
node tools/wails-fast-dev.mjs --refresh-bindings
# Windows PowerShell 低内存视觉模式:关闭透明 WebView 和 Acrylic 背景
$env:GONAVI_LOW_MEMORY_MODE="1"; node tools/wails-fast-dev.mjs
```
### 构建
### 编译构建
```bash
# 构建当前平台
wails build
wails build -clean # 发布前推荐
# 清理后构建(发布前推荐)
wails build -clean
```
产物位于 `build/bin`
构建产物位于 `build/bin`
### 只想直接用?
### 跨平台发布GitHub Actions
前往 **[Releases](https://github.com/Syngnat/GoNavi/releases)** 下载
macOS AMD64/ARM64 · Windows AMD64 · Linux WebKitGTK 4.0/4.1
仓库内置发布流水线,推送 `v*` Tag 可自动构建并发布 Release。
Release 更新说明会基于已合并 Pull Request 自动生成,并按 `.github/release.yaml` 分类
支持目标:
- macOS (AMD64 / ARM64)
- Windows (AMD64)
- Linux (AMD64含 WebKitGTK 4.0 / 4.1 变体)
---
## 🌐 Web Server实验中
## 常见问题
同一套 Go 后端 + React 前端提供浏览器访问(**不是**把 Wails 桌面窗口容器化)。
### macOS 提示“应用已损坏,无法打开”
```powershell
go build .
.\GoNavi-Wails.exe web-server --addr 127.0.0.1:34116
```
- 首次访问进入 `/setup`(管理员密码;可选 Google Authenticator
- 桥接:`window.go.*` / `window.runtime.*` → HTTP / SSE
- Session、恢复码、登录限流
#### Docker / Podman
```bash
cp docker.web-server.env.example docker.web-server.env
# 设置 GONAVI_HOST_DATA_ROOT 为绝对路径
# 可选GONAVI_WEB_PASSWORD至少 6 位)
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d
```
打开 `http://127.0.0.1:34116`。将活动数据目录挂载到 `/data`
`connections.json``daily_secrets.json`,可选 `drivers/`)。
认证状态写入 `web_auth.json`
> **不要把未加固的 Web 入口直接暴露到公网。** 生产环境请配合反向代理与 HTTPS。
健康检查:`GET /__gonavi/healthz`
本地源码构建:叠加 `-f docker-compose.web-server.local.yml --build`
修改 env 密码后需重建容器:
```bash
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d --force-recreate
```
`docker restart` **不会**重新加载 env 文件。)
---
## 🤖 MCP & Agents
把结构工具交给 Agent而不是把密钥交出去
```bash
cp docker.mcp-server.env.example docker.mcp-server.env
docker compose --env-file docker.mcp-server.env -f docker-compose.mcp-server.yml up -d
```
| 形态 | 入口 |
|---|---|
| MCP 容器 | `docker-compose.mcp-server.yml``ghcr.io/syngnat/gonavi-mcp-server` |
| Web UI 容器 | `docker-compose.web-server.yml``ghcr.io/syngnat/gonavi-web-server` |
| Podman / Quadlet | [deploy/podman/gonavi-mcp-server](deploy/podman/gonavi-mcp-server) |
| Kubernetes | [deploy/k8s/gonavi-mcp-server](deploy/k8s/gonavi-mcp-server) |
| Helm | [deploy/helm/gonavi-mcp-server](deploy/helm/gonavi-mcp-server) |
| 仅构建镜像 | `Dockerfile.build-env``ghcr.io/syngnat/gonavi-build-env` |
安全默认:远端 `schema-only` 不暴露 `execute_sql`;变更类 SQL 需显式 `allowMutating=true`
详见:[cmd/gonavi-mcp-server/README.md](cmd/gonavi-mcp-server/README.md)。
### 仅 Linux 构建环境
```bash
docker build -f Dockerfile.build-env -t gonavi-build-env:local .
docker run --rm -it -v "$PWD:/workspace" -w /workspace gonavi-build-env:local bash
```
---
## 📦 发布流水线
推送 `v*` Tag → GitHub Actions 自动多架构构建。
Release 说明按 `.github/release.yaml` 从已合并 PR 生成。
---
## 🛠 常见问题
<details>
<summary><b>Windows缺少 Microsoft Edge WebView2 运行时(内网常见)</b></summary>
GoNavi 桌面版在 Windows 上依赖 **Microsoft Edge WebView2 Runtime**(系统级组件,不是完整 Chrome
部分内网 / 精简镜像 / Windows Server / LTSC 机器未预装,会出现:
- 双击后窗口一闪即关、白屏、空白窗口
- 提示找不到 WebView2 / WebView2 Runtime
- 杀软或组策略拦截了运行时安装
### 1. 先确认是否已安装
**PowerShell** 中执行:
```powershell
# Evergreen Runtime 常见安装目录64 位系统)
Test-Path "${env:ProgramFiles(x86)}\Microsoft\EdgeWebView\Application"
# 注册表(有显示名称通常表示已安装)
Get-ItemProperty -Path "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" `
-ErrorAction SilentlyContinue |
Select-Object pv, name
```
若路径不存在且注册表无 `pv` 版本号,需要安装运行时。
### 2. 有外网:在线安装(最简单)
1. 打开微软官方下载页:
[Microsoft Edge WebView2](https://developer.microsoft.com/microsoft-edge/webview2/)
2. 下载 **Evergreen Bootstrapper**(体积小,安装时联网拉完整运行时)
3. 右键「以管理员身份运行」安装完成后,**重新打开 GoNavi**
### 3. 内网 / 离线:独立安装包(推荐企业分发)
Bootstrapper 在完全断网环境会失败。请改用 **Evergreen Standalone Installer**
1. 在一台能上网的电脑打开同一下载页,下载对应架构的独立安装包,例如:
- `MicrosoftEdgeWebView2RuntimeInstallerX64.exe`(绝大多数 64 位 Windows
- `…X86.exe` / `…ARM64.exe`(按机器架构选择)
2. 将安装包拷贝到内网U 盘、软件中心、文件共享均可)
3. 在目标机 **管理员权限** 安装:
```powershell
# 交互安装
.\MicrosoftEdgeWebView2RuntimeInstallerX64.exe
# 静默安装(便于 IT 批量推送)
.\MicrosoftEdgeWebView2RuntimeInstallerX64.exe /silent /install
```
4. 安装完成后重新启动 GoNavi。若仍异常注销/重启一次 Windows 再试。
### 4. 组策略 / 权限受限时
- 需要本地管理员或由 IT 通过 SCCM / 软件中心推送 Standalone 安装包
- 确认未禁用 Edge / WebView2 相关更新与安装策略
- 企业可固定使用 [Fixed Version](https://developer.microsoft.com/microsoft-edge/webview2/) 运行时并由管理员统一维护(一般用户优先 Evergreen
### 5. 临时绕过Web Server 模式(仍需能跑 GoNavi 后端)
若桌面 WebView 短期无法装上,可在本机用浏览器访问实验性 Web Server**不是**把桌面窗口容器化):
```powershell
# 以发布包中的可执行文件为例
.\GoNavi.exe web-server --addr 127.0.0.1:34116
```
浏览器打开 `http://127.0.0.1:34116`。详见上文「Web Server」章节。
注意:未加固的 Web 入口不要直接暴露到公网。
更多说明与反馈入口见 Issue[#672](https://github.com/Syngnat/GoNavi/issues/672)。
</details>
<details>
<summary><b>macOS提示「应用已损坏无法打开」</b></summary>
未做 Apple Notarization 时Gatekeeper 可能拦截:
在未进行 Apple Notarization 时Gatekeeper 可能拦截应用。
```bash
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
```
也可在 Finder 中右键打开。建议先移到「应用程序」。
</details>
<details>
<summary><b>Linux缺少 WebKitGTK</b></summary>
### Linux 缺少 `libwebkit2gtk` / `libjavascriptcoregtk`
```bash
# Debian 13 / Ubuntu 24.04+
@@ -423,12 +196,9 @@ sudo apt-get update
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
```
`-WebKit41` 后缀的产物更适配 Debian 13 / Ubuntu 24.04+。
### Linux 中文显示为方框
</details>
<details>
<summary><b>Linux中文显示为方框</b></summary>
Ubuntu 24.04 LTS 的最小化桌面或服务器环境可能没有安装中文 CJK 字体GoNavi 打开后中文会显示为方框。安装 Noto / 文泉驿字体后重启 GoNavi
```bash
sudo apt-get update
@@ -436,31 +206,19 @@ sudo apt-get install -y fonts-noto-cjk fonts-wqy-microhei
fc-cache -fv
```
</details>
---
## 🤝 贡献
## 贡献指南
欢迎 Issue 与 PR。请从 **`dev`** 拉分支,并向 **`dev`** 提 PR
欢迎提交 Issue 与 Pull Request
完整流程、分支模型与维护者同步规则请查看:
- [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md)
- 共建邀请:[Issue #671](https://github.com/Syngnat/GoNavi/issues/671)
---
外部贡献者应从 `dev` 拉出分支,并统一向 `dev` 发起 Pull Request。
## 💬 联系与反馈
| 渠道 | 说明 |
|---|---|
| **GitHub Issues** | Bug、功能建议、文档问题优先在此提交便于跟踪与复现 |
| **微信** | `ygf1140302783`备注「GoNavi」— 可拉交流群,适合使用讨论与快速沟通 |
> 技术问题尽量附版本号、系统与复现步骤;内网 Windows 启动白屏/闪退见 [WebView2 说明](https://github.com/Syngnat/GoNavi/issues/672)。
---
## ⭐ Star History
## Star History (Star 增长趋势)
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
<picture>
@@ -470,8 +228,6 @@ fc-cache -fv
</picture>
</a>
---
## 友情链接
- [linux.do](https://linux.do/)
@@ -479,8 +235,4 @@ fc-cache -fv
## 开源协议
[Apache-2.0](LICENSE)
<p align="center">
<sub>为泡在 SQL、表结构与 Agent 循环里的人而造。</sub>
</p>
本项目采用 [Apache-2.0 协议](LICENSE)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

View File

@@ -5,7 +5,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss gaussdb iris mongodb tdengine iotdb clickhouse elasticsearch trino)
DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss iris mongodb tdengine clickhouse elasticsearch)
DEFAULT_PLATFORMS=(darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64)
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip"
@@ -42,9 +42,8 @@ normalize_driver() {
case "$name" in
doris|diros) echo "doris" ;;
open_gauss|open-gauss) echo "opengauss" ;;
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
elasticsearch|elastic) echo "elasticsearch" ;;
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse|trino)
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|iris|mongodb|tdengine|clickhouse)
echo "$name"
;;
*)

View File

@@ -355,9 +355,6 @@ if command -v sha256sum &> /dev/null; then
: > SHA256SUMS
for f in *; do
[ -f "$f" ] || continue
case "$f" in
SHA256SUMS|latest.json|latest-dev.json) continue ;;
esac
sha256sum "$f" >> SHA256SUMS
done
cd ..
@@ -366,9 +363,6 @@ elif command -v shasum &> /dev/null; then
: > SHA256SUMS
for f in *; do
[ -f "$f" ] || continue
case "$f" in
SHA256SUMS|latest.json|latest-dev.json) continue ;;
esac
shasum -a 256 "$f" >> SHA256SUMS
done
cd ..
@@ -376,35 +370,6 @@ else
echo -e "${YELLOW} ⚠️ 未找到 sha256sum/shasum跳过校验文件生成。${NC}"
fi
echo -e "${GREEN}📄 生成静态更新清单...${NC}"
if command -v python3 &> /dev/null; then
# 正式版latest.jsondev/test 版本latest-dev.json对应客户端 dev 通道)
case "$VERSION" in
dev-*|*-dev*|*dev*|*test*)
TAG_FOR_MANIFEST="dev-latest"
CHANNEL_FOR_MANIFEST="dev"
OUT_MANIFEST="$DIST_DIR/latest-dev.json"
;;
*)
TAG_FOR_MANIFEST="v${VERSION#v}"
CHANNEL_FOR_MANIFEST="latest"
OUT_MANIFEST="$DIST_DIR/latest.json"
;;
esac
if python3 tools/generate-update-latest-manifest.py \
--assets-dir "$DIST_DIR" \
--version "$VERSION" \
--tag "$TAG_FOR_MANIFEST" \
--channel "$CHANNEL_FOR_MANIFEST" \
--output "$OUT_MANIFEST"; then
echo -e "${GREEN} ✅ 已生成 $OUT_MANIFEST (channel=${CHANNEL_FOR_MANIFEST})${NC}"
else
echo -e "${YELLOW} ⚠️ 生成更新清单失败(不影响本地产物,正式/dev 发版由 CI 生成)${NC}"
fi
else
echo -e "${YELLOW} ⚠️ 未找到 python3跳过更新清单${NC}"
fi
echo ""
if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then
echo -e "${RED}❌ 构建未完全成功,失败平台:${BUILD_FAILURES[*]}${NC}"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -1,100 +0,0 @@
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Package Name="$(var.ProductName)"
Manufacturer="Syngnat"
Language="1033"
Version="$(var.ProductVersion)"
ProductCode="*"
UpgradeCode="$(var.UpgradeCode)"
Scope="perMachine"
InstallerVersion="500"
Compressed="yes">
<MajorUpgrade AllowDowngrades="yes"
Schedule="afterInstallInitialize" />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<Icon Id="GoNaviIcon" SourceFile="$(var.IconPath)" />
<Property Id="ARPPRODUCTICON" Value="GoNaviIcon" />
<Property Id="ARPURLINFOABOUT" Value="https://github.com/Syngnat/GoNavi" />
<Property Id="INSTALLDESKTOPSHORTCUT" Value="1" Secure="yes" />
<SetProperty Id="ARPINSTALLLOCATION" Value="[INSTALLFOLDER]" After="CostFinalize" />
<WixVariable Id="WixUILicenseRtf" Value="$(var.LicenseRtf)" />
<Feature Id="MainFeature" Title="$(var.ProductName)" Level="1">
<ComponentRef Id="MainExecutable" />
<ComponentRef Id="DesktopShortcutComponent" />
</Feature>
<Component Id="MainExecutable"
Directory="INSTALLFOLDER"
Guid="$(var.ComponentGuid)"
Bitness="always64">
<File Id="GoNaviExe"
Source="$(var.SourceExe)"
Name="GoNavi.exe"
KeyPath="yes">
<!-- Keep shortcut icons bound to the stable executable path. Referencing the MSI Icon table
can leave Windows shell caches pointing at the removed product cache after a major upgrade. -->
<Shortcut Id="StartMenuShortcut"
Directory="ProgramMenuFolder"
Name="$(var.ProductName)"
WorkingDirectory="INSTALLFOLDER"
Advertise="no" />
</File>
<File Id="GoNaviMsiInstallMarker"
Source="$(var.InstallMarker)"
Name=".gonavi-msi-install" />
<File Id="GoNaviLicense"
Source="$(var.LicenseFile)"
Name="LICENSE.txt" />
<File Id="GoNaviNotice"
Source="$(var.NoticeFile)"
Name="NOTICE.txt" />
<RegistryValue Root="HKLM"
Key="Software\Syngnat\$(var.RegistryKeyName)"
Name="InstallType"
Value="MSI"
Type="string"
KeyPath="no" />
<RegistryValue Root="HKLM"
Key="Software\Syngnat\$(var.RegistryKeyName)"
Name="InstallPath"
Value="[INSTALLFOLDER]GoNavi.exe"
Type="string"
KeyPath="no" />
</Component>
<!-- Online updates pass the user's pre-update desktop shortcut state. Keeping this resource
in its own component lets a major upgrade remove an unwanted old shortcut without
recreating it, while first-time and manual installs retain the existing default. -->
<Component Id="DesktopShortcutComponent"
Directory="DesktopFolder"
Guid="$(var.DesktopShortcutComponentGuid)"
Bitness="always64"
Condition="INSTALLDESKTOPSHORTCUT = 1">
<Shortcut Id="DesktopShortcut"
Name="$(var.ProductName)"
Target="[#GoNaviExe]"
WorkingDirectory="INSTALLFOLDER"
Advertise="no" />
<RegistryValue Root="HKLM"
Key="Software\Syngnat\$(var.RegistryKeyName)"
Name="DesktopShortcutInstalled"
Value="1"
Type="integer"
KeyPath="yes" />
</Component>
<ui:WixUI Id="WixUI_InstallDir"
InstallDirectory="INSTALLFOLDER"
ExtendedPathValidation="yes" />
</Package>
<Fragment>
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="$(var.InstallFolderName)" />
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder" />
<StandardDirectory Id="DesktopFolder" />
</Fragment>
</Wix>

View File

@@ -10,13 +10,6 @@
- 入参:`connectionId`
- `get_tables`
- 入参:`connectionId`、可选 `dbName`
- 返回表列表,并在 `views` 字段附带视图列表,兼容旧客户端只调用 `get_tables` 的场景
- `get_views`
- 入参:`connectionId`、可选 `dbName`
- 返回视图列表
- `get_objects`
- 入参:`connectionId`、可选 `dbName`、可选 `objectTypes`
- 返回表、视图、触发器、函数、过程、序列、包、事件,以及消息队列类 `topic/queue/exchange` 等对象清单
- `get_columns`
- 入参:`connectionId`、可选 `dbName``tableName`
- `get_table_ddl`
@@ -27,7 +20,7 @@
- 如果 SQL 包含 DDL/DML必须显式传 `allowMutating=true`
- `maxRowsPerResult` 用来限制单个结果集返回的行数,默认 `200`
远程 Agent 只需要结构元数据时,启动 HTTP 模式请加 `--schema-only`。该模式不注册 `execute_sql`,只保留连接摘要、对象清单、表/视图、字段、索引、外键、触发器和 DDL 工具。
远程 Agent 只需要库表结构时,启动 HTTP 模式请加 `--schema-only`。该模式不注册 `execute_sql`,只保留连接摘要、库表、字段、索引、外键、触发器和 DDL 工具。
## 运行方式
@@ -49,187 +42,6 @@ go run ./cmd/gonavi-mcp-server stdio
go build -o .\bin\gonavi-mcp-server.exe .\cmd\gonavi-mcp-server
```
## Docker / Podman / Compose
当前容器化支持仅覆盖 `gonavi-mcp-server`,不包含 Wails 桌面 GUI。
当前支持矩阵:
- Docker Desktop / Linux 服务器 / NAS直接使用 Compose 或 `docker run`
- Podman / Quadlet使用 `deploy/podman/gonavi-mcp-server`
- Kubernetes使用 `deploy/k8s/gonavi-mcp-server`
- Helm使用 `deploy/helm/gonavi-mcp-server`
- 仅构建环境:使用仓库根目录 `Dockerfile.build-env`
- 桌面 GUI 浏览器访问版:当前不提供,此仓库主应用仍是 Wails 桌面程序,不是现成的 Web 服务
仓库根目录已提供以下文件:
- `Dockerfile.mcp-server`
- `docker-compose.mcp-server.yml`
- `docker.mcp-server.env.example`
- `deploy/podman/gonavi-mcp-server/*`
推荐流程:
```bash
cp docker.mcp-server.env.example docker.mcp-server.env
docker compose --env-file docker.mcp-server.env -f docker-compose.mcp-server.yml up -d
```
默认 Compose 会拉取 GHCR 预构建镜像。如果你要基于当前工作区源码本地构建,再叠加:
```bash
docker compose --env-file docker.mcp-server.env \
-f docker-compose.mcp-server.yml \
-f docker-compose.mcp-server.local.yml \
up -d --build
```
其中 `GONAVI_HOST_DATA_ROOT` 必须指向 GoNavi 当前活动数据目录。该目录内至少应包含:
- `connections.json`
- `daily_secrets.json`
- `drivers/`(如果目标连接依赖可选 driver agent
容器内默认会设置:
- `GONAVI_DATA_ROOT=/data`
- `GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765`
- `GONAVI_MCP_HTTP_PATH=/mcp`
`GONAVI_DATA_ROOT` 会覆盖默认活动数据目录解析逻辑,避免宿主机路径与容器内路径不一致时依赖 `storage_root.json` 的绝对路径。
如果你只想手动构建镜像:
```bash
docker build -f Dockerfile.mcp-server -t gonavi-mcp-server:local .
docker run --rm -p 8765:8765 \
-e GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token \
-e GONAVI_MCP_SCHEMA_ONLY=true \
-e GONAVI_DATA_ROOT=/data \
-v /absolute/path/to/gonavi-data:/data \
gonavi-mcp-server:local http
```
如果你直接使用已发布镜像:
```bash
docker run --rm -p 8765:8765 \
-e GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token \
-e GONAVI_MCP_SCHEMA_ONLY=true \
-e GONAVI_DATA_ROOT=/data \
-v /absolute/path/to/gonavi-data:/data \
ghcr.io/syngnat/gonavi-mcp-server:latest http
```
### Podman
仓库内还提供了 Podman 原生部署样例:
- `deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example`
- `deploy/podman/gonavi-mcp-server/gonavi-mcp-server.container`
- `deploy/podman/gonavi-mcp-server/README.md`
直接运行已发布镜像:
```bash
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ./gonavi-mcp-server.env
podman run -d --name gonavi-mcp-server --replace \
-p 8765:8765 \
--env-file ./gonavi-mcp-server.env \
-v /absolute/path/to/gonavi-data:/data:Z \
ghcr.io/syngnat/gonavi-mcp-server:latest http
```
如果你要基于当前源码本地构建:
```bash
podman build -f Dockerfile.mcp-server -t localhost/gonavi-mcp-server:local .
podman run -d --name gonavi-mcp-server --replace \
-p 8765:8765 \
--env-file ./gonavi-mcp-server.env \
-v /absolute/path/to/gonavi-data:/data:Z \
localhost/gonavi-mcp-server:local http
```
其中:
- `gonavi-mcp-server.env``deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example` 初始化
- `:Z` 适用于开启 SELinux 的宿主机;未启用 SELinux 可去掉
- 更适合长期运行的方式见 [deploy/podman/gonavi-mcp-server/README.md](../../deploy/podman/gonavi-mcp-server/README.md) 中的 Quadlet 示例
`podman compose` 本身依赖外部 compose provider所以仓库对 Podman 的主支持路径是 `podman run` 与 Quadlet而不是假设所有环境都能直接复用 Compose。
## Kubernetes
仓库内置了最小 K8s 示例:
- `deploy/k8s/gonavi-mcp-server/kustomization.yaml`
- `deploy/k8s/gonavi-mcp-server/base/deployment.yaml`
- `deploy/k8s/gonavi-mcp-server/base/service.yaml`
- `deploy/k8s/gonavi-mcp-server/README.md`
- `deploy/k8s/gonavi-mcp-server/overlays/*`
推荐先从现有 GoNavi 数据目录生成 Secret
```bash
kubectl create namespace gonavi
kubectl -n gonavi create secret generic gonavi-mcp-server-data \
--from-file=connections.json=/absolute/path/to/gonavi-data/connections.json \
--from-file=daily_secrets.json=/absolute/path/to/gonavi-data/daily_secrets.json \
--from-literal=GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
kubectl apply -k deploy/k8s/gonavi-mcp-server
```
如果需要 NAS hostPath、可选 driver agent PVC、Ingress或两者组合可直接使用 `overlays/nas-hostpath``overlays/drivers-pvc``overlays/ingress``overlays/ingress-with-drivers-pvc`
更完整的说明见 [deploy/k8s/gonavi-mcp-server/README.md](../../deploy/k8s/gonavi-mcp-server/README.md)。
## Helm
如果你希望把镜像、Secret、Ingress、hostPath / PVC 挂载做成参数化部署,而不是维护多份 Kustomize overlay可直接使用
- `deploy/helm/gonavi-mcp-server`
快速安装:
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server -n gonavi --create-namespace
```
Chart 详细说明见 [deploy/helm/gonavi-mcp-server/README.md](../../deploy/helm/gonavi-mcp-server/README.md)。
## Docker / Podman Build Environment
如果你的目标不是运行 MCP而是给 Linux 服务器 / NAS / CI 准备一套可重复的 Wails 构建环境,可直接使用仓库根目录的 `Dockerfile.build-env`
```bash
docker build -f Dockerfile.build-env -t gonavi-build-env:local .
docker run --rm -it -v "$PWD:/workspace" -w /workspace gonavi-build-env:local bash
```
如果你使用 Podman也可以直接执行
```bash
podman build -f Dockerfile.build-env -t localhost/gonavi-build-env:local .
podman run --rm -it -v "$PWD:/workspace" -w /workspace localhost/gonavi-build-env:local bash
```
镜像内已预装 Go、Node、Wails CLI、GTK3 与 WebKitGTK 开发依赖,适合执行:
```bash
wails build
```
这个镜像默认安装 WebKitGTK 4.0 构建依赖,适合作为通用 Linux / NAS 构建环境。镜像基座支持多架构,`amd64` / `arm64` 会跟随容器平台。
预构建镜像会发布到 GHCR
- `ghcr.io/syngnat/gonavi-mcp-server:latest`
- `ghcr.io/syngnat/gonavi-build-env:latest`
它只负责构建 Linux 产物,不会把 Wails 主程序变成浏览器版服务。
远程 Agent 使用 Streamable HTTP 时必须设置 bearer token
```powershell
@@ -309,7 +121,7 @@ OpenClaw、Hermans 这类部署在云端或远端 Linux 的 Agent不能直接
2. 在 Windows 本机启动 `GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only`
3. 通过 SSH 隧道、反向代理或内网网关把 `http://127.0.0.1:8765/mcp` 暴露为云端 Agent 可访问的 HTTPS 地址。
4. 在 OpenClaw / Hermans 中添加远程 MCP Servertransport 选择 Streamable HTTPURL 指向 `/mcp` 地址,并设置请求头 `Authorization: Bearer <随机token>`
5. 先调用 `get_connections` 获取 `connectionId`,再调用 `get_databases``get_objects``get_tables``get_views``get_columns``get_table_ddl` 等工具读取结构。
5. 先调用 `get_connections` 获取 `connectionId`,再调用 `get_databases``get_tables``get_columns``get_table_ddl` 等工具读取结构。
如果目标 Agent 支持 `mcpServers` JSON可按下面的通用片段配置

View File

@@ -7,11 +7,7 @@ import (
"fmt"
"os"
"reflect"
"runtime"
"runtime/debug"
"strconv"
"strings"
"sync/atomic"
"time"
"GoNavi-Wails/internal/connection"
@@ -21,7 +17,6 @@ import (
type agentRequest struct {
ID int64 `json:"id"`
Method string `json:"method"`
SessionID string `json:"sessionId,omitempty"`
Config *connection.ConnectionConfig `json:"config,omitempty"`
Query string `json:"query,omitempty"`
TimeoutMs int64 `json:"timeoutMs,omitempty"`
@@ -36,8 +31,6 @@ type agentResponse struct {
Error string `json:"error,omitempty"`
Data interface{} `json:"data,omitempty"`
Fields []string `json:"fields,omitempty"`
Messages []string `json:"messages,omitempty"`
ChunkType string `json:"chunkType,omitempty"`
RowsAffected int64 `json:"rowsAffected,omitempty"`
}
@@ -46,11 +39,7 @@ const (
agentMethodClose = "close"
agentMethodMetadata = "metadata"
agentMethodPing = "ping"
agentMethodOpenSession = "openSession"
agentMethodCloseSession = "closeSession"
agentMethodQuery = "query"
agentMethodQueryMulti = "queryMulti"
agentMethodStreamQuery = "streamQuery"
agentMethodExec = "exec"
agentMethodGetDatabases = "getDatabases"
agentMethodGetTables = "getTables"
@@ -65,67 +54,23 @@ const (
const legacyClickHouseDefaultTimeout = 2 * time.Hour
const (
agentChunkColumns = "columns"
agentChunkRows = "rows"
agentChunkDone = "done"
// agentStreamBatchSize 控制 driver-agent 向主进程发送 row chunk 的批次大小。
// 调小到 64单批 JSON 编码 + 主进程解码的瞬时内存峰值降为原来的 1/4
// 代价是 IPC 次数变为 4 倍,但每批仅一次 stdin/stdout 行读写,整体影响可忽略。
// 重要:减小批次不能根除内存峰值,仍需配合 SetGCPercent + 周期 GC见 main
agentStreamBatchSize = 64
agentMemoryTrimRowsThreshold = 100000
agentMemoryTrimMinInterval = 3 * time.Second
)
var (
agentDriverType string
agentDatabaseFactory func() db.Database
agentMemoryTrimRunning atomic.Bool
agentMemoryTrimLastAt atomic.Int64
runAgentMemoryTrimAsync = func(fn func()) {
go fn()
}
agentMemoryTrimFn = func() {
runtime.GC()
debug.FreeOSMemory()
}
agentDriverType string
agentDatabaseFactory func() db.Database
)
type agentRuntime struct {
inst db.Database
sessions map[string]db.StatementExecer
nextSessionID int64
}
func main() {
if agentDatabaseFactory == nil || strings.TrimSpace(agentDriverType) == "" {
fmt.Fprintf(os.Stderr, "未配置驱动代理 provider请使用 gonavi_<driver>_driver 标签构建\n")
os.Exit(2)
}
// driver-agent 是独立进程,主进程无法控制其 GC 行为。
// 大结果集88W+ 行)通过 JSON-lines 跨进程传输时,每行有 5-8 倍内存副本;
// Go 默认 GOGC=100 + Windows MADV_FREE 不归还 RSS会导致 driver-agent 进程
// 内存峰值达到数据总量的 10+ 倍(用户实测 88W 普通业务表撑到 8G+)。
//
// GC 策略组合:
// - SetGCPercent(50):堆增长 50% 即触发 GC比默认 100 更早收敛
// - InitMemorySoftLimit起始 2GB运行时由 MaybeGrowMemoryLimit 自适应抬升到最多 8GB
// (起步保守 + 按需扩张,避免静态 2GB 限制在大表场景触发 GC 硬模式降速 15-25%
//
// 代价CPU 开销增加约 5-10%。导出场景是 I/O 密集型,可忽略。
debug.SetGCPercent(50)
db.InitMemorySoftLimit(db.MemorySoftLimitInitialBytes)
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 16<<10), 8<<20)
writer := bufio.NewWriter(os.Stdout)
defer writer.Flush()
runtimeState := &agentRuntime{
sessions: make(map[string]db.StatementExecer),
}
var inst db.Database
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
@@ -142,32 +87,23 @@ func main() {
continue
}
if strings.TrimSpace(req.Method) == agentMethodStreamQuery {
if err := handleStreamRequest(runtimeState, req, writer); err != nil {
fmt.Fprintf(os.Stderr, "写入流式响应失败:%v\n", err)
break
}
continue
}
resp := handleRequest(runtimeState, req)
resp := handleRequest(&inst, req)
if err := writeResponse(writer, resp); err != nil {
fmt.Fprintf(os.Stderr, "写入响应失败:%v\n", err)
break
}
if strings.TrimSpace(req.Method) == agentMethodQuery {
maybeReleaseAgentMemory("query-response", countAgentResponseRows(resp.Data))
}
}
runtimeState.close()
if inst != nil {
_ = inst.Close()
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "读取请求失败:%v\n", err)
}
}
func handleRequest(runtimeState *agentRuntime, req agentRequest) agentResponse {
func handleRequest(inst *db.Database, req agentRequest) agentResponse {
resp := agentResponse{ID: req.ID, Success: true}
method := strings.TrimSpace(req.Method)
@@ -176,7 +112,9 @@ func handleRequest(runtimeState *agentRuntime, req agentRequest) agentResponse {
if req.Config == nil {
return fail(resp, "连接配置为空")
}
runtimeState.close()
if *inst != nil {
_ = (*inst).Close()
}
next := agentDatabaseFactory()
if next == nil {
return fail(resp, "驱动代理初始化失败")
@@ -184,13 +122,14 @@ func handleRequest(runtimeState *agentRuntime, req agentRequest) agentResponse {
if err := next.Connect(*req.Config); err != nil {
return fail(resp, err.Error())
}
runtimeState.inst = next
*inst = next
return resp
case agentMethodClose:
if runtimeState.inst != nil {
if err := runtimeState.close(); err != nil {
if *inst != nil {
if err := (*inst).Close(); err != nil {
return fail(resp, err.Error())
}
*inst = nil
}
return resp
case agentMethodMetadata:
@@ -200,146 +139,74 @@ func handleRequest(runtimeState *agentRuntime, req agentRequest) agentResponse {
"protocolSchema": "json-lines-v1",
}
return resp
case agentMethodOpenSession:
if runtimeState.inst == nil {
return fail(resp, "connection not open")
}
provider, ok := runtimeState.inst.(db.SessionExecerProvider)
if !ok {
return fail(resp, fmt.Sprintf("当前数据源(%s不支持 SQL 编辑器托管事务", strings.TrimSpace(agentDriverType)))
}
openCtx := context.Background()
var cancel context.CancelFunc
if req.TimeoutMs > 0 {
openCtx, cancel = context.WithTimeout(context.Background(), time.Duration(req.TimeoutMs)*time.Millisecond)
defer cancel()
}
session, err := provider.OpenSessionExecer(openCtx)
if err != nil {
return fail(resp, err.Error())
}
sessionID := runtimeState.nextID()
runtimeState.sessions[sessionID] = session
resp.Data = sessionID
return resp
case agentMethodCloseSession:
if err := runtimeState.closeSession(req.SessionID); err != nil {
return fail(resp, err.Error())
}
return resp
}
if runtimeState.inst == nil {
if *inst == nil {
return fail(resp, "connection not open")
}
if session, ok, err := runtimeState.session(req.SessionID); err != nil {
return fail(resp, err.Error())
} else if ok {
switch method {
case agentMethodQuery:
data, fields, messages, err := queryStatementWithMessagesOptionalTimeout(session, req.Query, req.TimeoutMs)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
resp.Fields = fields
resp.Messages = messages
case agentMethodQueryMulti:
data, messages, supported, err := queryMultiStatementWithMessagesOptionalTimeout(session, req.Query, req.TimeoutMs)
if err != nil {
return fail(resp, err.Error())
}
if !supported {
return fail(resp, "当前事务会话不支持多结果集查询")
}
resp.Data = data
resp.Messages = messages
case agentMethodExec:
affected, err := execStatementWithOptionalTimeout(session, req.Query, req.TimeoutMs)
if err != nil {
return fail(resp, err.Error())
}
resp.RowsAffected = affected
default:
return fail(resp, "当前事务会话不支持该方法")
}
return resp
}
switch method {
case agentMethodPing:
if err := runtimeState.inst.Ping(); err != nil {
if err := (*inst).Ping(); err != nil {
return fail(resp, err.Error())
}
case agentMethodQuery:
data, fields, messages, err := queryWithMessagesOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs)
data, fields, err := queryWithOptionalTimeout(*inst, req.Query, req.TimeoutMs)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
resp.Fields = fields
resp.Messages = messages
case agentMethodQueryMulti:
data, messages, supported, err := queryMultiWithMessagesOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs)
if err != nil {
return fail(resp, err.Error())
}
if !supported {
return fail(resp, "当前驱动不支持原生多结果集查询")
}
resp.Data = data
resp.Messages = messages
case agentMethodExec:
affected, err := execWithOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs)
affected, err := execWithOptionalTimeout(*inst, req.Query, req.TimeoutMs)
if err != nil {
return fail(resp, err.Error())
}
resp.RowsAffected = affected
case agentMethodGetDatabases:
data, err := runtimeState.inst.GetDatabases()
data, err := (*inst).GetDatabases()
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case agentMethodGetTables:
data, err := runtimeState.inst.GetTables(req.DBName)
data, err := (*inst).GetTables(req.DBName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case agentMethodGetCreateStmt:
data, err := runtimeState.inst.GetCreateStatement(req.DBName, req.TableName)
data, err := (*inst).GetCreateStatement(req.DBName, req.TableName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case agentMethodGetColumns:
data, err := runtimeState.inst.GetColumns(req.DBName, req.TableName)
data, err := (*inst).GetColumns(req.DBName, req.TableName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case agentMethodGetAllColumns:
data, err := runtimeState.inst.GetAllColumns(req.DBName)
data, err := (*inst).GetAllColumns(req.DBName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case agentMethodGetIndexes:
data, err := runtimeState.inst.GetIndexes(req.DBName, req.TableName)
data, err := (*inst).GetIndexes(req.DBName, req.TableName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case agentMethodGetForeignKey:
data, err := runtimeState.inst.GetForeignKeys(req.DBName, req.TableName)
data, err := (*inst).GetForeignKeys(req.DBName, req.TableName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case agentMethodGetTriggers:
data, err := runtimeState.inst.GetTriggers(req.DBName, req.TableName)
data, err := (*inst).GetTriggers(req.DBName, req.TableName)
if err != nil {
return fail(resp, err.Error())
}
@@ -348,7 +215,7 @@ func handleRequest(runtimeState *agentRuntime, req agentRequest) agentResponse {
if req.Changes == nil {
return fail(resp, "变更集为空")
}
applier, ok := runtimeState.inst.(interface {
applier, ok := (*inst).(interface {
ApplyChanges(tableName string, changes connection.ChangeSet) error
})
if !ok {
@@ -364,169 +231,6 @@ func handleRequest(runtimeState *agentRuntime, req agentRequest) agentResponse {
return resp
}
type agentStreamResponseWriter struct {
writer *bufio.Writer
requestID int64
columns []string
rows [][]interface{}
rowCount int64
}
func newAgentStreamResponseWriter(writer *bufio.Writer, requestID int64) *agentStreamResponseWriter {
return &agentStreamResponseWriter{
writer: writer,
requestID: requestID,
}
}
func (w *agentStreamResponseWriter) SetColumns(columns []string) error {
w.columns = append([]string(nil), columns...)
return writeResponse(w.writer, agentResponse{
ID: w.requestID,
Success: true,
ChunkType: agentChunkColumns,
Fields: w.columns,
})
}
func (w *agentStreamResponseWriter) ConsumeRow(row map[string]interface{}) error {
if len(w.columns) == 0 {
return fmt.Errorf("流式查询缺少列定义")
}
values := make([]interface{}, len(w.columns))
for idx, column := range w.columns {
values[idx] = row[column]
}
return w.ConsumeRowValues(values)
}
func (w *agentStreamResponseWriter) ConsumeRowValues(values []interface{}) error {
row := append([]interface{}(nil), values...)
w.rows = append(w.rows, row)
w.rowCount++
if len(w.rows) < agentStreamBatchSize {
return nil
}
return w.flushRows()
}
func (w *agentStreamResponseWriter) flushRows() error {
if len(w.rows) == 0 {
return nil
}
rows := w.rows
w.rows = nil
return writeResponse(w.writer, agentResponse{
ID: w.requestID,
Success: true,
ChunkType: agentChunkRows,
Data: rows,
})
}
func (w *agentStreamResponseWriter) finish() error {
return w.flushRows()
}
func handleStreamRequest(runtimeState *agentRuntime, req agentRequest, writer *bufio.Writer) error {
resp := agentResponse{ID: req.ID, Success: true}
if runtimeState.inst == nil {
return writeResponse(writer, fail(resp, "connection not open"))
}
streamWriter := newAgentStreamResponseWriter(writer, req.ID)
if session, ok, err := runtimeState.session(req.SessionID); err != nil {
return writeResponse(writer, fail(resp, err.Error()))
} else if ok {
if err := streamStatementWithOptionalTimeout(session, req.Query, req.TimeoutMs, streamWriter); err != nil {
_ = streamWriter.finish()
return writeResponse(writer, fail(resp, err.Error()))
}
if err := streamWriter.finish(); err != nil {
return err
}
if err := writeResponse(writer, agentResponse{ID: req.ID, Success: true, ChunkType: agentChunkDone}); err != nil {
return err
}
maybeReleaseAgentMemory("stream-query-session", streamWriter.rowCount)
return nil
}
if err := streamDatabaseWithOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs, streamWriter); err != nil {
_ = streamWriter.finish()
return writeResponse(writer, fail(resp, err.Error()))
}
if err := streamWriter.finish(); err != nil {
return err
}
if err := writeResponse(writer, agentResponse{ID: req.ID, Success: true, ChunkType: agentChunkDone}); err != nil {
return err
}
maybeReleaseAgentMemory("stream-query-db", streamWriter.rowCount)
return nil
}
func (r *agentRuntime) nextID() string {
r.ensureSessionMap()
r.nextSessionID++
return "session-" + strconv.FormatInt(r.nextSessionID, 10)
}
func (r *agentRuntime) session(sessionID string) (db.StatementExecer, bool, error) {
r.ensureSessionMap()
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return nil, false, nil
}
session, ok := r.sessions[sessionID]
if !ok || session == nil {
return nil, false, fmt.Errorf("事务会话不存在或已结束")
}
return session, true, nil
}
func (r *agentRuntime) closeSession(sessionID string) error {
r.ensureSessionMap()
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return fmt.Errorf("事务会话 ID 不能为空")
}
session, ok := r.sessions[sessionID]
if ok {
delete(r.sessions, sessionID)
}
if !ok || session == nil {
return fmt.Errorf("事务会话不存在或已结束")
}
return session.Close()
}
func (r *agentRuntime) close() error {
var closeErr error
r.ensureSessionMap()
for sessionID, session := range r.sessions {
delete(r.sessions, sessionID)
if session != nil {
if err := session.Close(); err != nil && closeErr == nil {
closeErr = err
}
}
}
if r.inst != nil {
if err := r.inst.Close(); err != nil && closeErr == nil {
closeErr = err
}
r.inst = nil
}
return closeErr
}
func (r *agentRuntime) ensureSessionMap() {
if r.sessions == nil {
r.sessions = make(map[string]db.StatementExecer)
}
}
func writeResponse(writer *bufio.Writer, resp agentResponse) error {
// 对响应数据做统一 JSON 安全归一化:
// 将 map[any]any如 duckdb.Map递归转换为 map[string]any避免序列化失败导致代理进程退出。
@@ -597,234 +301,25 @@ func normalizeAgentResponseData(v interface{}) interface{} {
}
}
type agentQueryRunner interface {
Query(string) ([]map[string]interface{}, []string, error)
}
type agentQueryContextRunner interface {
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
}
type agentQueryMessageRunner interface {
QueryWithMessages(query string) ([]map[string]interface{}, []string, []string, error)
}
type agentQueryMessageContextRunner interface {
QueryContextWithMessages(context.Context, string) ([]map[string]interface{}, []string, []string, error)
}
type agentMultiResultMessageRunner interface {
QueryMultiWithMessages(query string) ([]connection.ResultSetData, []string, error)
}
type agentMultiResultMessageContextRunner interface {
QueryMultiContextWithMessages(context.Context, string) ([]connection.ResultSetData, []string, error)
}
type agentMultiResultRunner interface {
QueryMulti(query string) ([]connection.ResultSetData, error)
}
type agentMultiResultContextRunner interface {
QueryMultiContext(context.Context, string) ([]connection.ResultSetData, error)
}
type agentExecRunner interface {
Exec(string) (int64, error)
}
type agentExecContextRunner interface {
ExecContext(context.Context, string) (int64, error)
}
func queryWithMessagesOptionalTimeout(inst agentQueryRunner, query string, timeoutMs int64) ([]map[string]interface{}, []string, []string, error) {
func queryWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) {
effectiveTimeoutMs := timeoutMs
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
}
if effectiveTimeoutMs <= 0 {
if q, ok := inst.(agentQueryMessageRunner); ok {
return q.QueryWithMessages(query)
}
data, fields, err := inst.Query(query)
return data, fields, nil, err
return inst.Query(query)
}
if q, ok := inst.(agentQueryMessageContextRunner); ok {
if q, ok := inst.(interface {
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
}); ok {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
return q.QueryContextWithMessages(ctx, query)
return q.QueryContext(ctx, query)
}
if q, ok := inst.(agentQueryContextRunner); ok {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
data, fields, err := q.QueryContext(ctx, query)
return data, fields, nil, err
}
if q, ok := inst.(agentQueryMessageRunner); ok {
return q.QueryWithMessages(query)
}
data, fields, err := inst.Query(query)
return data, fields, nil, err
return inst.Query(query)
}
func queryWithOptionalTimeout(inst agentQueryRunner, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) {
data, fields, _, err := queryWithMessagesOptionalTimeout(inst, query, timeoutMs)
return data, fields, err
}
func queryStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) {
queryRunner, ok := inst.(agentQueryRunner)
if !ok {
return nil, nil, fmt.Errorf("当前事务会话不支持查询语句")
}
return queryWithOptionalTimeout(queryRunner, query, timeoutMs)
}
func queryStatementWithMessagesOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) ([]map[string]interface{}, []string, []string, error) {
queryRunner, ok := inst.(agentQueryRunner)
if !ok {
return nil, nil, nil, fmt.Errorf("当前事务会话不支持查询语句")
}
return queryWithMessagesOptionalTimeout(queryRunner, query, timeoutMs)
}
func queryMultiWithMessagesOptionalTimeout(inst db.Database, query string, timeoutMs int64) ([]connection.ResultSetData, []string, bool, error) {
effectiveTimeoutMs := timeoutMs
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
}
if effectiveTimeoutMs > 0 {
if q, ok := inst.(agentMultiResultMessageContextRunner); ok {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
data, messages, err := q.QueryMultiContextWithMessages(ctx, query)
return data, messages, true, err
}
if q, ok := inst.(agentMultiResultContextRunner); ok {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
data, err := q.QueryMultiContext(ctx, query)
return data, nil, true, err
}
}
if q, ok := inst.(agentMultiResultMessageRunner); ok {
data, messages, err := q.QueryMultiWithMessages(query)
return data, messages, true, err
}
if q, ok := inst.(agentMultiResultRunner); ok {
data, err := q.QueryMulti(query)
return data, nil, true, err
}
return nil, nil, false, nil
}
func queryMultiStatementWithMessagesOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) ([]connection.ResultSetData, []string, bool, error) {
effectiveTimeoutMs := timeoutMs
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
}
if effectiveTimeoutMs > 0 {
if q, ok := inst.(agentMultiResultMessageContextRunner); ok {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
data, messages, err := q.QueryMultiContextWithMessages(ctx, query)
return data, messages, true, err
}
if q, ok := inst.(agentMultiResultContextRunner); ok {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
data, err := q.QueryMultiContext(ctx, query)
return data, nil, true, err
}
}
if q, ok := inst.(agentMultiResultMessageRunner); ok {
data, messages, err := q.QueryMultiWithMessages(query)
return data, messages, true, err
}
if q, ok := inst.(agentMultiResultRunner); ok {
data, err := q.QueryMulti(query)
return data, nil, true, err
}
return nil, nil, false, nil
}
func streamWithOptionalTimeout(inst db.StreamQueryExecer, query string, timeoutMs int64, consumer db.QueryStreamConsumer) error {
effectiveTimeoutMs := timeoutMs
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
}
if effectiveTimeoutMs <= 0 {
return inst.StreamQuery(query, consumer)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
return inst.StreamQueryContext(ctx, query, consumer)
}
func streamBufferedQueryResult(fields []string, data []map[string]interface{}, consumer db.QueryStreamConsumer) error {
if err := consumer.SetColumns(fields); err != nil {
return err
}
if valueConsumer, ok := consumer.(db.QueryStreamValueConsumer); ok {
for _, row := range data {
values := make([]interface{}, len(fields))
for idx, field := range fields {
values[idx] = row[field]
}
if err := valueConsumer.ConsumeRowValues(values); err != nil {
return err
}
}
return nil
}
for _, row := range data {
if err := consumer.ConsumeRow(row); err != nil {
return err
}
}
return nil
}
func streamStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64, consumer db.QueryStreamConsumer) error {
if streamer, ok := inst.(db.StreamQueryExecer); ok {
return streamWithOptionalTimeout(streamer, query, timeoutMs, consumer)
}
data, fields, err := queryStatementWithOptionalTimeout(inst, query, timeoutMs)
if err != nil {
return err
}
return streamBufferedQueryResult(fields, data, consumer)
}
func streamDatabaseWithOptionalTimeout(inst db.Database, query string, timeoutMs int64, consumer db.QueryStreamConsumer) error {
if streamer, ok := inst.(db.StreamQueryExecer); ok {
return streamWithOptionalTimeout(streamer, query, timeoutMs, consumer)
}
if provider, ok := inst.(db.SessionExecerProvider); ok {
openCtx := context.Background()
var cancel context.CancelFunc
effectiveTimeoutMs := timeoutMs
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
}
if effectiveTimeoutMs > 0 {
openCtx, cancel = context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
}
session, err := provider.OpenSessionExecer(openCtx)
if err == nil {
defer session.Close()
return streamStatementWithOptionalTimeout(session, query, timeoutMs, consumer)
}
}
data, fields, err := queryWithOptionalTimeout(inst, query, timeoutMs)
if err != nil {
return err
}
return streamBufferedQueryResult(fields, data, consumer)
}
func execWithOptionalTimeout(inst agentExecRunner, query string, timeoutMs int64) (int64, error) {
func execWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) (int64, error) {
effectiveTimeoutMs := timeoutMs
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
@@ -832,52 +327,12 @@ func execWithOptionalTimeout(inst agentExecRunner, query string, timeoutMs int64
if effectiveTimeoutMs <= 0 {
return inst.Exec(query)
}
if e, ok := inst.(agentExecContextRunner); ok {
if e, ok := inst.(interface {
ExecContext(context.Context, string) (int64, error)
}); ok {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
return e.ExecContext(ctx, query)
}
return inst.Exec(query)
}
func execStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) (int64, error) {
return execWithOptionalTimeout(inst, query, timeoutMs)
}
func countAgentResponseRows(data interface{}) int64 {
rows, ok := data.([]map[string]interface{})
if !ok {
return 0
}
return int64(len(rows))
}
func maybeReleaseAgentMemory(reason string, rows int64) {
if rows < agentMemoryTrimRowsThreshold {
return
}
if !agentMemoryTrimRunning.CompareAndSwap(false, true) {
return
}
runAgentMemoryTrimAsync(func() {
defer agentMemoryTrimRunning.Store(false)
if delay := nextAgentMemoryTrimDelay(); delay > 0 {
time.Sleep(delay)
}
agentMemoryTrimFn()
agentMemoryTrimLastAt.Store(time.Now().UnixNano())
})
}
func nextAgentMemoryTrimDelay() time.Duration {
lastUnixNano := agentMemoryTrimLastAt.Load()
if lastUnixNano <= 0 {
return 0
}
elapsed := time.Since(time.Unix(0, lastUnixNano))
if elapsed >= agentMemoryTrimMinInterval {
return 0
}
return agentMemoryTrimMinInterval - elapsed
}

View File

@@ -6,7 +6,6 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
@@ -78,8 +77,8 @@ func TestHandleRequestMetadataReportsAgentRevision(t *testing.T) {
agentDriverType = "clickhouse"
agentDatabaseFactory = func() db.Database { return nil }
runtimeState := &agentRuntime{sessions: make(map[string]db.StatementExecer)}
resp := handleRequest(runtimeState, agentRequest{ID: 7, Method: agentMethodMetadata})
var inst db.Database
resp := handleRequest(&inst, agentRequest{ID: 7, Method: agentMethodMetadata})
if !resp.Success {
t.Fatalf("metadata request failed: %s", resp.Error)
}
@@ -101,9 +100,6 @@ type fakeAgentTimeoutDB struct {
execCalled bool
execContextCalled bool
deadlineSet bool
queryMessages []string
multiResults []connection.ResultSetData
multiMessages []string
}
func (f *fakeAgentTimeoutDB) Connect(config connection.ConnectionConfig) error { return nil }
@@ -120,14 +116,6 @@ func (f *fakeAgentTimeoutDB) QueryContext(ctx context.Context, query string) ([]
}
return []map[string]interface{}{{"ok": 1}}, []string{"ok"}, nil
}
func (f *fakeAgentTimeoutDB) QueryWithMessages(query string) ([]map[string]interface{}, []string, []string, error) {
data, fields, err := f.QueryContext(context.Background(), query)
return data, fields, append([]string(nil), f.queryMessages...), err
}
func (f *fakeAgentTimeoutDB) QueryContextWithMessages(ctx context.Context, query string) ([]map[string]interface{}, []string, []string, error) {
data, fields, err := f.QueryContext(ctx, query)
return data, fields, append([]string(nil), f.queryMessages...), err
}
func (f *fakeAgentTimeoutDB) Exec(query string) (int64, error) {
f.execCalled = true
return 0, errors.New("exec should not be called")
@@ -161,121 +149,6 @@ func (f *fakeAgentTimeoutDB) GetForeignKeys(dbName, tableName string) ([]connect
func (f *fakeAgentTimeoutDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return nil, nil
}
func (f *fakeAgentTimeoutDB) QueryMultiWithMessages(query string) ([]connection.ResultSetData, []string, error) {
return append([]connection.ResultSetData(nil), f.multiResults...), append([]string(nil), f.multiMessages...), nil
}
func (f *fakeAgentTimeoutDB) QueryMultiContextWithMessages(ctx context.Context, query string) ([]connection.ResultSetData, []string, error) {
if _, ok := ctx.Deadline(); ok {
f.deadlineSet = true
}
return f.QueryMultiWithMessages(query)
}
type fakeAgentSessionDB struct {
fakeAgentTimeoutDB
session *fakeAgentStatementSession
}
func (f *fakeAgentSessionDB) OpenSessionExecer(ctx context.Context) (db.StatementExecer, error) {
f.session = &fakeAgentStatementSession{}
return f.session, nil
}
type fakeAgentStatementSession struct {
queryCalls int
execCalls int
closed bool
messages []string
}
func (f *fakeAgentStatementSession) Query(query string) ([]map[string]interface{}, []string, error) {
return f.QueryContext(context.Background(), query)
}
func (f *fakeAgentStatementSession) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
f.queryCalls++
return []map[string]interface{}{{"session_ok": 1}}, []string{"session_ok"}, nil
}
func (f *fakeAgentStatementSession) QueryWithMessages(query string) ([]map[string]interface{}, []string, []string, error) {
data, fields, err := f.QueryContext(context.Background(), query)
return data, fields, append([]string(nil), f.messages...), err
}
func (f *fakeAgentStatementSession) QueryContextWithMessages(ctx context.Context, query string) ([]map[string]interface{}, []string, []string, error) {
data, fields, err := f.QueryContext(ctx, query)
return data, fields, append([]string(nil), f.messages...), err
}
func (f *fakeAgentStatementSession) Exec(query string) (int64, error) {
return f.ExecContext(context.Background(), query)
}
func (f *fakeAgentStatementSession) ExecContext(ctx context.Context, query string) (int64, error) {
f.execCalls++
return 9, nil
}
func (f *fakeAgentStatementSession) Close() error {
f.closed = true
return nil
}
type fakeAgentStreamSession struct {
closed bool
streamCalls int
deadlineSet bool
}
func (f *fakeAgentStreamSession) Exec(query string) (int64, error) {
return 0, nil
}
func (f *fakeAgentStreamSession) ExecContext(ctx context.Context, query string) (int64, error) {
return 0, nil
}
func (f *fakeAgentStreamSession) Close() error {
f.closed = true
return nil
}
func (f *fakeAgentStreamSession) StreamQuery(query string, consumer db.QueryStreamConsumer) error {
return f.StreamQueryContext(context.Background(), query, consumer)
}
func (f *fakeAgentStreamSession) StreamQueryContext(ctx context.Context, query string, consumer db.QueryStreamConsumer) error {
f.streamCalls++
if _, ok := ctx.Deadline(); ok {
f.deadlineSet = true
}
if err := consumer.SetColumns([]string{"id", "name"}); err != nil {
return err
}
if valueConsumer, ok := consumer.(db.QueryStreamValueConsumer); ok {
if err := valueConsumer.ConsumeRowValues([]interface{}{1, "alice"}); err != nil {
return err
}
if err := valueConsumer.ConsumeRowValues([]interface{}{2, "bob"}); err != nil {
return err
}
return nil
}
if err := consumer.ConsumeRow(map[string]interface{}{"id": 1, "name": "alice"}); err != nil {
return err
}
return consumer.ConsumeRow(map[string]interface{}{"id": 2, "name": "bob"})
}
type fakeAgentSessionStreamDB struct {
fakeAgentTimeoutDB
session *fakeAgentStreamSession
openCalls int
}
func (f *fakeAgentSessionStreamDB) OpenSessionExecer(ctx context.Context) (db.StatementExecer, error) {
f.openCalls++
f.session = &fakeAgentStreamSession{}
return f.session, nil
}
func TestQueryWithOptionalTimeout_UsesQueryContext(t *testing.T) {
fake := &fakeAgentTimeoutDB{}
@@ -325,277 +198,3 @@ func TestQueryWithOptionalTimeout_ClickHouseLegacyModeUsesQueryContext(t *testin
t.Fatalf("clickhouse legacy query 调用路径异常QueryContext=%v Query=%v", fake.queryContextCalled, fake.queryCalled)
}
}
func TestHandleRequest_QueryIncludesServerMessages(t *testing.T) {
old := agentDriverType
defer func() { agentDriverType = old }()
agentDriverType = "sqlserver"
fake := &fakeAgentTimeoutDB{
queryMessages: []string{"PRINT sql line 1", "PRINT sql line 2"},
}
runtimeState := &agentRuntime{inst: fake, sessions: make(map[string]db.StatementExecer)}
resp := handleRequest(runtimeState, agentRequest{
ID: 11,
Method: agentMethodQuery,
Query: "exec dbo.p_get_select",
TimeoutMs: int64((2 * time.Second).Milliseconds()),
})
if !resp.Success {
t.Fatalf("query request failed: %s", resp.Error)
}
if len(resp.Messages) != 2 || resp.Messages[0] != "PRINT sql line 1" {
t.Fatalf("expected query messages to be preserved, got %#v", resp.Messages)
}
}
func TestHandleRequest_QueryMultiIncludesResultSetsAndMessages(t *testing.T) {
old := agentDriverType
defer func() { agentDriverType = old }()
agentDriverType = "sqlserver"
fake := &fakeAgentTimeoutDB{
multiResults: []connection.ResultSetData{
{
StatementIndex: 1,
Rows: []map[string]interface{}{{"name": "master"}},
Columns: []string{"name"},
},
{
StatementIndex: 1,
Rows: []map[string]interface{}{},
Columns: []string{},
Messages: []string{"PRINT generated sql"},
},
},
multiMessages: []string{"batch top-level message"},
}
runtimeState := &agentRuntime{inst: fake, sessions: make(map[string]db.StatementExecer)}
resp := handleRequest(runtimeState, agentRequest{
ID: 12,
Method: agentMethodQueryMulti,
Query: "exec dbo.p_get_select",
TimeoutMs: int64((2 * time.Second).Milliseconds()),
})
if !resp.Success {
t.Fatalf("queryMulti request failed: %s", resp.Error)
}
if len(resp.Messages) != 1 || resp.Messages[0] != "batch top-level message" {
t.Fatalf("expected top-level messages to be preserved, got %#v", resp.Messages)
}
resultSets, ok := resp.Data.([]connection.ResultSetData)
if !ok {
t.Fatalf("expected []connection.ResultSetData, got %T", resp.Data)
}
if len(resultSets) != 2 {
t.Fatalf("expected 2 result sets, got %#v", resultSets)
}
if len(resultSets[1].Messages) != 1 || resultSets[1].Messages[0] != "PRINT generated sql" {
t.Fatalf("expected message-only result set to be preserved, got %#v", resultSets[1])
}
}
func TestHandleRequest_UsesPinnedSessionForSessionScopedQueryAndExec(t *testing.T) {
old := agentDriverType
defer func() { agentDriverType = old }()
agentDriverType = "sqlserver"
fake := &fakeAgentSessionDB{}
runtimeState := &agentRuntime{
inst: fake,
sessions: make(map[string]db.StatementExecer),
}
openResp := handleRequest(runtimeState, agentRequest{ID: 1, Method: agentMethodOpenSession})
if !openResp.Success {
t.Fatalf("openSession failed: %s", openResp.Error)
}
sessionID, ok := openResp.Data.(string)
if !ok || strings.TrimSpace(sessionID) == "" {
t.Fatalf("unexpected session id payload: %#v", openResp.Data)
}
if fake.session == nil {
t.Fatal("expected OpenSessionExecer to create a pinned session")
}
queryResp := handleRequest(runtimeState, agentRequest{
ID: 2,
Method: agentMethodQuery,
SessionID: sessionID,
Query: "SELECT 1",
})
if !queryResp.Success {
t.Fatalf("session query failed: %s", queryResp.Error)
}
if len(queryResp.Messages) != 0 {
t.Fatalf("expected empty default session messages, got %#v", queryResp.Messages)
}
if fake.queryCalled || fake.queryContextCalled {
t.Fatalf("expected session query to bypass database-level query path, got Query=%v QueryContext=%v", fake.queryCalled, fake.queryContextCalled)
}
if fake.session.queryCalls != 1 {
t.Fatalf("expected pinned session queryCalls=1, got %d", fake.session.queryCalls)
}
execResp := handleRequest(runtimeState, agentRequest{
ID: 3,
Method: agentMethodExec,
SessionID: sessionID,
Query: "UPDATE t SET v = 1",
})
if !execResp.Success {
t.Fatalf("session exec failed: %s", execResp.Error)
}
if fake.execCalled || fake.execContextCalled {
t.Fatalf("expected session exec to bypass database-level exec path, got Exec=%v ExecContext=%v", fake.execCalled, fake.execContextCalled)
}
if fake.session.execCalls != 1 {
t.Fatalf("expected pinned session execCalls=1, got %d", fake.session.execCalls)
}
closeResp := handleRequest(runtimeState, agentRequest{
ID: 4,
Method: agentMethodCloseSession,
SessionID: sessionID,
})
if !closeResp.Success {
t.Fatalf("closeSession failed: %s", closeResp.Error)
}
if !fake.session.closed {
t.Fatal("expected pinned session to close")
}
}
func TestHandleStreamRequest_UsesSessionStreamerAndWritesChunks(t *testing.T) {
old := agentDriverType
originalAsync := runAgentMemoryTrimAsync
originalTrim := agentMemoryTrimFn
originalLastAt := agentMemoryTrimLastAt.Load()
defer func() { agentDriverType = old }()
defer func() {
runAgentMemoryTrimAsync = originalAsync
agentMemoryTrimFn = originalTrim
agentMemoryTrimRunning.Store(false)
agentMemoryTrimLastAt.Store(originalLastAt)
}()
agentDriverType = "oceanbase"
agentMemoryTrimRunning.Store(false)
agentMemoryTrimLastAt.Store(0)
fake := &fakeAgentSessionStreamDB{}
runtimeState := &agentRuntime{
inst: fake,
sessions: make(map[string]db.StatementExecer),
}
trimmed := 0
runAgentMemoryTrimAsync = func(fn func()) {
fn()
}
agentMemoryTrimFn = func() {
trimmed++
}
var out bytes.Buffer
writer := bufio.NewWriter(&out)
if err := handleStreamRequest(runtimeState, agentRequest{
ID: 9,
Method: agentMethodStreamQuery,
Query: "SELECT * FROM person_info",
TimeoutMs: int64((2 * time.Second).Milliseconds()),
}, writer); err != nil {
t.Fatalf("handleStreamRequest 返回错误: %v", err)
}
if fake.openCalls != 1 {
t.Fatalf("expected OpenSessionExecer called once, got %d", fake.openCalls)
}
if fake.session == nil || fake.session.streamCalls != 1 {
t.Fatalf("expected session streamer used once, session=%#v", fake.session)
}
if !fake.session.deadlineSet {
t.Fatal("expected stream query context deadline to be set")
}
if !fake.session.closed {
t.Fatal("expected session to close after streaming")
}
if fake.queryCalled || fake.queryContextCalled {
t.Fatalf("unexpected fallback query path, Query=%v QueryContext=%v", fake.queryCalled, fake.queryContextCalled)
}
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 3 {
t.Fatalf("expected 3 stream responses, got %d: %q", len(lines), out.String())
}
var columnsResp struct {
Success bool `json:"success"`
ChunkType string `json:"chunkType"`
Fields []string `json:"fields"`
}
if err := json.Unmarshal([]byte(lines[0]), &columnsResp); err != nil {
t.Fatalf("decode columns response failed: %v", err)
}
if !columnsResp.Success || columnsResp.ChunkType != agentChunkColumns || len(columnsResp.Fields) != 2 {
t.Fatalf("unexpected columns response: %#v", columnsResp)
}
var rowsResp struct {
Success bool `json:"success"`
ChunkType string `json:"chunkType"`
Data [][]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(lines[1]), &rowsResp); err != nil {
t.Fatalf("decode rows response failed: %v", err)
}
if !rowsResp.Success || rowsResp.ChunkType != agentChunkRows || len(rowsResp.Data) != 2 {
t.Fatalf("unexpected rows response: %#v", rowsResp)
}
if got := rowsResp.Data[1][1]; got != "bob" {
t.Fatalf("unexpected streamed row payload: %v", rowsResp.Data)
}
var doneResp struct {
Success bool `json:"success"`
ChunkType string `json:"chunkType"`
}
if err := json.Unmarshal([]byte(lines[2]), &doneResp); err != nil {
t.Fatalf("decode done response failed: %v", err)
}
if !doneResp.Success || doneResp.ChunkType != agentChunkDone {
t.Fatalf("unexpected done response: %#v", doneResp)
}
if trimmed != 0 {
t.Fatalf("小流式任务不应触发内存回收got=%d", trimmed)
}
}
func TestMaybeReleaseAgentMemory_TriggersTrimForLargeJobs(t *testing.T) {
originalAsync := runAgentMemoryTrimAsync
originalTrim := agentMemoryTrimFn
originalLastAt := agentMemoryTrimLastAt.Load()
t.Cleanup(func() {
runAgentMemoryTrimAsync = originalAsync
agentMemoryTrimFn = originalTrim
agentMemoryTrimRunning.Store(false)
agentMemoryTrimLastAt.Store(originalLastAt)
})
agentMemoryTrimRunning.Store(false)
agentMemoryTrimLastAt.Store(0)
triggered := 0
runAgentMemoryTrimAsync = func(fn func()) {
fn()
}
agentMemoryTrimFn = func() {
triggered++
}
maybeReleaseAgentMemory("test-large-query", agentMemoryTrimRowsThreshold)
if triggered != 1 {
t.Fatalf("大查询完成后应触发一次内存回收got=%d", triggered)
}
}

View File

@@ -1,12 +0,0 @@
//go:build gonavi_gaussdb_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "gaussdb"
agentDatabaseFactory = func() db.Database {
return &db.GaussDB{}
}
}

View File

@@ -1,12 +0,0 @@
//go:build gonavi_iotdb_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "iotdb"
agentDatabaseFactory = func() db.Database {
return &db.IoTDBDB{}
}
}

View File

@@ -1,12 +0,0 @@
//go:build gonavi_trino_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "trino"
agentDatabaseFactory = func() db.Database {
return &db.TrinoDB{}
}
}

View File

@@ -1,7 +0,0 @@
apiVersion: v2
name: gonavi-mcp-server
description: Helm chart for deploying GoNavi MCP Server
type: application
version: 0.1.0
appVersion: "latest"
kubeVersion: ">=1.24.0-0"

View File

@@ -1,94 +0,0 @@
# GoNavi MCP Server Helm Chart
这个 Chart 用来部署 `gonavi-mcp-server`,覆盖当前仓库 K8s 示例里的几类常见场景:
- 基于现有 Secret 直接部署
- 通过 Helm values 内联创建测试用 Secret
- 启用 Ingress
- 追加 `drivers/` PVC
- NAS / k3s 单机节点直接挂宿主机数据目录
Chart 目录:
- `Chart.yaml`
- `values.yaml`
- `values-examples/*.yaml`
- `templates/*.yaml`
## 1. 默认安装
默认值假设你已经有一个现成 Secret
- 名称:`gonavi-mcp-server-data`
- 至少包含:
- `connections.json`
- `daily_secrets.json`
- `GONAVI_MCP_HTTP_TOKEN`
安装命令:
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server -n gonavi --create-namespace
```
## 2. 直接创建测试用 Secret
如果只是本地验证模板或快速试跑,可以使用示例 values
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
-n gonavi --create-namespace \
-f deploy/helm/gonavi-mcp-server/values-examples/inline-secret.yaml
```
这个示例只适合最小联调,不适合真实生产连接数据。
## 3. 常用示例
启用 Ingress
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
-n gonavi --create-namespace \
-f deploy/helm/gonavi-mcp-server/values-examples/ingress.yaml
```
挂载 drivers PVC
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
-n gonavi --create-namespace \
-f deploy/helm/gonavi-mcp-server/values-examples/drivers-pvc.yaml
```
同时启用 Ingress 与 drivers PVC
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
-n gonavi --create-namespace \
-f deploy/helm/gonavi-mcp-server/values-examples/ingress-with-drivers-pvc.yaml
```
NAS / k3s hostPath
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
-n gonavi --create-namespace \
-f deploy/helm/gonavi-mcp-server/values-examples/nas-hostpath.yaml
```
## 4. 关键参数
- `image.repository` / `image.tag`
- `secret.create`
- `secret.name`
- `data.mode=secret|hostPath|pvc`
- `drivers.mode=none|pvc|hostPath`
- `ingress.enabled`
## 5. 约束
- `data.mode=secret`Secret 必须能提供 `connections.json``daily_secrets.json`
- `data.mode=hostPath` 时,宿主机目录需要包含 GoNavi 活动数据目录内容
- `drivers.mode!=none` 时,会在 `/data/drivers` 追加独立挂载
- Chart 默认只部署 MCP Server不部署桌面 GUI

View File

@@ -1,49 +0,0 @@
{{- define "gonavi-mcp-server.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "gonavi-mcp-server.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := include "gonavi-mcp-server.name" . -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "gonavi-mcp-server.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" -}}
{{- end -}}
{{- define "gonavi-mcp-server.labels" -}}
helm.sh/chart: {{ include "gonavi-mcp-server.chart" . }}
app.kubernetes.io/name: {{ include "gonavi-mcp-server.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "gonavi-mcp-server.selectorLabels" -}}
app.kubernetes.io/name: {{ include "gonavi-mcp-server.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "gonavi-mcp-server.namespace" -}}
{{- default .Release.Namespace .Values.namespaceOverride -}}
{{- end -}}
{{- define "gonavi-mcp-server.secretName" -}}
{{- if .Values.secret.name -}}
{{- .Values.secret.name -}}
{{- else -}}
{{- include "gonavi-mcp-server.fullname" . -}}
{{- end -}}
{{- end -}}
{{- define "gonavi-mcp-server.serviceName" -}}
{{- include "gonavi-mcp-server.fullname" . -}}
{{- end -}}

View File

@@ -1,130 +0,0 @@
{{- $dataMode := .Values.data.mode -}}
{{- $driversMode := .Values.drivers.mode -}}
{{- if not (or (eq $dataMode "secret") (eq $dataMode "hostPath") (eq $dataMode "pvc")) -}}
{{- fail "data.mode must be one of: secret, hostPath, pvc" -}}
{{- end -}}
{{- if not (or (eq $driversMode "none") (eq $driversMode "pvc") (eq $driversMode "hostPath")) -}}
{{- fail "drivers.mode must be one of: none, pvc, hostPath" -}}
{{- end -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "gonavi-mcp-server.fullname" . }}
namespace: {{ include "gonavi-mcp-server.namespace" . }}
labels:
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "gonavi-mcp-server.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "gonavi-mcp-server.selectorLabels" . | nindent 8 }}
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: gonavi-mcp-server
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
args:
- http
{{- range .Values.mcp.extraArgs }}
- {{ . | quote }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
env:
- name: GONAVI_DATA_ROOT
value: {{ .Values.mcp.dataRoot | quote }}
- name: GONAVI_LOG_DIR
value: {{ .Values.mcp.logDir | quote }}
- name: GONAVI_MCP_HTTP_ADDR
value: {{ .Values.mcp.httpAddr | quote }}
- name: GONAVI_MCP_HTTP_PATH
value: {{ .Values.mcp.httpPath | quote }}
- name: GONAVI_MCP_SCHEMA_ONLY
value: {{ ternary "true" "false" .Values.mcp.schemaOnly | quote }}
- name: GONAVI_MCP_HTTP_TOKEN
valueFrom:
secretKeyRef:
name: {{ include "gonavi-mcp-server.secretName" . }}
key: {{ .Values.secret.tokenKey }}
{{- with .Values.mcp.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
- name: gonavi-data
mountPath: {{ .Values.data.mountPath | quote }}
readOnly: true
{{- if ne $driversMode "none" }}
- name: gonavi-drivers
mountPath: {{ .Values.drivers.mountPath | quote }}
readOnly: true
{{- end }}
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 20
resources:
{{- toYaml .Values.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.containerSecurityContext | nindent 12 }}
volumes:
- name: gonavi-data
{{- if eq $dataMode "secret" }}
projected:
sources:
- secret:
name: {{ include "gonavi-mcp-server.secretName" . }}
items:
- key: {{ .Values.secret.connectionsJsonKey }}
path: connections.json
- key: {{ .Values.secret.dailySecretsJsonKey }}
path: daily_secrets.json
{{- else if eq $dataMode "hostPath" }}
hostPath:
path: {{ required "data.hostPath.path is required when data.mode=hostPath" .Values.data.hostPath.path | quote }}
type: {{ default "Directory" .Values.data.hostPath.type | quote }}
{{- else if eq $dataMode "pvc" }}
persistentVolumeClaim:
claimName: {{ required "data.pvc.claimName is required when data.mode=pvc" .Values.data.pvc.claimName | quote }}
{{- end }}
{{- if eq $driversMode "pvc" }}
- name: gonavi-drivers
persistentVolumeClaim:
claimName: {{ required "drivers.pvc.claimName is required when drivers.mode=pvc" .Values.drivers.pvc.claimName | quote }}
{{- else if eq $driversMode "hostPath" }}
- name: gonavi-drivers
hostPath:
path: {{ required "drivers.hostPath.path is required when drivers.mode=hostPath" .Values.drivers.hostPath.path | quote }}
type: {{ default "Directory" .Values.drivers.hostPath.type | quote }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -1,34 +0,0 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "gonavi-mcp-server.fullname" . }}
namespace: {{ include "gonavi-mcp-server.namespace" . }}
labels:
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className | quote }}
{{- with .Values.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path | quote }}
pathType: {{ .pathType | quote }}
backend:
service:
name: {{ include "gonavi-mcp-server.serviceName" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -1,27 +0,0 @@
{{- if .Values.secret.create }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "gonavi-mcp-server.secretName" . }}
namespace: {{ include "gonavi-mcp-server.namespace" . }}
labels:
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
type: Opaque
stringData:
{{ .Values.secret.tokenKey }}: {{ required "secret.stringData.token is required when secret.create=true" .Values.secret.stringData.token | quote }}
{{- if eq .Values.data.mode "secret" }}
{{ .Values.secret.connectionsJsonKey }}: |
{{ required "secret.stringData.connectionsJson is required when data.mode=secret and secret.create=true" .Values.secret.stringData.connectionsJson | nindent 4 }}
{{ .Values.secret.dailySecretsJsonKey }}: |
{{ required "secret.stringData.dailySecretsJson is required when data.mode=secret and secret.create=true" .Values.secret.stringData.dailySecretsJson | nindent 4 }}
{{- else }}
{{- if .Values.secret.stringData.connectionsJson }}
{{ .Values.secret.connectionsJsonKey }}: |
{{ .Values.secret.stringData.connectionsJson | nindent 4 }}
{{- end }}
{{- if .Values.secret.stringData.dailySecretsJson }}
{{ .Values.secret.dailySecretsJsonKey }}: |
{{ .Values.secret.stringData.dailySecretsJson | nindent 4 }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -1,20 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "gonavi-mcp-server.serviceName" . }}
namespace: {{ include "gonavi-mcp-server.namespace" . }}
labels:
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "gonavi-mcp-server.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP

View File

@@ -1,4 +0,0 @@
drivers:
mode: pvc
pvc:
claimName: gonavi-drivers

View File

@@ -1,19 +0,0 @@
ingress:
enabled: true
className: nginx
hosts:
- host: gonavi-mcp.example.com
paths:
- path: /mcp
pathType: Prefix
- path: /healthz
pathType: Prefix
tls:
- secretName: gonavi-mcp-server-tls
hosts:
- gonavi-mcp.example.com
drivers:
mode: pvc
pvc:
claimName: gonavi-drivers

View File

@@ -1,14 +0,0 @@
ingress:
enabled: true
className: nginx
hosts:
- host: gonavi-mcp.example.com
paths:
- path: /mcp
pathType: Prefix
- path: /healthz
pathType: Prefix
tls:
- secretName: gonavi-mcp-server-tls
hosts:
- gonavi-mcp.example.com

View File

@@ -1,13 +0,0 @@
secret:
create: true
stringData:
token: replace-with-a-random-token
connectionsJson: |
{
"connections": []
}
dailySecretsJson: |
{
"schemaVersion": 1,
"connections": {}
}

View File

@@ -1,10 +0,0 @@
data:
mode: hostPath
hostPath:
path: /volume1/docker/gonavi/data
type: Directory
secret:
create: true
stringData:
token: replace-with-a-random-token

View File

@@ -1,99 +0,0 @@
nameOverride: ""
fullnameOverride: ""
namespaceOverride: ""
replicaCount: 1
image:
repository: ghcr.io/syngnat/gonavi-mcp-server
tag: latest
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8765
annotations: {}
mcp:
dataRoot: /data
logDir: /var/lib/gonavi/logs
httpAddr: 0.0.0.0:8765
httpPath: /mcp
schemaOnly: true
extraArgs: []
extraEnv: []
secret:
create: false
name: gonavi-mcp-server-data
tokenKey: GONAVI_MCP_HTTP_TOKEN
connectionsJsonKey: connections.json
dailySecretsJsonKey: daily_secrets.json
stringData:
token: ""
connectionsJson: ""
dailySecretsJson: ""
data:
mode: secret
mountPath: /data
hostPath:
path: /volume1/docker/gonavi/data
type: Directory
pvc:
claimName: ""
drivers:
mode: none
mountPath: /data/drivers
pvc:
claimName: gonavi-drivers
hostPath:
path: /volume1/docker/gonavi/drivers
type: Directory
ingress:
enabled: false
className: nginx
annotations:
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
hosts:
- host: gonavi-mcp.example.com
paths:
- path: /mcp
pathType: Prefix
- path: /healthz
pathType: Prefix
tls:
- secretName: gonavi-mcp-server-tls
hosts:
- gonavi-mcp.example.com
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
containerSecurityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -1,151 +0,0 @@
# GoNavi MCP Server Kubernetes 示例
这个目录提供 `gonavi-mcp-server` 的最小 K8s 部署清单,适合:
- Linux 服务器上的单集群部署
- NAS 自带 K8s / k3s / k8s-lite 环境
- 需要把 GoNavi MCP 通过 Ingress / Gateway 暴露给远端 Agent 的场景
如果你希望通过 values 参数统一控制镜像、Secret、Ingress、PVC/hostPath而不是维护多份 overlay请直接使用 [deploy/helm/gonavi-mcp-server](../../helm/gonavi-mcp-server)。
目录结构:
- `kustomization.yaml`:基础部署入口
- `base/kustomization.yaml`:基础资源集合
- `overlays/nas-hostpath`:直接挂 NAS / 单机节点上的 GoNavi 数据目录
- `base/deployment.yaml` / `base/service.yaml`:基础资源
- `overlays/drivers-pvc`:为 `/data/drivers` 增加 PVC 挂载
- `overlays/ingress`:增加 Ingress 暴露 `/mcp`
- `overlays/ingress-with-drivers-pvc`:同时启用 Ingress 和 drivers PVC
## 前提
容器仍然依赖 GoNavi 的活动数据目录。至少要准备:
- `connections.json`
- `daily_secrets.json`
如果目标连接依赖可选 driver agent还需要额外挂载 `/data/drivers`
`base/deployment.yaml` 默认镜像指向:
```text
ghcr.io/syngnat/gonavi-mcp-server:latest
```
如果你要跟随 `dev` 分支,可以改成:
```text
ghcr.io/syngnat/gonavi-mcp-server:dev-latest
```
如果你要使用自建镜像,再按下面方式覆盖 `image`
- 在当前节点可访问的 Docker / Podman / containerd 环境中构建并导入这个镜像
- 把镜像推送到你的私有仓库,并同步修改 `base/deployment.yaml` 里的 `image`
例如:
```bash
docker build -f Dockerfile.mcp-server -t gonavi-mcp-server:local .
# or
podman build -f Dockerfile.mcp-server -t localhost/gonavi-mcp-server:local .
```
## 1. 创建 Secret
推荐直接从现有 GoNavi 数据文件生成:
```bash
kubectl create secret generic gonavi-mcp-server-data \
--from-file=connections.json=/absolute/path/to/gonavi-data/connections.json \
--from-file=daily_secrets.json=/absolute/path/to/gonavi-data/daily_secrets.json \
--from-literal=GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
```
如需单独 namespace请先执行
```bash
kubectl create namespace gonavi
kubectl -n gonavi create secret generic gonavi-mcp-server-data \
--from-file=connections.json=/absolute/path/to/gonavi-data/connections.json \
--from-file=daily_secrets.json=/absolute/path/to/gonavi-data/daily_secrets.json \
--from-literal=GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
```
## 2. 应用清单
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server
```
如果使用独立 namespace请先把 YAML 里的 `namespace` 改成你的目标值,或自行用 `kustomize` / Helm 做二次封装。
清单内已经带了基础安全上下文和默认资源配额;如果你的连接数量、对象规模或并发访问更高,建议按实际负载调整 `resources`
### 常用 Overlay
仅基础部署:
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server
```
需要可选 driver agent
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/drivers-pvc
```
NAS / k3s 单机节点直接挂宿主机目录:
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/nas-hostpath
```
需要对外暴露 `/mcp`
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/ingress
```
同时需要 Ingress 与 drivers PVC
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/ingress-with-drivers-pvc
```
应用 overlay 前,记得先改里面的占位值,例如:
- `overlays/ingress/ingress.yaml` 里的域名、TLS Secret、`ingressClassName`
- `overlays/drivers-pvc/patch-deployment.yaml` 里的 PVC 名称 `gonavi-drivers`
- `overlays/nas-hostpath/patch-deployment.yaml` 里的宿主机目录 `/volume1/docker/gonavi/data`
`nas-hostpath` overlay 会直接把整个 GoNavi 数据目录挂到 `/data`,因此 `connections.json``daily_secrets.json``drivers/` 都从宿主机目录读取;此时仍建议保留 `GONAVI_MCP_HTTP_TOKEN` 的 Secret 注入,不要把 token 直接硬编码进 Deployment。
## 3. 校验
```bash
kubectl get pods -n gonavi
kubectl get svc -n gonavi
kubectl port-forward -n gonavi svc/gonavi-mcp-server 8765:8765
curl -H "Authorization: Bearer replace-with-a-random-token" http://127.0.0.1:8765/healthz
```
`/healthz` 返回 `ok` 说明 Pod 已对外提供 HTTP 服务。
## 4. 暴露给远端 Agent
- 集群内使用:直接访问 `http://gonavi-mcp-server.gonavi.svc.cluster.local:8765/mcp`
- 集群外使用:通过 Ingress / Gateway / 反向代理暴露 `/mcp`
- 远端 Agent 请求头:`Authorization: Bearer <你的 token>`
## 5. drivers 挂载
当前示例默认只挂载 `connections.json``daily_secrets.json`。如果保存连接里包含 Dameng、ClickHouse、DuckDB 等依赖 driver agent 的数据源,请在 Deployment 中额外补一个卷,把宿主机或 PVC 中的 `drivers/` 目录挂载到:
```text
/data/drivers
```
否则对应连接会因为缺少 driver agent 无法使用。

View File

@@ -1,89 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gonavi-mcp-server
namespace: gonavi
labels:
app.kubernetes.io/name: gonavi-mcp-server
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: gonavi-mcp-server
template:
metadata:
labels:
app.kubernetes.io/name: gonavi-mcp-server
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: gonavi-mcp-server
# 如需跟随 dev 分支,可改成 ghcr.io/syngnat/gonavi-mcp-server:dev-latest
image: ghcr.io/syngnat/gonavi-mcp-server:latest
imagePullPolicy: IfNotPresent
args:
- http
ports:
- name: http
containerPort: 8765
protocol: TCP
env:
- name: GONAVI_DATA_ROOT
value: /data
- name: GONAVI_LOG_DIR
value: /var/lib/gonavi/logs
- name: GONAVI_MCP_HTTP_ADDR
value: 0.0.0.0:8765
- name: GONAVI_MCP_HTTP_PATH
value: /mcp
- name: GONAVI_MCP_SCHEMA_ONLY
value: "true"
- name: GONAVI_MCP_HTTP_TOKEN
valueFrom:
secretKeyRef:
name: gonavi-mcp-server-data
key: GONAVI_MCP_HTTP_TOKEN
volumeMounts:
- name: gonavi-data
mountPath: /data
readOnly: true
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 20
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
volumes:
- name: gonavi-data
projected:
sources:
- secret:
name: gonavi-mcp-server-data
items:
- key: connections.json
path: connections.json
- key: daily_secrets.json
path: daily_secrets.json

View File

@@ -1,6 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml

View File

@@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: gonavi-mcp-server
namespace: gonavi
labels:
app.kubernetes.io/name: gonavi-mcp-server
spec:
selector:
app.kubernetes.io/name: gonavi-mcp-server
ports:
- name: http
port: 8765
targetPort: http
protocol: TCP

View File

@@ -1,5 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- base

View File

@@ -1,8 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: patch-deployment.yaml

View File

@@ -1,18 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gonavi-mcp-server
namespace: gonavi
spec:
template:
spec:
containers:
- name: gonavi-mcp-server
volumeMounts:
- name: gonavi-drivers
mountPath: /data/drivers
readOnly: true
volumes:
- name: gonavi-drivers
persistentVolumeClaim:
claimName: gonavi-drivers

View File

@@ -1,33 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gonavi-mcp-server
namespace: gonavi
annotations:
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
tls:
- hosts:
- gonavi-mcp.example.com
secretName: gonavi-mcp-server-tls
rules:
- host: gonavi-mcp.example.com
http:
paths:
- path: /mcp
pathType: Prefix
backend:
service:
name: gonavi-mcp-server
port:
number: 8765
- path: /healthz
pathType: Prefix
backend:
service:
name: gonavi-mcp-server
port:
number: 8765

View File

@@ -1,9 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
- ingress.yaml
patches:
- path: patch-deployment.yaml

View File

@@ -1,18 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gonavi-mcp-server
namespace: gonavi
spec:
template:
spec:
containers:
- name: gonavi-mcp-server
volumeMounts:
- name: gonavi-drivers
mountPath: /data/drivers
readOnly: true
volumes:
- name: gonavi-drivers
persistentVolumeClaim:
claimName: gonavi-drivers

View File

@@ -1,33 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gonavi-mcp-server
namespace: gonavi
annotations:
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
tls:
- hosts:
- gonavi-mcp.example.com
secretName: gonavi-mcp-server-tls
rules:
- host: gonavi-mcp.example.com
http:
paths:
- path: /mcp
pathType: Prefix
backend:
service:
name: gonavi-mcp-server
port:
number: 8765
- path: /healthz
pathType: Prefix
backend:
service:
name: gonavi-mcp-server
port:
number: 8765

View File

@@ -1,6 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
- ingress.yaml

View File

@@ -1,8 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: patch-deployment.yaml

View File

@@ -1,14 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gonavi-mcp-server
namespace: gonavi
spec:
template:
spec:
volumes:
- $patch: replace
name: gonavi-data
hostPath:
path: /volume1/docker/gonavi/data
type: Directory

View File

@@ -1,101 +0,0 @@
# GoNavi MCP Server Podman 示例
这个目录提供 `gonavi-mcp-server` 的 Podman 原生部署入口,覆盖两类常见场景:
- 直接用 `podman run` 在 Linux 服务器 / NAS 上启动
- 用 rootless Quadlet + systemd 做常驻服务
目录内容:
- `gonavi-mcp-server.env.example`:给 `podman run` / Quadlet 共用的容器环境变量示例
- `gonavi-mcp-server.container`Quadlet 示例
前提仍然一样:宿主机 GoNavi 活动数据目录内至少应包含:
- `connections.json`
- `daily_secrets.json`
如果目标连接依赖可选 driver agent还要保证同一数据目录下已有 `drivers/`
## 1. 直接运行已发布镜像
```bash
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ./gonavi-mcp-server.env
```
`GONAVI_MCP_HTTP_TOKEN` 改成随机值后运行:
```bash
podman run -d --name gonavi-mcp-server --replace \
-p 8765:8765 \
--env-file ./gonavi-mcp-server.env \
-v /absolute/path/to/gonavi-data:/data:Z \
ghcr.io/syngnat/gonavi-mcp-server:latest http
```
查看状态:
```bash
podman ps
podman logs -f gonavi-mcp-server
```
如果你的宿主机没有启用 SELinux可把挂载参数末尾的 `:Z` 去掉;如果启用了 SELinux建议保留它。
## 2. 本地源码构建镜像
仓库根目录已经提供 `.containerignore`Podman 会优先读取它来裁剪构建上下文。
```bash
podman build -f Dockerfile.mcp-server -t localhost/gonavi-mcp-server:local .
podman run -d --name gonavi-mcp-server --replace \
-p 8765:8765 \
--env-file ./gonavi-mcp-server.env \
-v /absolute/path/to/gonavi-data:/data:Z \
localhost/gonavi-mcp-server:local http
```
## 3. Rootless Quadlet 常驻服务
Podman Quadlet 适合 Linux 服务器 / NAS 上做 rootless 常驻服务。
把文件放到用户级目录:
```bash
mkdir -p ~/.config/containers/systemd
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.container ~/.config/containers/systemd/
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ~/.config/containers/systemd/gonavi-mcp-server.env
```
然后修改两处:
- `~/.config/containers/systemd/gonavi-mcp-server.container` 里的 `Volume=/absolute/path/to/gonavi-data:/data:Z`
- `~/.config/containers/systemd/gonavi-mcp-server.env` 里的 `GONAVI_MCP_HTTP_TOKEN`
启动服务:
```bash
systemctl --user daemon-reload
systemctl --user enable --now gonavi-mcp-server.service
systemctl --user status gonavi-mcp-server.service
journalctl --user -u gonavi-mcp-server.service -f
```
如果你希望用户退出登录后服务仍然保持运行,再执行:
```bash
loginctl enable-linger "$USER"
```
Rootless Quadlet 默认搜索路径是 `~/.config/containers/systemd/`;如果你要做系统级 rootful 部署,可改放到 `/etc/containers/systemd/`
## 4. 关于 Compose
Podman 的 `podman compose` 依赖外部 compose provider。也就是说是否能直接复用仓库根目录的 `docker-compose.mcp-server.yml`,取决于你的 Podman 环境是否已经安装并配置了对应 provider。
因此,这个仓库对 Podman 的主支持路径是:
- `podman run`
- Quadlet推荐长期运行
如果你的环境已经有可用的 compose provider再去复用根目录 Compose 文件即可。

View File

@@ -1,22 +0,0 @@
[Unit]
Description=GoNavi MCP Server (rootless Podman)
Wants=network-online.target
After=network-online.target
[Container]
ContainerName=gonavi-mcp-server
Image=ghcr.io/syngnat/gonavi-mcp-server:latest
Pull=newer
EnvironmentFile=./gonavi-mcp-server.env
# 修改成你的 GoNavi 活动数据目录SELinux 主机建议保留 :Z
Volume=/absolute/path/to/gonavi-data:/data:Z
PublishPort=8765:8765
Exec=http
LogDriver=journald
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=default.target

View File

@@ -1,15 +0,0 @@
# 容器内 GoNavi 活动数据根目录。通常不需要修改Volume= 已把宿主机目录挂到 /data。
GONAVI_DATA_ROOT=/data
# 日志目录。默认与镜像内目录保持一致。
GONAVI_LOG_DIR=/var/lib/gonavi/logs
# MCP HTTP 监听地址与路径。
GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765
GONAVI_MCP_HTTP_PATH=/mcp
# 远程 MCP 客户端访问时必须携带的 Bearer Token
GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
# true = 只暴露结构查询工具,不注册 execute_sql
GONAVI_MCP_SCHEMA_ONLY=true

View File

@@ -1,7 +0,0 @@
services:
gonavi-mcp-server:
build:
context: .
dockerfile: Dockerfile.mcp-server
image: ${GONAVI_MCP_IMAGE_LOCAL:-gonavi-mcp-server:local}
pull_policy: never

View File

@@ -1,20 +0,0 @@
services:
gonavi-mcp-server:
image: ${GONAVI_MCP_IMAGE:-ghcr.io/syngnat/gonavi-mcp-server:latest}
pull_policy: ${GONAVI_MCP_PULL_POLICY:-missing}
container_name: gonavi-mcp-server
restart: unless-stopped
ports:
- "${GONAVI_MCP_HTTP_PORT:-8765}:8765"
environment:
GONAVI_DATA_ROOT: /data
GONAVI_LOG_DIR: /var/lib/gonavi/logs
GONAVI_MCP_HTTP_ADDR: 0.0.0.0:8765
GONAVI_MCP_HTTP_PATH: ${GONAVI_MCP_HTTP_PATH:-/mcp}
GONAVI_MCP_HTTP_TOKEN: ${GONAVI_MCP_HTTP_TOKEN}
GONAVI_MCP_SCHEMA_ONLY: ${GONAVI_MCP_SCHEMA_ONLY:-true}
volumes:
- type: bind
source: ${GONAVI_HOST_DATA_ROOT}
target: /data
command: ["http"]

View File

@@ -1,7 +0,0 @@
services:
gonavi-web-server:
build:
context: .
dockerfile: Dockerfile.web-server
image: ${GONAVI_WEB_IMAGE_LOCAL:-gonavi-web-server:local}
pull_policy: never

View File

@@ -1,24 +0,0 @@
services:
gonavi-web-server:
image: ${GONAVI_WEB_IMAGE:-ghcr.io/syngnat/gonavi-web-server:latest}
pull_policy: ${GONAVI_WEB_PULL_POLICY:-missing}
container_name: gonavi-web-server
restart: unless-stopped
ports:
- "${GONAVI_WEB_HTTP_PORT:-34116}:34116"
environment:
GONAVI_DATA_ROOT: /data
GONAVI_LOG_DIR: /var/lib/gonavi/logs
GONAVI_WEB_ADDR: 0.0.0.0:34116
GONAVI_WEB_PASSWORD: ${GONAVI_WEB_PASSWORD:-}
volumes:
- type: bind
source: ${GONAVI_HOST_DATA_ROOT}
target: /data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:34116/__gonavi/healthz"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
command: ["web-server"]

View File

@@ -1,20 +0,0 @@
# 宿主机 GoNavi 活动数据目录,目录内至少应包含 connections.json 与 daily_secrets.json
GONAVI_HOST_DATA_ROOT=/absolute/path/to/gonavi-data
# 预构建镜像地址。稳定版通常使用 latest跟随 dev 分支可改成 ghcr.io/syngnat/gonavi-mcp-server:dev-latest
GONAVI_MCP_IMAGE=ghcr.io/syngnat/gonavi-mcp-server:latest
# 预构建镜像拉取策略always / missing / never
GONAVI_MCP_PULL_POLICY=missing
# 远程 MCP 客户端访问时必须携带的 Bearer Token
GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
# 宿主机暴露端口
GONAVI_MCP_HTTP_PORT=8765
# MCP HTTP 路径
GONAVI_MCP_HTTP_PATH=/mcp
# true = 只暴露结构查询工具,不注册 execute_sql
GONAVI_MCP_SCHEMA_ONLY=true

View File

@@ -1,19 +0,0 @@
# 宿主机 GoNavi 活动数据目录。
# 建议至少包含connections.json、daily_secrets.json
# 如需可选驱动代理,一并挂载 drivers/。
# Web 认证状态会写入 web_auth.json未设置环境密码时首次访问 /setup 初始化。
GONAVI_HOST_DATA_ROOT=/absolute/path/to/gonavi-data
# 预构建镜像地址。稳定版通常使用 latest跟随 dev 分支可改成 ghcr.io/syngnat/gonavi-web-server:dev-latest
GONAVI_WEB_IMAGE=ghcr.io/syngnat/gonavi-web-server:latest
# 预构建镜像拉取策略always / missing / never
GONAVI_WEB_PULL_POLICY=missing
# 宿主机暴露端口(映射到容器 34116
GONAVI_WEB_HTTP_PORT=34116
# Web 管理员密码(至少 6 位)。留空时首次访问 /setup 初始化;公网部署请使用更强密码。
# 设置或修改后请执行 docker compose up -d --force-recreate请限制本文件的读取权限。
# 新部署如需启用 2FA请先留空并通过 /setup 初始化,再设置为同一密码并重新创建容器。
GONAVI_WEB_PASSWORD=

View File

@@ -1,355 +0,0 @@
# Cmd/Ctrl+W 标签关闭快捷键设计
- 日期2026-07-20
- 状态:已确认,待实施
- 适用范围GoNavi 主窗口的工作区标签和 SQL 查询结果区标签
## 1. 背景
GoNavi 当前没有统一的 `Cmd+W` / `Ctrl+W` 应用内关闭语义。工作区标签由
`TabManager` 管理,查询结果标签由每个 `QueryEditor` 的本地状态管理,两套关闭
流程拥有不同的保护规则。若分别注册全局键盘监听,同一次按键可能同时关闭结果
标签和工作区标签。
本设计增加一个可配置动作 `closeActiveTab`,由唯一的全局入口根据用户最后一次
明确交互的区域,将关闭请求路由到结果区或工作区。
## 2. 目标
1. macOS 默认使用 `Meta+W`Windows/Linux 默认使用 `Ctrl+W`
2. 快捷键在设置中心可修改、启用或禁用。
3. 同一次按键最多执行一个关闭动作,结果区与工作区之间不允许穿透。
4. 复用现有关闭流程,保留未保存 SQL、运行中导入任务等保护。
5. 结果区日志标签保持常驻;日志标签活跃时,快捷键隐藏整个结果区。
6. 结果区被快捷键隐藏后,连续按键不得继续关闭工作区标签。
## 3. 非目标
1. 不新增“分别关闭结果标签”和“分别关闭工作区标签”两个可配置动作。
2. 不改变鼠标关闭按钮、中键关闭或右键批量关闭的产品语义。
3. 不关闭或清空 SQL 日志数据。
4. 不改变后台导出、同步任务在工作区标签关闭后的运行方式。
5. 不统一重构所有历史浮动窗口的关闭入口。
6. 不让主窗口快捷键关闭已经拆出的独立窗口标签或独立结果窗口。
## 4. 方案选择
### 4.1 采用方案:单一全局路由器
`App` 保持唯一的全局 `keydown` capture 监听入口,维护最后一次明确交互区域,
并向目标组件派发单一、明确的关闭命令。
采用该方案的原因:
- 键盘事件只匹配和消费一次,天然避免双重关闭。
- `TabManager``QueryEditor` 继续拥有各自的状态与保护逻辑。
- 路由状态可以独立测试,不需要把查询结果状态上移到全局 store。
- 后续自定义快捷键仍复用现有快捷键设置与冲突检测体系。
### 4.2 未采用方案
- 分区独立监听:工作区和结果区分别监听 `window.keydown`。该方案存在监听顺序、
冒泡阻断和同一 `window` 多监听器重复执行问题。
- 完全依赖 DOM 焦点Monaco、DataGrid、Ant Design Portal 和 `body` 级事件目标会
让焦点不足以可靠表达用户的区域意图。
## 5. 快捷键配置
### 5.1 动作定义
新增动作 `closeActiveTab`,纳入:
- `ShortcutAction`
- `SHORTCUT_ACTION_ORDER`
- `SHORTCUT_ACTION_META`
- `DEFAULT_SHORTCUT_OPTIONS`
- 快捷键设置界面和全部语言目录
动作允许在 Monaco、输入框和 DataGrid 等可编辑目标中触发,因为焦点位于编辑器
时仍应能关闭当前工作区标签。
默认绑定:
| 平台 | 组合键 | 默认状态 |
| --- | --- | --- |
| macOS | `Meta+W` | 启用 |
| Windows/Linux | `Ctrl+W` | 启用 |
GoNavi 正式接管该组合键,因此删除现有“浏览器关闭标签页”的 `Ctrl+W` 保留项;
其他浏览器、Monaco 和 DataGrid 保留项不变。
### 5.2 旧配置迁移
旧持久化配置中不存在 `closeActiveTab` 时,按平台分别迁移:
1. 若该平台没有任何已启用动作占用新默认组合键,补入启用的新动作。
2. 若已有动作占用 `Meta+W``Ctrl+W`,保留原动作及绑定,补入组合键相同但
`enabled: false` 的新动作。
3. 用户可在快捷键设置中修改冲突绑定并重新启用新动作。
4. “恢复默认值”和全新安装始终得到启用的默认绑定。
迁移只处理缺少新动作的旧配置,不覆盖用户已经保存的 `closeActiveTab` 设置。
禁用动作或把动作改绑到其他组合键后,默认 `Meta+W` / `Ctrl+W` 不再执行标签
关闭,但主窗口仍必须消费该平台原生关闭组合键,防止 WebView 把它解释为关闭应用
窗口或浏览器标签。
## 6. 路由状态模型
全局路由器维护 `CloseShortcutScope`
```ts
type CloseShortcutScope = 'workspace' | 'result' | 'blocked';
```
初始状态为 `workspace`
| 当前/触发条件 | 下一状态 | 含义 |
| --- | --- | --- |
| 用户点击或聚焦结果区 | `result` | 关闭请求只发送给当前查询结果区 |
| 用户点击或聚焦编辑器、工作区标签栏或侧边栏 | `workspace` | 关闭请求只发送给主工作区 |
| 快捷键在日志标签上隐藏结果区 | `blocked` | 后续重复按键只消费,不关闭任何标签 |
| `blocked` 后用户明确交互工作区 | `workspace` | 允许重新关闭工作区标签 |
| `blocked` 后用户明确交互重新显示的结果区 | `result` | 允许关闭结果标签或再次隐藏结果区 |
| 用户点击或聚焦独立结果窗口 | `blocked` | 独立窗口交互不得沿用后台工作区归属 |
SQL 执行后程序化展示结果、程序化切换结果标签、store 激活标签等行为不得修改
该状态。只有真实的 `pointerdown``focusin` 才能改变区域归属。
结果区根节点增加 `data-gonavi-close-shortcut-scope="result"`。路由器在 document
capture 阶段监听 `pointerdown``focusin`,通过该标记识别结果区;编辑器、
工作区标签栏和侧边栏归入工作区。
交互浮层与独立结果窗口使用稳定的
`data-gonavi-close-shortcut-guard="true"` 标记或统一 guard class。guard 包含当前可见
的模态框、抽屉、Dropdown、上下文菜单、Select/Picker 弹层、自建交互 Portal 和
`.gn-detached-result-window`。标记仅在浮层打开时处于 active 状态;若组件会保留
隐藏 DOM公共 helper 还必须检查元素的可见性。Tooltip、拖拽预览等不可获得输入
焦点的纯展示 Portal 不属于 guard。
普通浮层 guard 内的 `pointerdown``focusin` 不改变后台区域归属。关闭快捷键
来自 guard 内部时,只消费事件,不向工作区或结果区派发命令。
独立结果窗口是例外:其根节点同时标记
`data-gonavi-close-shortcut-scope="blocked"`。可信的 `pointerdown``focusin`
发生在窗口内部时,立即把路由 ref 写为 `blocked`。这覆盖标题栏拖动等不会夺取
后台 Monaco 焦点的交互;之后即使 `keydown.target` 仍是 Monaco也不得关闭主
工作区。只有用户再次明确交互主工作区或主结果区才恢复对应归属。
会遮挡后台交互的浮层另加
`data-gonavi-close-shortcut-blocks-background="true"`;只要此标记的节点当前可见,
即使键盘事件目标落在 `body`,也只消费、不关闭后台标签。模态框、抽屉、交互
Dropdown/上下文菜单、Select/Picker 弹层使用该标记。独立结果窗口只使用普通
guard不使用 background blocker避免窗口存在时永久禁用主工作区快捷键。
## 7. 键盘事件流
```text
全局 keydown capture
-> 不受 IME 过滤的底层组合键解析
-> 是否命中平台原生关闭键,或已启用的 closeActiveTab 绑定
-> 快捷键录制器优先接管
-> 命中平台原生关闭键时无条件 preventDefault
-> IME/guard 命中时只消费,不派发
-> 解析当前组合键的已启用动作 owner
其他动作 -> 交给原动作处理一次
closeActiveTab -> 读取 CloseShortcutScope 并路由
无 owner 且为平台原生关闭键 -> 消费为 no-op
```
事件处理约束:
1. `App` 是主窗口唯一的全局关闭快捷键入口。
2. 增加一个只做键位与修饰键比较、不会因 IME 状态返回 false 的底层 matcher。
它仅用于判断事件是否需要被应用接管;正常动作执行仍使用现有
`isShortcutMatch` 语义。
3. 路由器分别计算“平台原生关闭组合键”和“当前已启用动作绑定”的底层匹配,
同时按 `SHORTCUT_ACTION_ORDER` 解析当前组合键的已启用 owner。快捷键设置继续
保证同一平台不会新增多个启用 owner遇到历史重复数据时按现有动作顺序只取
第一个。
4. 快捷键录制器拥有优先权。录制期间全局路由器直接退出,由录制器负责
`preventDefault` 和保存绑定,不执行关闭动作。
5. 除录制器分支外,底层 matcher 一旦命中平台原生关闭组合键,必须立即
`preventDefault`,再判断 owner 和当前上下文。绑定 owner 存在但因可编辑目标、
当前标签类型或组件未挂载而无法执行时,动作可以 no-opWebView 默认关闭仍然
必须被抑制。
6. IME composing、全局 composition 状态或 keyCode/which 229 命中时,底层 matcher
仍能识别组合键并消费,但不得派发关闭。
7. 事件目标位于 guard 内,或存在可见的 background blocker 时,只消费、不关闭
后台标签。
8. 若 owner 是 `closeActiveTab`,才进入本设计的工作区/结果区路由。若 owner 是
其他历史动作,关闭路由不得抢占或改成 no-op全局动作继续由 App switch 执行,
查询编辑器动作继续交给其现有 owner并且整条链路只执行该动作一次。
9. 只有平台原生关闭键没有任何已启用 owner 时,才由保护分支消费为 no-op。
10. 关闭路由、IME/guard 保护和无 owner 的平台 no-op 在 `preventDefault` 后使用
`stopImmediatePropagation`,避免 WebView 或同一 `window` 上的其他监听器再次
处理。交给其他 owner 的事件不得在 owner 执行前被关闭路由
`stopImmediatePropagation`,但第 5 条的 `preventDefault` 仍已生效。
11. 一次事件只允许派发一个目标命令,不允许在目标无效时回退到另一区域。
12. `document.body``document.documentElement` 等文档级目标按已记录状态路由。
13. `closeActiveTab` 禁用或改绑,且没有其他已启用 owner 时,平台原生
`Meta+W` / `Ctrl+W` 仍被消费为 no-op自定义绑定只有在动作启用时才被接管。
14. 若真机验证发现 WebView 不向前端交付 `Cmd/Ctrl+W`,才增加 Wails 原生事件
fallbackfallback 必须汇入同一路由器并具备单次事件去重,不能形成第二套关闭
流程。
## 8. 工作区关闭契约
工作区命令由 `TabManager` 处理,目标必须是 `dockedActiveTabId`,不得使用全局
`activeTabId`。不存在主窗口活动标签时,命令为 no-op。
关闭必须复用:
```ts
closeTabsWithSQLFilePrompt(
[dockedActiveTabId],
() => closeTab(dockedActiveTabId),
);
```
由此保留:
- 外部 SQL 文件未保存时的保存、丢弃、取消确认。
- 外部 SQL 文件被移动或删除时的确认。
- 防止重复打开关闭确认。
- 运行中的 `data-import` 工作台关闭保护。
- store 已有的关闭后活动标签选择和关联资源清理。
`table-export``data-sync` 的任务状态独立于标签组件,仍允许关闭工作区标签并
在后台继续运行。
## 9. 结果区关闭契约
结果区命令只发送给主窗口当前活动的 `QueryEditor`,由它读取本地
`activeResultKey``resultSets`。组件提取一个共享的纯函数
`resolveEffectiveActiveResultKey`,供结果区渲染和快捷键命令共同使用,不能一方
读取原始 key、另一方使用视觉回退 key。
在支持嵌入 SQL 日志标签的结果区中,日志标签只要结果区可见就无条件存在,日志
数量为 0 时也显示;其 key 统一使用 `QUERY_EDITOR_SQL_LOG_TAB_KEY`
具体行为:
1. 当前键对应真实结果标签时,调用现有 `handleCloseResult`
2. 关闭后由现有逻辑选择相邻结果标签,不在路由器中重复计算。
3. 关闭最后一个真实结果标签后,活动状态明确切到日志 key结果区继续显示常驻
日志标签;即使历史状态中原始 key 为空,共享 resolver 也必须得到同一日志 key。
4. 当前键为日志标签时,不删除日志;隐藏整个结果区,并向路由器回报
`hidden`,使路由状态进入 `blocked`
5. 当前键缺失、过期或不属于真实结果与日志时,消费请求并回报 `ignored`,不得
回退关闭工作区。
6. 结果区已经隐藏时,结果命令为 no-op仍不得穿透到工作区。
结果命令需要一个明确的完成回报 `closed | hidden | ignored`。命令派发和回报必须
在当前 `keydown` 调用栈内同步完成,不使用 Promise 或等待 React render。可通过
同步 `dispatchEvent` 的可变 request detail 或等价的同步命令注册表返回 outcome。
路由状态以 ref/原子值为准,键盘监听不得读取可能滞后的 React state。收到
`hidden` 后,必须在监听器返回前写入 `scopeRef.current = 'blocked'``closed`
`ignored` 保持 `result`。因此即使同一 task 内连续派发两个 `keydown`,第二次也会
读到 `blocked`
结果命令同样读取 `resultSetsRef``activeResultKeyRef`。关闭动作在提交 React
state 更新的同时同步写入这两个 ref使同一 task 内的下一次命令能看到相邻结果
或日志 key而不是重复处理已经关闭的结果。
独立结果窗口不向主窗口发送工作区关闭命令。主窗口路由器也不控制独立结果窗口
的生命周期。
## 10. 错误与竞态处理
- 快速重复按键:工作区沿用关闭确认的互斥保护;结果区每次根据最新本地状态
处理。日志隐藏后立即切换为 `blocked`
- 结果标签关闭与查询完成并发:以命令处理时的最新 `activeResultKey` 为准;无效
key 返回 `ignored`
- 活动工作区标签在事件间切换:`TabManager` 在命令处理时读取最新
`dockedActiveTabId`
- 组件卸载:命令没有有效接收者时 no-op不向另一区域补发。
- 自定义绑定冲突:继续使用现有冲突提示;运行时不同时执行两个动作。
## 11. 测试设计
### 11.1 快捷键工具测试
- 新动作默认绑定、动作顺序、元信息和平台显示。
- 新动作允许在可编辑目标触发。
- `Ctrl+W` 不再报告浏览器保留冲突。
- 自定义绑定、禁用和恢复默认值。
- 动作禁用或改绑后,平台原生关闭组合键仍被消费但不关闭标签或窗口。
- 旧配置无冲突时自动启用新动作。
- 旧配置占用默认组合键时保留旧动作并禁用新动作,且按平台隔离。
- 迁移后按原生关闭组合键仍只执行原动作一次不执行关闭动作WebView 也不关闭。
- 历史 owner 因可编辑目标、非查询标签或组件未挂载而无法执行时,不执行任何动作,
但仍阻止 WebView 原生关闭。
- 所有语言包含新动作的 label/description。
### 11.2 路由器测试
- 初始状态关闭工作区,不触发结果区。
- 明确点击结果区后只关闭结果标签。
- 点击 Monaco、工作区标签栏或侧边栏后只关闭工作区标签。
- SQL 执行后程序化激活结果不抢占归属。
- 日志标签隐藏结果区并进入 `blocked`
- `blocked` 状态连续按键不关闭工作区;明确交互工作区后才恢复。
- 无效结果状态只消费,不穿透工作区。
- `body``documentElement` 目标使用最后明确区域。
- 快捷键录制期间不执行关闭。
- IME composing / keyCode 229 下,底层 matcher 仍消费默认或自定义关闭组合键,但
不执行关闭。
- 模态框、抽屉、下拉菜单和 Portal 活跃时不关闭后台标签。
- Tooltip 和拖拽预览等纯展示 Portal 不触发 guard。
- 保持后台 Monaco 焦点,初始状态为 `workspace` 时点击不可聚焦的独立结果窗口
标题栏,再按快捷键也不得关闭主工作区。
- 独立结果窗口仅仅存在时,不阻止主工作区内正常使用关闭快捷键。
- 可见 background blocker 存在且事件目标为 `body` 时也不得关闭后台标签。
- 同一个键盘事件最多派发一个关闭命令。
- `hidden` 回报后不等待重渲染,立即连续派发第二个事件仍保持 blocked。
### 11.3 工作区集成测试
- 使用 `dockedActiveTabId`,独立窗口处于全局 active 时也不关错标签。
- 外部 SQL 脏文件保留保存/丢弃/取消流程。
- 外部 SQL 文件丢失确认仍生效。
- 运行中数据导入工作台无法被快捷键关闭。
- 导出、同步工作台标签允许关闭,后台任务继续。
- 没有 docked 标签时 no-op。
### 11.4 结果区集成测试
- 关闭真实结果标签并正确选择相邻标签。
- 关闭最后一个真实结果后显示日志标签。
- 关闭最后一个真实结果后,命令和渲染共同解析为日志 key下一次快捷键隐藏
结果区。
- 日志标签不会被删除,结果区整体隐藏。
- 计数请求等现有清理逻辑仍由 `handleCloseResult` 执行。
- 结果区隐藏、active key 过期、组件卸载时均不关闭工作区。
- 独立结果窗口不会把命令路由回主工作区。
### 11.5 真机验收
- macOS 触摸板、Monaco、DataGrid 和普通面板焦点下验证 `Cmd+W`
- Windows/Linux 对应验证 `Ctrl+W`
- 验证 WebView 不执行原生窗口或浏览器标签关闭。
- 验证自定义组合键、完全禁用状态及 IME 组合输入;改绑或禁用后默认平台关闭键
仍是应用内 no-op。
## 12. 验收标准
1. 默认快捷键、自定义和禁用均按平台生效。
2. 结果区、工作区和 `blocked` 三种状态的路由符合本设计。
3. 一次按键不会同时关闭结果标签和工作区标签。
4. 日志标签始终保留;日志活跃时隐藏的是整个结果区。
5. 隐藏结果区后连续按键不会误关工作区标签。
6. 现有 SQL 文件和数据导入保护没有被绕过。
7. 旧用户配置不会因升级被静默覆盖或产生双动作。
8. 自动化测试和 macOS/Windows 真机验证通过。
## 13. 预计影响文件
- `frontend/src/utils/shortcuts.ts`
- `frontend/src/App.tsx`
- `frontend/src/components/TabManager.tsx`
- `frontend/src/components/QueryEditor.tsx`
- `frontend/src/components/QueryEditorResultsPanel.tsx`
- `frontend/src/i18n/catalog.ts` 及目录完整性测试
- 快捷键、App、TabManager、QueryEditor 相关测试文件

View File

@@ -1,45 +0,0 @@
# 需求进度追踪 - Oracle对象跳转与存储过程修改执行
## 1. 需求摘要
- 需求名称Oracle对象跳转与存储过程修改执行
- 提出日期2026-06-25
- 负责人Codex
- 目标SQL编辑器支持 Oracle 序列、存储包 Ctrl/Cmd 点击跳转;对象编辑执行 Oracle 存储过程时不再生成非法第二条语句。
- 非目标:不调整 Oracle 连接驱动、不改数据库 schema、不重构侧栏对象树。
## 2. 范围与验收
- 范围QueryEditor 对象元数据、hover/跳转逻辑、DefinitionViewer 对象编辑 SQL 生成、相关 i18n 和回归测试。
- 验收标准:序列/存储包在 SQL 编辑器可出现链接提示并打开定义页;带 SQLPlus `/` 的 Oracle PL/SQL 编辑 SQL 不生成 `/;`,执行时保留完整定义交给后端拆分。
- 依赖与约束:复用现有侧栏 sequence/package tab 类型;不混入 `frontend/wailsjs/go/models.ts` 既有未提交改动。
## 3. 里程碑与进度
- [x] 阶段 1需求澄清确认序列/存储包侧栏已可打开,但 SQL 编辑器跳转缺失;存储过程修改仍报 ORA-00900。
- [x] 阶段 2影响分析影响前端 QueryEditor、DefinitionViewer、i18n、测试后端拆分已有基础保护。
- [x] 阶段 3方案设计补齐元数据与跳转类型对象编辑 SQL 保留 SQLPlus `/` 语义。
- [x] 阶段 4实施计划先补导航闭环再修 PL/SQL 生成/执行,最后跑定向测试。
- [x] 阶段 5实现与自检已完成定向测试、QueryEditor 全文件测试、前端 build、后端 Oracle 拆分测试。
- [ ] 阶段 6评审与交付待提交推送。
- [ ] 阶段 7发布与观察发布后验证 Oracle 对象编辑与 SQL 编辑器跳转。
## 4. 变更清单
- 已完成:补齐 QueryEditor sequence/package 元数据、hover、点击打开定义页修复对象编辑 SQLPlus `/;`;执行路径兼容旧编辑页 `/;`
- 进行中:提交并推送。
- 待处理:发布后验证 Oracle 实库。
## 5. 风险与阻塞
- 风险:三段名称 `schema.package.proc``db.schema.table` 存在歧义。
- 阻塞:无。
- 缓解措施:仅当第一段不是可见数据库时,将三段名称按 Oracle schema-qualified sequence/package 解析。
## 6. 决策记录
- 决策 1序列和存储包复用侧栏现有 `sequence-def` / `package-def` tab 结构。
- 决策 2Oracle PL/SQL 定义执行时保留 SQLPlus `/` 分隔符,由后端拆分器去掉 delimiter避免前端用分号重组造成 ORA-00900。
## 7. 验证记录
- 验证项QueryEditor 导航回归测试、DefinitionViewer 对象编辑测试、SQL 语句拆分测试、i18n catalog 测试、前端 build、后端 Oracle 拆分测试。
- 结果:通过。
- 证据(日志/截图/链接):`npm test -- src/components/QueryEditor.external-sql-save.test.tsx``npm test -- src/components/DefinitionViewer.object-edit.test.tsx src/i18n/catalog.test.ts src/utils/sqlStatementSelection.test.ts``go test ./internal/app -run 'TestDBQueryMultiKeepsOracle|TestDBQueryMultiSkipsOracle'``npm run build`
## 8. 下一步
- 下一步行动:提交并推送到 dev。
- 负责人Codex

View File

@@ -1,74 +0,0 @@
# 需求进度追踪 - 生产连接只读保护
## 1. 需求摘要
- 需求名称:生产连接只读保护
- 提出日期2026-06-23
- 负责人Codex
- 目标:为 SQL 类数据库与 MongoDB 连接增加连接级只读保护启用后仅允许查询阻止写入、DDL、导入和同步目标操作
- 非目标:不为所有侧栏写操作都新增前端隐藏逻辑;不引入新的环境分级体系;不改造 JVM 只读能力
## 2. 范围与验收
- 范围:
- 连接配置模型、保存/回填与 RPC 序列化链路
- 连接弹窗只读开关、查询编辑器本地拦截、DataGrid 导入入口收口
- 后端 SQL/Mongo 查询判定与写操作统一守卫
- 验收标准:
- 支持的数据源出现“生产连接/只允许查询”开关
- 启用后普通查询仍可执行,非查询 SQL / Mongo 写命令被前后端阻止
- 导入、结构变更、清表、同步目标等关键写入口被后端拒绝
- 依赖与约束:
- 保持现有数据源能力判定与 QueryEditor 执行链路
- MongoDB 前端判定以保守拦截为主,最终正确性由后端守卫兜底
## 3. 里程碑与进度
- [x] 阶段 1需求澄清确认采用连接级 `readOnly` 布尔字段,不新建环境系统
- [x] 阶段 2影响分析梳理前端能力面板、查询执行、导入与后端写入口
- [x] 阶段 3方案设计确定“前端预拦截 + 后端最终守卫”双层保护
- [x] 阶段 4实施计划接入配置链路、能力判定、查询判定与写入口守卫
- [x] 阶段 5实现与自检补文案、测试与定向验证
- [x] 阶段 6评审与交付确认范围、风险、回滚点和验证命令
- [ ] 阶段 7发布与观察待体验验证
## 4. 变更清单
- 已完成:
- 新增连接级 `readOnly` 配置字段及前后端序列化支持
- 连接弹窗为 SQL 类数据库与 MongoDB 增加生产连接保护开关
- QueryEditor 增加本地非查询拦截DataGrid 导入入口在只读连接下禁用
- 后端为查询、DDL、导入、清表、同步等写入口增加统一只读守卫
- MongoDB 查询判定改为命令级白名单,不再把所有 JSON 命令都视为只读
- 补充前端/后端定向测试与需求追踪文档
- 进行中:
- 等待体验包验证连接弹窗、查询拦截和写操作拒绝文案
- 待处理:
- 如需进一步优化体验,再补侧栏对象级写菜单的前端隐藏/禁用
## 5. 风险与阻塞
- 风险:
- 前端 SQL/Mongo 只读判定是保守策略,边界命令可能仍需后端兜底
- 现有部分侧栏写菜单仍可能显示,但执行时会被后端拒绝
- 阻塞:
- 暂无
- 缓解措施:
- 关键写入口统一走后端守卫;前端只负责提前反馈与减少误操作
## 6. 决策记录
- 决策 1只对 SQL 类数据库和 MongoDB 支持连接级生产保护,其他数据源忽略 `readOnly`
- 决策 2采用顶层 `readOnly` 布尔字段,避免新增环境枚举和迁移成本
- 决策 3MongoDB 只读判定按命令白名单处理,防止把写命令误放行
## 7. 验证记录
- 验证项:
- 前端数据源能力判定、RPC 配置、连接配置测试
- 后端只读连接守卫与 SQL/Mongo 查询判定测试
- 结果:
- 通过
- 证据(日志/截图/链接):
- `go test ./internal/app -run 'Test(EnsureReadOnlyConnectionAllows|SupportsConnectionReadOnlyMode|IsReadOnlySQLQuery)'`
- `npm --prefix frontend test -- src/utils/dataSourceCapabilities.test.ts src/utils/connectionRpcConfig.test.ts src/components/connectionModal/connectionModalConfig.keepalive.test.ts`
## 8. 下一步
- 下一步行动:
- 用体验包回归验证生产连接下的查询、导入、建库删库、同步目标和 Mongo 命令拦截
- 如需更完整 UX再补侧栏写菜单的只读态收口
- 负责人:
- Codex

View File

@@ -25,6 +25,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/bootstrap.ts"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -2,15 +2,12 @@
"name": "gonavi-client",
"private": true,
"version": "0.6.5",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"postinstall": "patch-package --error-on-fail",
"i18n:scan": "go run ../tools/i18n-scan --root .."
"test": "vitest run"
},
"dependencies": {
"@ant-design/icons": "^5.2.6",
@@ -19,11 +16,9 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.6.0",
"@types/dagre": "^0.7.54",
"@types/react-syntax-highlighter": "^15.5.13",
"antd": "^5.12.0",
"clsx": "^2.1.0",
"dagre": "^0.8.5",
"fflate": "^0.8.3",
"mermaid": "^11.13.0",
"react": "^18.2.0",
@@ -31,7 +26,6 @@
"react-markdown": "^10.1.0",
"react-resizable": "^3.1.3",
"react-syntax-highlighter": "^16.1.1",
"reactflow": "^11.11.4",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"sql-formatter": "^15.7.0",
@@ -45,7 +39,6 @@
"@types/react-test-renderer": "^18.0.7",
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.1",
"patch-package": "^8.0.1",
"react-test-renderer": "^18.2.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",

View File

@@ -1 +1 @@
339d5c974e6ab5cba2d5def171f5ce0a
416aaa5c6e66a62430103d6905ad9465

View File

@@ -1,461 +0,0 @@
diff --git a/node_modules/rc-table/es/VirtualTable/BodyGrid.js b/node_modules/rc-table/es/VirtualTable/BodyGrid.js
index 42dcde8..2cca743 100644
--- a/node_modules/rc-table/es/VirtualTable/BodyGrid.js
+++ b/node_modules/rc-table/es/VirtualTable/BodyGrid.js
@@ -23,6 +23,8 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
sticky = _useContext2.sticky,
scrollY = _useContext2.scrollY,
listItemHeight = _useContext2.listItemHeight,
+ listItemHeightFixed = _useContext2.listItemHeightFixed,
+ listItemColumnVirtual = _useContext2.listItemColumnVirtual,
getComponent = _useContext2.getComponent,
onTablePropScroll = _useContext2.onScroll;
@@ -113,8 +115,14 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
var extraRender = function extraRender(info) {
+ // The column-window fast path explicitly requires rows without spans.
+ // Avoid scanning every column on each vertical window update.
+ if (listItemColumnVirtual) {
+ return null;
+ }
var start = info.start,
end = info.end,
getSize = info.getSize,
- offsetY = info.offsetY;
+ offsetY = info.offsetY,
+ offsetX = info.offsetX;
// Do nothing if no data
if (end < 0) {
@@ -198,6 +200,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
style: {
top: -offsetY + sizeInfo.top
},
+ offsetX: offsetX,
extra: true,
getHeight: getHeight
});
@@ -239,6 +242,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
className: tblPrefixCls,
height: scrollY,
itemHeight: listItemHeight || 24,
+ itemHeightFixed: listItemHeightFixed,
data: flattenData,
itemKey: function itemKey(item) {
return getRowKey(item.record);
@@ -262,6 +266,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
data: item,
rowKey: rowKey,
index: index,
+ offsetX: itemProps.offsetX,
style: itemProps.style
});
}));
diff --git a/node_modules/rc-table/es/VirtualTable/BodyLine.js b/node_modules/rc-table/es/VirtualTable/BodyLine.js
index c760819..1a6c396 100644
--- a/node_modules/rc-table/es/VirtualTable/BodyLine.js
+++ b/node_modules/rc-table/es/VirtualTable/BodyLine.js
@@ -2,7 +2,7 @@ import _extends from "@babel/runtime/helpers/esm/extends";
import _objectSpread from "@babel/runtime/helpers/esm/objectSpread2";
import _defineProperty from "@babel/runtime/helpers/esm/defineProperty";
import _objectWithoutProperties from "@babel/runtime/helpers/esm/objectWithoutProperties";
-var _excluded = ["data", "index", "className", "rowKey", "style", "extra", "getHeight"];
+var _excluded = ["data", "index", "className", "rowKey", "style", "offsetX", "extra", "getHeight"];
import { useContext } from '@rc-component/context';
import classNames from 'classnames';
import * as React from 'react';
@@ -10,7 +10,7 @@ import Cell from "../Cell";
import TableContext, { responseImmutable } from "../context/TableContext";
import useRowInfo from "../hooks/useRowInfo";
import VirtualCell from "./VirtualCell";
-import { StaticContext } from "./context";
+import { GridContext, StaticContext } from "./context";
import { computedExpandedClassName } from "../utils/expandUtil";
var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
var data = props.data,
@@ -18,20 +18,25 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
className = props.className,
rowKey = props.rowKey,
style = props.style,
+ offsetX = props.offsetX,
extra = props.extra,
getHeight = props.getHeight,
restProps = _objectWithoutProperties(props, _excluded);
var record = data.record,
indent = data.indent,
renderIndex = data.index;
- var _useContext = useContext(TableContext, ['prefixCls', 'flattenColumns', 'fixColumn', 'componentWidth', 'scrollX']),
+ var _useContext = useContext(TableContext, ['prefixCls', 'flattenColumns', 'fixColumn', 'componentWidth', 'scrollX', 'direction']),
scrollX = _useContext.scrollX,
flattenColumns = _useContext.flattenColumns,
prefixCls = _useContext.prefixCls,
fixColumn = _useContext.fixColumn,
- componentWidth = _useContext.componentWidth;
- var _useContext2 = useContext(StaticContext, ['getComponent']),
- getComponent = _useContext2.getComponent;
+ componentWidth = _useContext.componentWidth,
+ direction = _useContext.direction;
+ var _useContext2 = useContext(StaticContext, ['getComponent', 'listItemColumnVirtual']),
+ getComponent = _useContext2.getComponent,
+ listItemColumnVirtual = _useContext2.listItemColumnVirtual;
+ var _useContext3 = useContext(GridContext, ['columnsOffset']),
+ columnsOffset = _useContext3.columnsOffset;
var rowInfo = useRowInfo(record, rowKey, index, indent);
var RowComponent = getComponent(['body', 'row'], 'div');
var cellComponent = getComponent(['body', 'cell'], 'div');
@@ -71,12 +76,7 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
rowStyle.position = 'absolute';
rowStyle.pointerEvents = 'none';
}
- var rowNode = /*#__PURE__*/React.createElement(RowComponent, _extends({}, rowProps, restProps, {
- "data-row-key": rowKey,
- ref: rowSupportExpand ? null : ref,
- className: classNames(className, "".concat(prefixCls, "-row"), rowProps === null || rowProps === void 0 ? void 0 : rowProps.className, _defineProperty({}, "".concat(prefixCls, "-row-extra"), extra)),
- style: _objectSpread(_objectSpread({}, rowStyle), rowProps === null || rowProps === void 0 ? void 0 : rowProps.style)
- }), flattenColumns.map(function (column, colIndex) {
+ var renderVirtualCell = function renderVirtualCell(column, colIndex) {
return /*#__PURE__*/React.createElement(VirtualCell, {
key: colIndex,
component: cellComponent,
@@ -90,7 +90,60 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
inverse: extra,
getHeight: getHeight
});
- }));
+ };
+ var columnNodes;
+ if (listItemColumnVirtual && direction !== 'rtl' && !extra && columnsOffset && columnsOffset.length === flattenColumns.length) {
+ var mergedOffsetX = Math.max(0, offsetX || 0);
+ var viewportWidth = Math.max(0, componentWidth || 0);
+ var overscanWidth = Math.max(320, viewportWidth);
+ var visibleStart = Math.max(0, mergedOffsetX - overscanWidth);
+ var visibleEnd = mergedOffsetX + viewportWidth + overscanWidth;
+ var nodes = [];
+ var spacerStart = -1;
+ var spacerWidth = 0;
+ var flushSpacer = function flushSpacer() {
+ if (spacerWidth <= 0) return;
+ nodes.push(/*#__PURE__*/React.createElement(cellComponent, {
+ "aria-hidden": true,
+ className: "".concat(prefixCls, "-cell-virtual-spacer"),
+ key: "virtual-spacer-".concat(spacerStart),
+ style: {
+ border: 0,
+ flex: "0 0 ".concat(spacerWidth, "px"),
+ minWidth: 0,
+ padding: 0,
+ pointerEvents: 'none',
+ visibility: 'hidden',
+ width: "".concat(spacerWidth, "px")
+ }
+ }));
+ spacerStart = -1;
+ spacerWidth = 0;
+ };
+ flattenColumns.forEach(function (column, colIndex) {
+ var columnStart = colIndex > 0 ? columnsOffset[colIndex - 1] : 0;
+ var columnEnd = columnsOffset[colIndex] || columnStart;
+ var fixedColumn = column.fixed === true || column.fixed === 'left' || column.fixed === 'right';
+ var visibleColumn = fixedColumn || columnEnd >= visibleStart && columnStart <= visibleEnd;
+ if (visibleColumn) {
+ flushSpacer();
+ nodes.push(renderVirtualCell(column, colIndex));
+ } else {
+ if (spacerStart < 0) spacerStart = colIndex;
+ spacerWidth += Math.max(0, columnEnd - columnStart);
+ }
+ });
+ flushSpacer();
+ columnNodes = nodes;
+ } else {
+ columnNodes = flattenColumns.map(renderVirtualCell);
+ }
+ var rowNode = /*#__PURE__*/React.createElement(RowComponent, _extends({}, rowProps, restProps, {
+ "data-row-key": rowKey,
+ ref: rowSupportExpand ? null : ref,
+ className: classNames(className, "".concat(prefixCls, "-row"), rowProps === null || rowProps === void 0 ? void 0 : rowProps.className, _defineProperty({}, "".concat(prefixCls, "-row-extra"), extra)),
+ style: _objectSpread(_objectSpread({}, rowStyle), rowProps === null || rowProps === void 0 ? void 0 : rowProps.style)
+ }), columnNodes);
if (rowSupportExpand) {
return /*#__PURE__*/React.createElement("div", {
ref: ref
diff --git a/node_modules/rc-table/es/VirtualTable/context.d.ts b/node_modules/rc-table/es/VirtualTable/context.d.ts
index e558165..ccd74fc 100644
--- a/node_modules/rc-table/es/VirtualTable/context.d.ts
+++ b/node_modules/rc-table/es/VirtualTable/context.d.ts
@@ -3,6 +3,8 @@ import type { GetComponent, TableSticky } from '../interface';
export interface StaticContextProps {
scrollY: number;
listItemHeight: number;
+ listItemHeightFixed?: boolean;
+ listItemColumnVirtual?: boolean;
sticky: boolean | TableSticky;
getComponent: GetComponent;
onScroll?: React.UIEventHandler<HTMLDivElement>;
diff --git a/node_modules/rc-table/es/VirtualTable/index.d.ts b/node_modules/rc-table/es/VirtualTable/index.d.ts
index 31fe8e0..c5097b2 100644
--- a/node_modules/rc-table/es/VirtualTable/index.d.ts
+++ b/node_modules/rc-table/es/VirtualTable/index.d.ts
@@ -8,6 +8,9 @@ export interface VirtualTableProps<RecordType> extends Omit<TableProps<RecordTyp
y: number;
};
listItemHeight?: number;
+ listItemHeightFixed?: boolean;
+ /** LTR-only fast path; requires body cells without colSpan/rowSpan. */
+ listItemColumnVirtual?: boolean;
}
export type ForwardGenericVirtualTable = (<RecordType>(props: TableProps<RecordType> & React.RefAttributes<Reference>) => React.ReactElement) & {
displayName?: string;
diff --git a/node_modules/rc-table/es/VirtualTable/index.js b/node_modules/rc-table/es/VirtualTable/index.js
index fb00e9b..a778aa6 100644
--- a/node_modules/rc-table/es/VirtualTable/index.js
+++ b/node_modules/rc-table/es/VirtualTable/index.js
@@ -27,6 +27,8 @@ function VirtualTable(props, ref) {
prefixCls = _props$prefixCls === void 0 ? DEFAULT_PREFIX : _props$prefixCls,
className = props.className,
listItemHeight = props.listItemHeight,
+ listItemHeightFixed = props.listItemHeightFixed,
+ listItemColumnVirtual = props.listItemColumnVirtual,
components = props.components,
onScroll = props.onScroll;
var _ref = scroll || {},
@@ -61,10 +63,12 @@ function VirtualTable(props, ref) {
sticky: sticky,
scrollY: scrollY,
listItemHeight: listItemHeight,
+ listItemHeightFixed: listItemHeightFixed,
+ listItemColumnVirtual: listItemColumnVirtual,
getComponent: getComponent,
onScroll: onInternalScroll
};
- }, [sticky, scrollY, listItemHeight, getComponent, onInternalScroll]);
+ }, [sticky, scrollY, listItemHeight, listItemHeightFixed, listItemColumnVirtual, getComponent, onInternalScroll]);
// ========================== Render ==========================
return /*#__PURE__*/React.createElement(StaticContext.Provider, {
diff --git a/node_modules/rc-table/lib/VirtualTable/BodyGrid.js b/node_modules/rc-table/lib/VirtualTable/BodyGrid.js
index dee2539..b077789 100644
--- a/node_modules/rc-table/lib/VirtualTable/BodyGrid.js
+++ b/node_modules/rc-table/lib/VirtualTable/BodyGrid.js
@@ -33,6 +33,8 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
sticky = _useContext2.sticky,
scrollY = _useContext2.scrollY,
listItemHeight = _useContext2.listItemHeight,
+ listItemHeightFixed = _useContext2.listItemHeightFixed,
+ listItemColumnVirtual = _useContext2.listItemColumnVirtual,
getComponent = _useContext2.getComponent,
onTablePropScroll = _useContext2.onScroll;
@@ -123,8 +125,14 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
var extraRender = function extraRender(info) {
+ // The column-window fast path explicitly requires rows without spans.
+ // Avoid scanning every column on each vertical window update.
+ if (listItemColumnVirtual) {
+ return null;
+ }
var start = info.start,
end = info.end,
getSize = info.getSize,
- offsetY = info.offsetY;
+ offsetY = info.offsetY,
+ offsetX = info.offsetX;
// Do nothing if no data
if (end < 0) {
@@ -208,6 +210,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
style: {
top: -offsetY + sizeInfo.top
},
+ offsetX: offsetX,
extra: true,
getHeight: getHeight
});
@@ -249,6 +252,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
className: tblPrefixCls,
height: scrollY,
itemHeight: listItemHeight || 24,
+ itemHeightFixed: listItemHeightFixed,
data: flattenData,
itemKey: function itemKey(item) {
return getRowKey(item.record);
@@ -272,6 +276,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
data: item,
rowKey: rowKey,
index: index,
+ offsetX: itemProps.offsetX,
style: itemProps.style
});
}));
diff --git a/node_modules/rc-table/lib/VirtualTable/BodyLine.js b/node_modules/rc-table/lib/VirtualTable/BodyLine.js
index e1275fb..d962380 100644
--- a/node_modules/rc-table/lib/VirtualTable/BodyLine.js
+++ b/node_modules/rc-table/lib/VirtualTable/BodyLine.js
@@ -19,7 +19,7 @@ var _useRowInfo = _interopRequireDefault(require("../hooks/useRowInfo"));
var _VirtualCell = _interopRequireDefault(require("./VirtualCell"));
var _context2 = require("./context");
var _expandUtil = require("../utils/expandUtil");
-var _excluded = ["data", "index", "className", "rowKey", "style", "extra", "getHeight"];
+var _excluded = ["data", "index", "className", "rowKey", "style", "offsetX", "extra", "getHeight"];
function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(e) { return e ? t : r; })(e); }
function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != _typeof(e) && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && Object.prototype.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; }
var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
@@ -28,20 +28,25 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
className = props.className,
rowKey = props.rowKey,
style = props.style,
+ offsetX = props.offsetX,
extra = props.extra,
getHeight = props.getHeight,
restProps = (0, _objectWithoutProperties2.default)(props, _excluded);
var record = data.record,
indent = data.indent,
renderIndex = data.index;
- var _useContext = (0, _context.useContext)(_TableContext.default, ['prefixCls', 'flattenColumns', 'fixColumn', 'componentWidth', 'scrollX']),
+ var _useContext = (0, _context.useContext)(_TableContext.default, ['prefixCls', 'flattenColumns', 'fixColumn', 'componentWidth', 'scrollX', 'direction']),
scrollX = _useContext.scrollX,
flattenColumns = _useContext.flattenColumns,
prefixCls = _useContext.prefixCls,
fixColumn = _useContext.fixColumn,
- componentWidth = _useContext.componentWidth;
- var _useContext2 = (0, _context.useContext)(_context2.StaticContext, ['getComponent']),
- getComponent = _useContext2.getComponent;
+ componentWidth = _useContext.componentWidth,
+ direction = _useContext.direction;
+ var _useContext2 = (0, _context.useContext)(_context2.StaticContext, ['getComponent', 'listItemColumnVirtual']),
+ getComponent = _useContext2.getComponent,
+ listItemColumnVirtual = _useContext2.listItemColumnVirtual;
+ var _useContext3 = (0, _context.useContext)(_context2.GridContext, ['columnsOffset']),
+ columnsOffset = _useContext3.columnsOffset;
var rowInfo = (0, _useRowInfo.default)(record, rowKey, index, indent);
var RowComponent = getComponent(['body', 'row'], 'div');
var cellComponent = getComponent(['body', 'cell'], 'div');
@@ -81,12 +86,7 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
rowStyle.position = 'absolute';
rowStyle.pointerEvents = 'none';
}
- var rowNode = /*#__PURE__*/React.createElement(RowComponent, (0, _extends2.default)({}, rowProps, restProps, {
- "data-row-key": rowKey,
- ref: rowSupportExpand ? null : ref,
- className: (0, _classnames.default)(className, "".concat(prefixCls, "-row"), rowProps === null || rowProps === void 0 ? void 0 : rowProps.className, (0, _defineProperty2.default)({}, "".concat(prefixCls, "-row-extra"), extra)),
- style: (0, _objectSpread2.default)((0, _objectSpread2.default)({}, rowStyle), rowProps === null || rowProps === void 0 ? void 0 : rowProps.style)
- }), flattenColumns.map(function (column, colIndex) {
+ var renderVirtualCell = function renderVirtualCell(column, colIndex) {
return /*#__PURE__*/React.createElement(_VirtualCell.default, {
key: colIndex,
component: cellComponent,
@@ -100,7 +100,60 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
inverse: extra,
getHeight: getHeight
});
- }));
+ };
+ var columnNodes;
+ if (listItemColumnVirtual && direction !== 'rtl' && !extra && columnsOffset && columnsOffset.length === flattenColumns.length) {
+ var mergedOffsetX = Math.max(0, offsetX || 0);
+ var viewportWidth = Math.max(0, componentWidth || 0);
+ var overscanWidth = Math.max(320, viewportWidth);
+ var visibleStart = Math.max(0, mergedOffsetX - overscanWidth);
+ var visibleEnd = mergedOffsetX + viewportWidth + overscanWidth;
+ var nodes = [];
+ var spacerStart = -1;
+ var spacerWidth = 0;
+ var flushSpacer = function flushSpacer() {
+ if (spacerWidth <= 0) return;
+ nodes.push(/*#__PURE__*/React.createElement(cellComponent, {
+ "aria-hidden": true,
+ className: "".concat(prefixCls, "-cell-virtual-spacer"),
+ key: "virtual-spacer-".concat(spacerStart),
+ style: {
+ border: 0,
+ flex: "0 0 ".concat(spacerWidth, "px"),
+ minWidth: 0,
+ padding: 0,
+ pointerEvents: 'none',
+ visibility: 'hidden',
+ width: "".concat(spacerWidth, "px")
+ }
+ }));
+ spacerStart = -1;
+ spacerWidth = 0;
+ };
+ flattenColumns.forEach(function (column, colIndex) {
+ var columnStart = colIndex > 0 ? columnsOffset[colIndex - 1] : 0;
+ var columnEnd = columnsOffset[colIndex] || columnStart;
+ var fixedColumn = column.fixed === true || column.fixed === 'left' || column.fixed === 'right';
+ var visibleColumn = fixedColumn || columnEnd >= visibleStart && columnStart <= visibleEnd;
+ if (visibleColumn) {
+ flushSpacer();
+ nodes.push(renderVirtualCell(column, colIndex));
+ } else {
+ if (spacerStart < 0) spacerStart = colIndex;
+ spacerWidth += Math.max(0, columnEnd - columnStart);
+ }
+ });
+ flushSpacer();
+ columnNodes = nodes;
+ } else {
+ columnNodes = flattenColumns.map(renderVirtualCell);
+ }
+ var rowNode = /*#__PURE__*/React.createElement(RowComponent, (0, _extends2.default)({}, rowProps, restProps, {
+ "data-row-key": rowKey,
+ ref: rowSupportExpand ? null : ref,
+ className: (0, _classnames.default)(className, "".concat(prefixCls, "-row"), rowProps === null || rowProps === void 0 ? void 0 : rowProps.className, (0, _defineProperty2.default)({}, "".concat(prefixCls, "-row-extra"), extra)),
+ style: (0, _objectSpread2.default)((0, _objectSpread2.default)({}, rowStyle), rowProps === null || rowProps === void 0 ? void 0 : rowProps.style)
+ }), columnNodes);
if (rowSupportExpand) {
return /*#__PURE__*/React.createElement("div", {
ref: ref
diff --git a/node_modules/rc-table/lib/VirtualTable/context.d.ts b/node_modules/rc-table/lib/VirtualTable/context.d.ts
index e558165..ccd74fc 100644
--- a/node_modules/rc-table/lib/VirtualTable/context.d.ts
+++ b/node_modules/rc-table/lib/VirtualTable/context.d.ts
@@ -3,6 +3,8 @@ import type { GetComponent, TableSticky } from '../interface';
export interface StaticContextProps {
scrollY: number;
listItemHeight: number;
+ listItemHeightFixed?: boolean;
+ listItemColumnVirtual?: boolean;
sticky: boolean | TableSticky;
getComponent: GetComponent;
onScroll?: React.UIEventHandler<HTMLDivElement>;
diff --git a/node_modules/rc-table/lib/VirtualTable/index.d.ts b/node_modules/rc-table/lib/VirtualTable/index.d.ts
index 31fe8e0..c5097b2 100644
--- a/node_modules/rc-table/lib/VirtualTable/index.d.ts
+++ b/node_modules/rc-table/lib/VirtualTable/index.d.ts
@@ -8,6 +8,9 @@ export interface VirtualTableProps<RecordType> extends Omit<TableProps<RecordTyp
y: number;
};
listItemHeight?: number;
+ listItemHeightFixed?: boolean;
+ /** LTR-only fast path; requires body cells without colSpan/rowSpan. */
+ listItemColumnVirtual?: boolean;
}
export type ForwardGenericVirtualTable = (<RecordType>(props: TableProps<RecordType> & React.RefAttributes<Reference>) => React.ReactElement) & {
displayName?: string;
diff --git a/node_modules/rc-table/lib/VirtualTable/index.js b/node_modules/rc-table/lib/VirtualTable/index.js
index 8e31a9b..24ab161 100644
--- a/node_modules/rc-table/lib/VirtualTable/index.js
+++ b/node_modules/rc-table/lib/VirtualTable/index.js
@@ -38,6 +38,8 @@ function VirtualTable(props, ref) {
prefixCls = _props$prefixCls === void 0 ? _Table.DEFAULT_PREFIX : _props$prefixCls,
className = props.className,
listItemHeight = props.listItemHeight,
+ listItemHeightFixed = props.listItemHeightFixed,
+ listItemColumnVirtual = props.listItemColumnVirtual,
components = props.components,
onScroll = props.onScroll;
var _ref = scroll || {},
@@ -72,10 +74,12 @@ function VirtualTable(props, ref) {
sticky: sticky,
scrollY: scrollY,
listItemHeight: listItemHeight,
+ listItemHeightFixed: listItemHeightFixed,
+ listItemColumnVirtual: listItemColumnVirtual,
getComponent: getComponent,
onScroll: onInternalScroll
};
- }, [sticky, scrollY, listItemHeight, getComponent, onInternalScroll]);
+ }, [sticky, scrollY, listItemHeight, listItemHeightFixed, listItemColumnVirtual, getComponent, onInternalScroll]);
// ========================== Render ==========================
return /*#__PURE__*/React.createElement(_context.StaticContext.Provider, {

View File

@@ -1,336 +0,0 @@
diff --git a/node_modules/rc-virtual-list/es/List.d.ts b/node_modules/rc-virtual-list/es/List.d.ts
index c517366..e414513 100644
--- a/node_modules/rc-virtual-list/es/List.d.ts
+++ b/node_modules/rc-virtual-list/es/List.d.ts
@@ -20,6 +20,8 @@ export interface ListProps<T> extends Omit<React.HTMLAttributes<any>, 'children'
data: T[];
height?: number;
itemHeight?: number;
+ /** Treat every item as exactly itemHeight and calculate the visible range in O(1). */
+ itemHeightFixed?: boolean;
/** If not match virtual scroll condition, Set List still use height of container. */
fullHeight?: boolean;
itemKey: React.Key | ((item: T) => React.Key);
diff --git a/node_modules/rc-virtual-list/es/List.js b/node_modules/rc-virtual-list/es/List.js
index 2926056..e917ba1 100644
--- a/node_modules/rc-virtual-list/es/List.js
+++ b/node_modules/rc-virtual-list/es/List.js
@@ -4,7 +4,7 @@ import _objectSpread from "@babel/runtime/helpers/esm/objectSpread2";
import _defineProperty from "@babel/runtime/helpers/esm/defineProperty";
import _slicedToArray from "@babel/runtime/helpers/esm/slicedToArray";
import _objectWithoutProperties from "@babel/runtime/helpers/esm/objectWithoutProperties";
-var _excluded = ["prefixCls", "className", "height", "itemHeight", "fullHeight", "style", "data", "children", "itemKey", "virtual", "direction", "scrollWidth", "component", "onScroll", "onVirtualScroll", "onVisibleChange", "innerProps", "extraRender", "styles", "showScrollBar"];
+var _excluded = ["prefixCls", "className", "height", "itemHeight", "itemHeightFixed", "fullHeight", "style", "data", "children", "itemKey", "virtual", "direction", "scrollWidth", "component", "onScroll", "onVirtualScroll", "onVisibleChange", "innerProps", "extraRender", "styles", "showScrollBar"];
import classNames from 'classnames';
import ResizeObserver from 'rc-resize-observer';
import { useEvent } from 'rc-util';
@@ -35,6 +35,7 @@ export function RawList(props, ref) {
className = props.className,
height = props.height,
itemHeight = props.itemHeight,
+ itemHeightFixed = props.itemHeightFixed,
_props$fullHeight = props.fullHeight,
fullHeight = _props$fullHeight === void 0 ? true : _props$fullHeight,
style = props.style,
@@ -65,7 +66,7 @@ export function RawList(props, ref) {
}, [itemKey]);
// ================================ Height ================================
- var _useHeights = useHeights(getKey, null, null),
+ var _useHeights = useHeights(getKey, null, null, itemHeightFixed),
_useHeights2 = _slicedToArray(_useHeights, 4),
setInstanceRef = _useHeights2[0],
collectHeight = _useHeights2[1],
@@ -74,11 +75,12 @@ export function RawList(props, ref) {
// ================================= MISC =================================
var useVirtual = !!(virtual !== false && height && itemHeight);
- var containerHeight = React.useMemo(function () {
+ var measuredContainerHeight = React.useMemo(function () {
return Object.values(heights.maps).reduce(function (total, curr) {
return total + curr;
}, 0);
}, [heights.id, heights.maps]);
+ var containerHeight = itemHeightFixed ? itemHeight * (data || EMPTY_DATA).length : measuredContainerHeight;
var inVirtual = useVirtual && data && (Math.max(itemHeight * data.length, containerHeight) > height || !!scrollWidth);
var isRTL = direction === 'rtl';
var mergedClassName = classNames(prefixCls, _defineProperty({}, "".concat(prefixCls, "-rtl"), isRTL), className);
@@ -159,6 +161,28 @@ export function RawList(props, ref) {
offset: undefined
};
}
+ if (itemHeightFixed && itemHeight > 0) {
+ var fixedDataLen = mergedData.length;
+ var fixedScrollHeight = fixedDataLen * itemHeight;
+ if (fixedDataLen === 0) {
+ return {
+ scrollHeight: 0,
+ start: 0,
+ end: -1,
+ offset: 0
+ };
+ }
+ var fixedMaxScrollTop = Math.max(0, fixedScrollHeight - height);
+ var fixedOffsetTop = Math.max(0, Math.min(fixedMaxScrollTop, offsetTop));
+ var fixedStartIndex = Math.min(fixedDataLen - 1, Math.max(0, Math.ceil(fixedOffsetTop / itemHeight) - 1));
+ var fixedEndIndex = Math.min(fixedDataLen - 1, Math.floor((fixedOffsetTop + height) / itemHeight) + 1);
+ return {
+ scrollHeight: fixedScrollHeight,
+ start: fixedStartIndex,
+ end: fixedEndIndex,
+ offset: fixedStartIndex * itemHeight
+ };
+ }
var itemTop = 0;
var startIndex;
var startOffset;
@@ -201,7 +225,7 @@ export function RawList(props, ref) {
end: endIndex,
offset: startOffset
};
- }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height]),
+ }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height, itemHeightFixed, itemHeight]),
scrollHeight = _React$useMemo.scrollHeight,
start = _React$useMemo.start,
end = _React$useMemo.end,
@@ -213,6 +237,10 @@ export function RawList(props, ref) {
// Which will make scroll jump.
// Let's sync scroll top to avoid jump
React.useLayoutEffect(function () {
+ if (itemHeightFixed) {
+ heights.resetRecord();
+ return;
+ }
var changedRecord = heights.getRecord();
if (changedRecord.size === 1) {
var recordKey = Array.from(changedRecord.keys())[0];
@@ -232,7 +260,7 @@ export function RawList(props, ref) {
}
}
heights.resetRecord();
- }, [scrollHeight]);
+ }, [scrollHeight, itemHeightFixed, heightUpdatedMark]);
// ================================= Size =================================
var _React$useState = React.useState({
diff --git a/node_modules/rc-virtual-list/es/hooks/useHeights.d.ts b/node_modules/rc-virtual-list/es/hooks/useHeights.d.ts
--- a/node_modules/rc-virtual-list/es/hooks/useHeights.d.ts
+++ b/node_modules/rc-virtual-list/es/hooks/useHeights.d.ts
@@ -1,6 +1,6 @@
import type { GetKey } from '../interface';
import CacheMap from '../utils/CacheMap';
-export default function useHeights<T>(getKey: GetKey<T>, onItemAdd?: (item: T) => void, onItemRemove?: (item: T) => void): [
+export default function useHeights<T>(getKey: GetKey<T>, onItemAdd?: (item: T) => void, onItemRemove?: (item: T) => void, disabled?: boolean): [
setInstanceRef: (item: T, instance: HTMLElement) => void,
collectHeight: (sync?: boolean) => void,
cacheMap: CacheMap,
diff --git a/node_modules/rc-virtual-list/es/hooks/useHeights.js b/node_modules/rc-virtual-list/es/hooks/useHeights.js
index 0323aae..38e6e75 100644
--- a/node_modules/rc-virtual-list/es/hooks/useHeights.js
+++ b/node_modules/rc-virtual-list/es/hooks/useHeights.js
@@ -6,7 +6,7 @@ function parseNumber(value) {
var num = parseFloat(value);
return isNaN(num) ? 0 : num;
}
-export default function useHeights(getKey, onItemAdd, onItemRemove) {
+export default function useHeights(getKey, onItemAdd, onItemRemove, disabled) {
var _React$useState = React.useState(0),
_React$useState2 = _slicedToArray(_React$useState, 2),
updatedMark = _React$useState2[0],
@@ -14,12 +14,17 @@ export default function useHeights(getKey, onItemAdd, onItemRemove) {
var instanceRef = useRef(new Map());
var heightsRef = useRef(new CacheMap());
var promiseIdRef = useRef(0);
+ var disabledRef = useRef(disabled);
+ disabledRef.current = disabled;
function cancelRaf() {
promiseIdRef.current += 1;
}
function collectHeight() {
var sync = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
cancelRaf();
+ if (disabledRef.current) {
+ return;
+ }
var doCollect = function doCollect() {
var changed = false;
instanceRef.current.forEach(function (element, key) {
@@ -79,5 +84,10 @@ export default function useHeights(getKey, onItemAdd, onItemRemove) {
useEffect(function () {
return cancelRaf;
}, []);
+ useEffect(function () {
+ if (!disabled) {
+ collectHeight(true);
+ }
+ }, [disabled]);
return [setInstanceRef, collectHeight, heightsRef.current, updatedMark];
}
diff --git a/node_modules/rc-virtual-list/lib/List.d.ts b/node_modules/rc-virtual-list/lib/List.d.ts
index c517366..e414513 100644
--- a/node_modules/rc-virtual-list/lib/List.d.ts
+++ b/node_modules/rc-virtual-list/lib/List.d.ts
@@ -20,6 +20,8 @@ export interface ListProps<T> extends Omit<React.HTMLAttributes<any>, 'children'
data: T[];
height?: number;
itemHeight?: number;
+ /** Treat every item as exactly itemHeight and calculate the visible range in O(1). */
+ itemHeightFixed?: boolean;
/** If not match virtual scroll condition, Set List still use height of container. */
fullHeight?: boolean;
itemKey: React.Key | ((item: T) => React.Key);
diff --git a/node_modules/rc-virtual-list/lib/List.js b/node_modules/rc-virtual-list/lib/List.js
index f0cb64d..d24a9ad 100644
--- a/node_modules/rc-virtual-list/lib/List.js
+++ b/node_modules/rc-virtual-list/lib/List.js
@@ -32,7 +32,7 @@ var _useScrollDrag = _interopRequireDefault(require("./hooks/useScrollDrag"));
var _useScrollTo = _interopRequireDefault(require("./hooks/useScrollTo"));
var _ScrollBar = _interopRequireDefault(require("./ScrollBar"));
var _scrollbarUtil = require("./utils/scrollbarUtil");
-var _excluded = ["prefixCls", "className", "height", "itemHeight", "fullHeight", "style", "data", "children", "itemKey", "virtual", "direction", "scrollWidth", "component", "onScroll", "onVirtualScroll", "onVisibleChange", "innerProps", "extraRender", "styles", "showScrollBar"];
+var _excluded = ["prefixCls", "className", "height", "itemHeight", "itemHeightFixed", "fullHeight", "style", "data", "children", "itemKey", "virtual", "direction", "scrollWidth", "component", "onScroll", "onVirtualScroll", "onVisibleChange", "innerProps", "extraRender", "styles", "showScrollBar"];
var EMPTY_DATA = [];
var ScrollStyle = {
overflowY: 'auto',
@@ -44,6 +44,7 @@ function RawList(props, ref) {
className = props.className,
height = props.height,
itemHeight = props.itemHeight,
+ itemHeightFixed = props.itemHeightFixed,
_props$fullHeight = props.fullHeight,
fullHeight = _props$fullHeight === void 0 ? true : _props$fullHeight,
style = props.style,
@@ -74,7 +75,7 @@ function RawList(props, ref) {
}, [itemKey]);
// ================================ Height ================================
- var _useHeights = (0, _useHeights3.default)(getKey, null, null),
+ var _useHeights = (0, _useHeights3.default)(getKey, null, null, itemHeightFixed),
_useHeights2 = (0, _slicedToArray2.default)(_useHeights, 4),
setInstanceRef = _useHeights2[0],
collectHeight = _useHeights2[1],
@@ -83,11 +84,12 @@ function RawList(props, ref) {
// ================================= MISC =================================
var useVirtual = !!(virtual !== false && height && itemHeight);
- var containerHeight = React.useMemo(function () {
+ var measuredContainerHeight = React.useMemo(function () {
return Object.values(heights.maps).reduce(function (total, curr) {
return total + curr;
}, 0);
}, [heights.id, heights.maps]);
+ var containerHeight = itemHeightFixed ? itemHeight * (data || EMPTY_DATA).length : measuredContainerHeight;
var inVirtual = useVirtual && data && (Math.max(itemHeight * data.length, containerHeight) > height || !!scrollWidth);
var isRTL = direction === 'rtl';
var mergedClassName = (0, _classnames.default)(prefixCls, (0, _defineProperty2.default)({}, "".concat(prefixCls, "-rtl"), isRTL), className);
@@ -168,6 +170,28 @@ function RawList(props, ref) {
offset: undefined
};
}
+ if (itemHeightFixed && itemHeight > 0) {
+ var fixedDataLen = mergedData.length;
+ var fixedScrollHeight = fixedDataLen * itemHeight;
+ if (fixedDataLen === 0) {
+ return {
+ scrollHeight: 0,
+ start: 0,
+ end: -1,
+ offset: 0
+ };
+ }
+ var fixedMaxScrollTop = Math.max(0, fixedScrollHeight - height);
+ var fixedOffsetTop = Math.max(0, Math.min(fixedMaxScrollTop, offsetTop));
+ var fixedStartIndex = Math.min(fixedDataLen - 1, Math.max(0, Math.ceil(fixedOffsetTop / itemHeight) - 1));
+ var fixedEndIndex = Math.min(fixedDataLen - 1, Math.floor((fixedOffsetTop + height) / itemHeight) + 1);
+ return {
+ scrollHeight: fixedScrollHeight,
+ start: fixedStartIndex,
+ end: fixedEndIndex,
+ offset: fixedStartIndex * itemHeight
+ };
+ }
var itemTop = 0;
var startIndex;
var startOffset;
@@ -210,7 +234,7 @@ function RawList(props, ref) {
end: endIndex,
offset: startOffset
};
- }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height]),
+ }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height, itemHeightFixed, itemHeight]),
scrollHeight = _React$useMemo.scrollHeight,
start = _React$useMemo.start,
end = _React$useMemo.end,
@@ -222,6 +246,10 @@ function RawList(props, ref) {
// Which will make scroll jump.
// Let's sync scroll top to avoid jump
React.useLayoutEffect(function () {
+ if (itemHeightFixed) {
+ heights.resetRecord();
+ return;
+ }
var changedRecord = heights.getRecord();
if (changedRecord.size === 1) {
var recordKey = Array.from(changedRecord.keys())[0];
@@ -241,7 +269,7 @@ function RawList(props, ref) {
}
}
heights.resetRecord();
- }, [scrollHeight]);
+ }, [scrollHeight, itemHeightFixed, heightUpdatedMark]);
// ================================= Size =================================
var _React$useState = React.useState({
diff --git a/node_modules/rc-virtual-list/lib/hooks/useHeights.d.ts b/node_modules/rc-virtual-list/lib/hooks/useHeights.d.ts
--- a/node_modules/rc-virtual-list/lib/hooks/useHeights.d.ts
+++ b/node_modules/rc-virtual-list/lib/hooks/useHeights.d.ts
@@ -1,6 +1,6 @@
import type { GetKey } from '../interface';
import CacheMap from '../utils/CacheMap';
-export default function useHeights<T>(getKey: GetKey<T>, onItemAdd?: (item: T) => void, onItemRemove?: (item: T) => void): [
+export default function useHeights<T>(getKey: GetKey<T>, onItemAdd?: (item: T) => void, onItemRemove?: (item: T) => void, disabled?: boolean): [
setInstanceRef: (item: T, instance: HTMLElement) => void,
collectHeight: (sync?: boolean) => void,
cacheMap: CacheMap,
diff --git a/node_modules/rc-virtual-list/lib/hooks/useHeights.js b/node_modules/rc-virtual-list/lib/hooks/useHeights.js
index 471ffc6..a6d2ce5 100644
--- a/node_modules/rc-virtual-list/lib/hooks/useHeights.js
+++ b/node_modules/rc-virtual-list/lib/hooks/useHeights.js
@@ -14,7 +14,7 @@ function parseNumber(value) {
var num = parseFloat(value);
return isNaN(num) ? 0 : num;
}
-function useHeights(getKey, onItemAdd, onItemRemove) {
+function useHeights(getKey, onItemAdd, onItemRemove, disabled) {
var _React$useState = React.useState(0),
_React$useState2 = (0, _slicedToArray2.default)(_React$useState, 2),
updatedMark = _React$useState2[0],
@@ -22,12 +22,17 @@ function useHeights(getKey, onItemAdd, onItemRemove) {
var instanceRef = (0, _react.useRef)(new Map());
var heightsRef = (0, _react.useRef)(new _CacheMap.default());
var promiseIdRef = (0, _react.useRef)(0);
+ var disabledRef = (0, _react.useRef)(disabled);
+ disabledRef.current = disabled;
function cancelRaf() {
promiseIdRef.current += 1;
}
function collectHeight() {
var sync = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
cancelRaf();
+ if (disabledRef.current) {
+ return;
+ }
var doCollect = function doCollect() {
var changed = false;
instanceRef.current.forEach(function (element, key) {
@@ -87,5 +92,10 @@ function useHeights(getKey, onItemAdd, onItemRemove) {
(0, _react.useEffect)(function () {
return cancelRaf;
}, []);
+ (0, _react.useEffect)(function () {
+ if (!disabled) {
+ collectHeight(true);
+ }
+ }, [disabled]);
return [setInstanceRef, collectHeight, heightsRef.current, updatedMark];
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -1,86 +0,0 @@
{
"version": 9,
"defaultId": "02",
"icons": [
{
"id": "01",
"slug": "database-hug",
"titleZh": "抱库小狗",
"titleEn": "Database hug",
"iconPath": "/brand-icons/01-database-hug.webp",
"fullPath": "/brand-icons/01-database-hug.webp"
},
{
"id": "02",
"slug": "database-search",
"titleZh": "搜库小狗",
"titleEn": "Database search",
"iconPath": "/brand-icons/02-database-search.webp",
"fullPath": "/brand-icons/02-database-search.webp"
},
{
"id": "03",
"slug": "bandana-badge",
"titleZh": "头巾徽章",
"titleEn": "Bandana badge",
"iconPath": "/brand-icons/03-bandana-badge.webp",
"fullPath": "/brand-icons/03-bandana-badge.webp"
},
{
"id": "04",
"slug": "magnifier-wink",
"titleZh": "放大镜眨眼",
"titleEn": "Magnifier wink",
"iconPath": "/brand-icons/04-magnifier-wink.webp",
"fullPath": "/brand-icons/04-magnifier-wink.webp"
},
{
"id": "05",
"slug": "window-peek",
"titleZh": "窗口探头",
"titleEn": "Window peek",
"iconPath": "/brand-icons/05-window-peek.webp",
"fullPath": "/brand-icons/05-window-peek.webp"
},
{
"id": "06",
"slug": "hex-collar",
"titleZh": "六边项圈",
"titleEn": "Hex collar",
"iconPath": "/brand-icons/06-hex-collar.webp",
"fullPath": "/brand-icons/06-hex-collar.webp"
},
{
"id": "07",
"slug": "graph-sit",
"titleZh": "关系图",
"titleEn": "Graph sit",
"iconPath": "/brand-icons/07-graph-sit.webp",
"fullPath": "/brand-icons/07-graph-sit.webp"
},
{
"id": "08",
"slug": "cloud-banner",
"titleZh": "云朵横幅",
"titleEn": "Cloud banner",
"iconPath": "/brand-icons/08-cloud-banner.webp",
"fullPath": "/brand-icons/08-cloud-banner.webp"
},
{
"id": "09",
"slug": "terminal-sit",
"titleZh": "终端旁坐",
"titleEn": "Terminal sit",
"iconPath": "/brand-icons/09-terminal-sit.webp",
"fullPath": "/brand-icons/09-terminal-sit.webp"
},
{
"id": "10",
"slug": "compass-bandana",
"titleZh": "罗盘头巾",
"titleEn": "Compass bandana",
"iconPath": "/brand-icons/10-compass-bandana.webp",
"fullPath": "/brand-icons/10-compass-bandana.webp"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1 +0,0 @@
<svg width="163" height="40" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="prefix__a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="2" width="53" height="36"><path d="M52.503 2.501H.003v35h52.5v-35z" fill="#fff"/></mask><g mask="url(#prefix__a)"><path d="M17.503 2.501c-9.665 0-17.5 7.835-17.5 17.5s7.835 17.5 17.5 17.5 17.5-7.835 17.5-17.5-7.835-17.5-17.5-17.5z" fill="#327EFF"/><path d="M35.003 2.501c-9.665 0-17.5 7.835-17.5 17.5s7.835 17.5 17.5 17.5 17.5-7.834 17.5-17.5c0-9.665-7.835-17.5-17.5-17.5z" fill="#FFDE2D"/><path d="M17.503 20.002c0-9.665 7.835-17.5 17.5-17.5v17.5h-17.5z" fill="#FF6446"/><path d="M35.003 20.001c0 9.665-7.835 17.5-17.5 17.5v-17.5h17.5z" fill="#FF6446"/></g><path d="M68.543 30.45c-6.03 0-10.32-4.65-10.32-11.1 0-6.36 3.96-11.22 10.29-11.22 5.28 0 8.4 3.15 8.85 7.23h-4.32c-.39-2.16-2.01-3.57-4.53-3.57-3.96 0-5.85 3.3-5.85 7.56 0 4.38 2.28 7.53 5.88 7.53 2.55 0 4.35-1.53 4.62-3.78h4.26a7.596 7.596 0 01-2.37 5.07c-1.47 1.38-3.54 2.28-6.51 2.28zM83.41 8.55v8.07h.09c1.11-1.62 2.37-2.43 4.47-2.43 3.18 0 5.31 2.4 5.31 5.76V30H89.2v-9.45c0-1.65-.96-2.82-2.67-2.82-1.8 0-3.12 1.44-3.12 3.54V30h-4.08V8.55h4.08zm16.315 6.06v2.46h.09c.93-1.74 1.98-2.64 3.78-2.64.45 0 .72.03.96.12v3.57h-.09c-2.67-.27-4.59 1.14-4.59 4.38V30h-4.08V14.61h3.93zm13.438 15.84c-4.77 0-8.04-3.54-8.04-8.13 0-4.59 3.27-8.13 8.04-8.13s8.04 3.54 8.04 8.13c0 4.59-3.27 8.13-8.04 8.13zm0-3.12c2.49 0 3.9-2.01 3.9-5.01s-1.41-5.04-3.9-5.04c-2.52 0-3.9 2.04-3.9 5.04s1.38 5.01 3.9 5.01zM123.1 30V14.61h3.93v2.07h.09c.84-1.41 2.34-2.49 4.47-2.49 1.95 0 3.51 1.08 4.26 2.7h.06c1.05-1.68 2.67-2.7 4.62-2.7 3.24 0 5.07 2.1 5.07 5.46V30h-4.08v-9.66c0-1.74-.87-2.64-2.37-2.64-1.71 0-2.76 1.32-2.76 3.36V30h-4.08v-9.66c0-1.74-.87-2.64-2.37-2.64-1.65 0-2.76 1.32-2.76 3.36V30h-4.08zm34.71 0c-.24-.3-.39-1.02-.48-1.71h-.06c-.78 1.17-1.89 2.07-4.53 2.07-3.15 0-5.37-1.65-5.37-4.71 0-3.39 2.76-4.47 6.18-4.95 2.55-.36 3.72-.57 3.72-1.74 0-1.11-.87-1.83-2.58-1.83-1.92 0-2.85.69-2.97 2.16h-3.63c.12-2.7 2.13-5.07 6.629-5.07 4.621 0 6.481 2.07 6.481 5.67v7.83c0 1.17.18 1.86.54 2.13V30h-3.93zm-4.08-2.49c2.34 0 3.63-1.44 3.63-2.94v-2.31c-.72.42-1.83.66-2.851.9-2.129.48-3.179.96-3.179 2.4s.96 1.95 2.4 1.95z" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1"
id="Õ_xBA__x2264__x201E__1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 107.7 107.7"
style="enable-background:new 0 0 107.7 107.7;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#9E2878;}
</style>
<g>
<g id="g1133" transform="translate(-244.51235,-228.78793)">
<path id="path1119" class="st0" d="M340.8,253.8c2.6-1,5.5,0.4,6.2,3c0.7,2.6-1.1,5.7-3.7,6.4c-3.2,0.6-6.2-1.4-9.3,0.2
c-3.1,1.7-4,5.2-4.7,8.3c-1.4,5-8.5,7.3-12.1,4.2c-3.3-2.4-3.4-7.8-0.2-11c2.2-2.5,5.9-3.3,8.8-2c2.7,1.2,6,1.7,8.6-0.4
C337.5,260.1,336.7,255.1,340.8,253.8L340.8,253.8z"/>
<path id="path1121" class="st0" d="M280.5,244.7c4.2-2.2,9.5,1.5,8.2,6.1c-1.4,5.4-0.7,11.5,2.9,15.5c3.4,4,9.8,4.8,14.6,1.9
c3.7-2.1,6-5.8,7.4-9.6c1-3.1,0.6-6.2,1.1-9.3c1-3.8,5.8-6,9.1-4.2c3.2,1.4,4,5.9,1.7,8.8c-1.4,2.2-4.2,2.7-6.3,3.8
c-3.6,1.9-6.5,4.9-8.4,8.6c-1.2,2.1-1.1,4.5-1.7,6.7c-0.9,1.9-3,2.7-4.8,2.9c-4.8,0.6-9.5,3-13,6.7c-1.7,1.7-3.1,4.2-5.6,4.2
c-2.7-0.2-4.7-2.6-7.4-2.7c-4.9-0.7-10.2,0.7-14.4,4c-3.7,3.1-9.4,0.8-9.6-3.7c-0.9-5.1,6.2-9.5,10.1-6.2c4.3,4,10.8,5.6,16.9,3.7
c5.6-1.9,9.7-8.3,8.6-14c-0.9-4.9-4.2-9.3-8.6-11.4c-1.7-0.8-3.6-1.6-4.2-3.5C275.6,250.2,277.3,246.2,280.5,244.7L280.5,244.7z"
/>
<path id="path1123" class="st0" d="M277.8,235.9c2.2-0.8,4.2,1.3,3.3,3.4c-0.6,2.1-3.7,2.7-4.8,1.1
C275,238.9,276,236.4,277.8,235.9z"/>
<path id="path1125" class="st0" d="M246.9,278.8c2.2-0.8,4.2,1.2,3.3,3.4c-0.6,2.1-3.7,2.7-4.8,1C244,281.9,245,279.3,246.9,278.8
L246.9,278.8z"/>
<path id="path1127" class="st0" d="M328.2,236.2c2.2-0.7,4.2,1.3,3.3,3.5c-0.6,2-3.7,2.7-4.8,1
C325.4,239.2,326.3,236.8,328.2,236.2z"/>
<path id="path1129" class="st0" d="M253.6,257.7c0.4-3.7,5.5-5.9,8.1-3.6c1.9,1.1,1.6,3.6,2.2,5.4c0.4,2.4,2.7,4.3,5.2,4.3
c3.2,0.3,6.4-2.3,9.5-1.2c4.8,1.2,6.5,7.5,3.2,11.5c-2.9,4.1-9.3,4.5-12,0.7c-2.4-2.8-0.5-7.1-2.7-10.1c-1.7-2.9-5.4-2.7-8.3-1.9
C255.8,263.8,253,260.7,253.6,257.7L253.6,257.7z"/>
<path id="path1131" class="st0" d="M300.8,230c3.3-1.9,7.5,0.9,6.7,4.6c0,2.3-2.2,3.6-3.5,5.2c-1.9,1.9-2.3,4.9-1.2,7
c1.3,2.9,4.9,4,5.5,7.2c1.2,4.8-3.3,10.1-8.2,9.8c-4.8,0.1-8.3-5-6.3-9.5c1.2-3.8,5.8-4.9,7.2-8.5c1.7-3.2-0.4-6.1-2.4-8.1
C296.7,235.6,297.9,231.4,300.8,230z"/>
</g>
<g id="g1149" transform="translate(-244.51235,-228.78793)">
<path id="path1135" class="st0" d="M256,311.5c-2.6,1-5.5-0.4-6.2-3c-0.7-2.6,1.1-5.7,3.7-6.4c3.2-0.6,6.2,1.4,9.3-0.2
c3.1-1.6,4-5.1,4.7-8.2c1.4-5,8.5-7.3,12.1-4.2c3.3,2.4,3.4,7.8,0.2,10.9c-2.2,2.5-5.9,3.3-8.8,2c-2.7-1.2-6-1.7-8.6,0.4
C259.1,305,259.9,310.2,256,311.5L256,311.5z"/>
<path id="path1137" class="st0" d="M316.1,320.5c-4.2,2.2-9.5-1.5-8.2-6c1.4-5.4,0.7-11.6-2.9-15.6c-3.4-4-9.8-4.7-14.6-1.9
c-3.7,2-6,5.7-7.4,9.5c-1,3.1-0.6,6.2-1.1,9.3c-1,3.8-5.8,6-9.1,4.3c-3.2-1.4-4-5.9-1.7-8.9c1.4-2.2,4.2-2.7,6.3-3.8
c3.6-1.9,6.5-4.9,8.4-8.6c1.2-2,1.1-4.5,1.7-6.6c0.9-1.9,3-2.7,4.8-2.9c4.8-0.6,9.5-3,13-6.7c1.7-1.6,3.1-4.2,5.6-4.1
c2.7,0.1,4.7,2.5,7.4,2.7c4.9,0.6,10.2-0.8,14.4-4c3.7-3.2,9.4-0.9,9.6,3.6c0.9,5.1-6.2,9.5-10.1,6.2c-4.3-4-10.8-5.6-16.9-3.7
c-5.6,1.9-9.7,8.3-8.6,14c0.9,4.8,4.2,9.3,8.6,11.3c1.7,0.8,3.6,1.6,4.2,3.5C321.2,314.9,319.4,319.1,316.1,320.5L316.1,320.5z"/>
<path id="path1139" class="st0" d="M318.9,329.4c-2.2,0.8-4.2-1.3-3.3-3.4c0.6-2.1,3.7-2.7,4.8-1.1
C321.7,326.3,320.7,328.8,318.9,329.4z"/>
<path id="path1141" class="st0" d="M349.9,286.5c-2.2,0.7-4.2-1.3-3.3-3.5c0.6-2.1,3.7-2.7,4.8-1
C352.8,283.5,351.8,285.9,349.9,286.5z"/>
<path id="path1143" class="st0" d="M268.5,329c-2.2,0.7-4.2-1.3-3.3-3.5c0.6-2,3.7-2.7,4.8-1C271.3,325.9,270.3,328.5,268.5,329z"
/>
<path id="path1145" class="st0" d="M343.1,307.4c-0.4,3.7-5.5,5.9-8.1,3.6c-1.9-1.1-1.6-3.6-2.2-5.4c-0.4-2.4-2.7-4.3-5.2-4.3
c-3.2-0.3-6.4,2.3-9.5,1.2c-4.8-1.2-6.5-7.5-3.2-11.5c2.9-4.1,9.3-4.5,12-0.7c2.4,2.8,0.5,7,2.7,10c1.7,2.9,5.4,2.7,8.3,1.9
C341,301.4,343.8,304.4,343.1,307.4L343.1,307.4z"/>
<path id="path1147" class="st0" d="M295.8,335.2c-3.3,2-7.5-0.9-6.7-4.6c0-2.3,2.2-3.6,3.5-5.2c1.9-1.9,2.3-4.9,1.2-7
c-1.3-2.9-4.9-4-5.5-7.2c-1.2-4.8,3.3-10.1,8.2-9.8c4.8-0.1,8.3,5,6.3,9.6c-1.2,3.7-5.8,4.8-7.2,8.4c-1.7,3.2,0.4,6.1,2.4,8.2
C300,329.6,298.8,333.8,295.8,335.2L295.8,335.2z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#00A1EA"><title>Milvus</title><path d="M15.536 11.997a4.005 4.005 0 0 1-4.015 4.018c-2.36-.005-4.05-1.954-4.01-4.08a4.013 4.013 0 0 1 8.025.061zm8.463-.04a.823.823 0 0 1-.25.616q-1.122 1.128-2.248 2.25c-.082.083-.165.1-.25.054-.091-.048-.124-.121-.098-.242a12.764 12.764 0 0 0 .25-1.626 12.153 12.153 0 0 0 .028-1.49 11.56 11.56 0 0 0-.27-2.114.255.255 0 0 1 .017-.213.194.194 0 0 1 .273-.052.628.628 0 0 1 .086.075l2.174 2.174c.171.17.29.366.289.567zM11.226 20c-1.89-.002-3.488-.565-4.926-1.611a8.317 8.317 0 0 1-.952-.816q-1.125-1.112-2.253-2.221l-1.931-1.899c-.291-.285-.583-.57-.873-.857-.393-.39-.387-.83.012-1.217.296-.288.59-.579.885-.868q1.433-1.41 2.867-2.818c.532-.522 1.057-1.051 1.604-1.555a7.996 7.996 0 0 1 3.786-1.95A7.668 7.668 0 0 1 11.135 4c1.89.015 3.612.546 5.113 1.723a7.987 7.987 0 0 1 2.282 2.77 7.567 7.567 0 0 1 .76 2.556c.255 2.404-.414 4.518-2.046 6.312a7.772 7.772 0 0 1-3.927 2.366 8.995 8.995 0 0 1-2.091.274zm.364-2.137a5.819 5.819 0 0 0 .605-.036 5.35 5.35 0 0 0 .38-.05c1.525-.28 2.76-1.051 3.704-2.263.92-1.178 1.28-2.542 1.168-4.028a5.626 5.626 0 0 0-.631-2.168c-.627-1.213-1.596-2.067-2.817-2.646-1.096-.519-2.248-.649-3.443-.464A5.814 5.814 0 0 0 7.33 7.8c-.858.83-1.707 1.669-2.56 2.504-.426.419-.85.841-1.28 1.257-.274.266-.286.588-.013.857q.804.793 1.61 1.583c.722.709 1.44 1.42 2.165 2.126 1.184 1.153 2.604 1.737 4.339 1.738z"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

Some files were not shown because too many files have changed in this diff Show More